diff --git a/CHANGELOG.md b/CHANGELOG.md index 90b1223..aec941a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased * Fix issue with oversized batches on offline simulators (#210) +* Improve reporting of known API calls error scenarios (#211) ## qiskit-aqt-provider v1.9.0 diff --git a/docs/apidoc/api_client.rst b/docs/apidoc/api_client.rst index 3411749..3de7f9d 100644 --- a/docs/apidoc/api_client.rst +++ b/docs/apidoc/api_client.rst @@ -25,3 +25,7 @@ API client :members: :show-inheritance: :exclude-members: __init__, __new__, model_fields, model_computed_fields, model_config + +.. autoclass:: qiskit_aqt_provider.api_client.errors.APIError + :show-inheritance: + :exclude-members: __init__, __new__ diff --git a/qiskit_aqt_provider/api_client/errors.py b/qiskit_aqt_provider/api_client/errors.py new file mode 100644 index 0000000..2d2afad --- /dev/null +++ b/qiskit_aqt_provider/api_client/errors.py @@ -0,0 +1,58 @@ +# This code is part of Qiskit. +# +# (C) Copyright Alpine Quantum Technologies GmbH 2025 +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +import json +from typing import Any + +import httpx + + +class APIError(Exception): + """An API request failed. + + Instances of this class are raised when errors occur while communicating + with the target resource API (for both remote and direct-access resources). + + If the underlying API request failed with an error status, the exception chain + contains the original `httpx.HTTPStatusError `_ + """ + + def __init__(self, detail: Any) -> None: + """Initialize the exception instance. + + Args: + detail: error description payload. The string representation is used as error message. + """ + super().__init__(str(detail) if detail is not None else "Unspecified error") + + # Keep the original object, in case it wasn't a string. + self.detail = detail + + +def http_response_raise_for_status(response: httpx.Response) -> httpx.Response: + """Check the HTTP status of a response payload. + + Returns: + The passed HTTP response, unchanged. + + Raises: + APIError: the API response contains an error status. + """ + try: + return response.raise_for_status() + except httpx.HTTPStatusError as status_error: + try: + detail = response.json().get("detail") + except (json.JSONDecodeError, UnicodeDecodeError, AttributeError): + detail = None + + raise APIError(detail) from status_error diff --git a/qiskit_aqt_provider/api_client/portal_client.py b/qiskit_aqt_provider/api_client/portal_client.py index 8338b19..6f97198 100644 --- a/qiskit_aqt_provider/api_client/portal_client.py +++ b/qiskit_aqt_provider/api_client/portal_client.py @@ -13,6 +13,8 @@ import httpx +from qiskit_aqt_provider.api_client.errors import http_response_raise_for_status + from . import models from .versions import make_user_agent @@ -58,10 +60,9 @@ def workspaces(self) -> models.Workspaces: Raises: httpx.NetworkError: connection to the remote portal failed. - httpx.HTTPStatusError: something went wrong with the request to the remote portal. + APIError: something went wrong with the request to the remote portal. """ with self._http_client as client: - response = client.get("/workspaces") + response = http_response_raise_for_status(client.get("/workspaces")) - response.raise_for_status() return models.Workspaces.model_validate(response.json()) diff --git a/qiskit_aqt_provider/aqt_job.py b/qiskit_aqt_provider/aqt_job.py index a9da152..7c494c2 100644 --- a/qiskit_aqt_provider/aqt_job.py +++ b/qiskit_aqt_provider/aqt_job.py @@ -278,6 +278,7 @@ def submit(self) -> None: Raises: RuntimeError: this job was already submitted. + APIError: the operation failed on the remote portal. """ if self.job_id(): raise RuntimeError(f"Job already submitted (ID: {self.job_id()})") @@ -290,6 +291,9 @@ def status(self) -> JobStatus: Returns: Aggregated job status for all the circuits in this job. + + Raises: + APIError: the operation failed on the remote portal. """ payload = self._backend.result(uuid.UUID(self.job_id())) @@ -343,6 +347,9 @@ def result(self) -> Result: Returns: The combined result of all circuit evaluations. + + Raises: + APIError: the operation failed on the remote portal. """ if self.options.with_progress_bar: context: Union[tqdm[NoReturn], _MockProgressBar] = tqdm(total=len(self.circuits)) @@ -438,6 +445,9 @@ def result(self) -> Result: Returns: The combined result of all circuit evaluations. + + Raises: + APIError: the operation failed on the target resource. """ if self.options.with_progress_bar: context: Union[tqdm[NoReturn], _MockProgressBar] = tqdm(total=len(self.circuits)) diff --git a/qiskit_aqt_provider/aqt_provider.py b/qiskit_aqt_provider/aqt_provider.py index 874d37b..391728a 100644 --- a/qiskit_aqt_provider/aqt_provider.py +++ b/qiskit_aqt_provider/aqt_provider.py @@ -35,6 +35,7 @@ from typing_extensions import TypeAlias, override from qiskit_aqt_provider.api_client import PortalClient, Resource, ResourceType, Workspace +from qiskit_aqt_provider.api_client.errors import APIError from qiskit_aqt_provider.aqt_resource import ( AQTDirectAccessResource, AQTResource, @@ -234,7 +235,7 @@ def backends( # Only query if remote resources are requested. if backend_type != "offline_simulator": - with contextlib.suppress(httpx.HTTPError, httpx.NetworkError): + with contextlib.suppress(APIError, httpx.NetworkError): remote_workspaces = self._portal_client.workspaces().filter( name_pattern=name, backend_type=backend_type if backend_type else None, diff --git a/qiskit_aqt_provider/aqt_resource.py b/qiskit_aqt_provider/aqt_resource.py index 0f95841..83caf04 100644 --- a/qiskit_aqt_provider/aqt_resource.py +++ b/qiskit_aqt_provider/aqt_resource.py @@ -38,6 +38,7 @@ from qiskit_aqt_provider import api_client from qiskit_aqt_provider.api_client import models as api_models from qiskit_aqt_provider.api_client import models_direct as api_models_direct +from qiskit_aqt_provider.api_client.errors import http_response_raise_for_status from qiskit_aqt_provider.aqt_job import AQTDirectAccessJob, AQTJob from qiskit_aqt_provider.aqt_options import AQTDirectAccessOptions, AQTOptions from qiskit_aqt_provider.circuit_to_aqt import aqt_to_qiskit_circuit @@ -256,12 +257,12 @@ def submit(self, job: AQTJob) -> UUID: Returns: The unique identifier of the submitted job. """ - resp = self._http_client.post( - f"/submit/{self.resource_id.workspace_id}/{self.resource_id.resource_id}", - json=job.api_submit_payload.model_dump(), + resp = http_response_raise_for_status( + self._http_client.post( + f"/submit/{self.resource_id.workspace_id}/{self.resource_id.resource_id}", + json=job.api_submit_payload.model_dump(), + ) ) - - resp.raise_for_status() return api_models.Response.model_validate(resp.json()).job.job_id def result(self, job_id: UUID) -> api_models.JobResponse: @@ -278,8 +279,7 @@ def result(self, job_id: UUID) -> api_models.JobResponse: Returns: AQT API payload with the job results. """ - resp = self._http_client.get(f"/result/{job_id}") - resp.raise_for_status() + resp = http_response_raise_for_status(self._http_client.get(f"/result/{job_id}")) return api_models.Response.model_validate(resp.json()) @@ -343,8 +343,9 @@ def submit(self, circuit: api_models.QuantumCircuit) -> UUID: Returns: The unique identifier of the submitted job. """ - resp = self._http_client.put("/circuit", json=circuit.model_dump()) - resp.raise_for_status() + resp = http_response_raise_for_status( + self._http_client.put("/circuit", json=circuit.model_dump()) + ) return UUID(resp.json()) def result(self, job_id: UUID, *, timeout: Optional[float]) -> api_models_direct.JobResult: @@ -359,8 +360,9 @@ def result(self, job_id: UUID, *, timeout: Optional[float]) -> api_models_direct Returns: Job result, as API payload. """ - resp = self._http_client.get(f"/circuit/result/{job_id}", timeout=timeout) - resp.raise_for_status() + resp = http_response_raise_for_status( + self._http_client.get(f"/circuit/result/{job_id}", timeout=timeout) + ) return api_models_direct.JobResult.model_validate(resp.json()) diff --git a/tach.toml b/tach.toml index c227d48..702de44 100644 --- a/tach.toml +++ b/tach.toml @@ -37,6 +37,7 @@ expose = [ "__version__", # There are some instances of this, although not included in __all__ "models.*", + "errors.*", ] from = [ "qiskit_aqt_provider.api_client", diff --git a/test/api_client/test_errors.py b/test/api_client/test_errors.py new file mode 100644 index 0000000..f871313 --- /dev/null +++ b/test/api_client/test_errors.py @@ -0,0 +1,71 @@ +# This code is part of Qiskit. +# +# (C) Copyright Alpine Quantum Technologies GmbH 2025. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + + +from contextlib import AbstractContextManager +from typing import Any + +import httpx +import pytest + +from qiskit_aqt_provider.api_client.errors import APIError, http_response_raise_for_status + + +def test_http_response_raise_for_status_no_error() -> None: + """Test the wrapper around httpx.Response.raise_for_status when there is no error.""" + response = httpx.Response(status_code=httpx.codes.OK) + # Set a dummy request (required to call raise_for_status). + response.request = httpx.Request(method="GET", url="https://example.com") + + ret_response = http_response_raise_for_status(response) + + # The passed response is returned as-is. + assert ret_response is response + + +@pytest.mark.parametrize( + ("response", "expected"), + [ + pytest.param( + httpx.Response(status_code=httpx.codes.INTERNAL_SERVER_ERROR), + pytest.raises(APIError), + id="no-detail", + ), + pytest.param( + httpx.Response( + status_code=httpx.codes.INTERNAL_SERVER_ERROR, json={"detail": "error_message"} + ), + pytest.raises(APIError, match="error_message"), + id="with-detail", + ), + ], +) +def test_http_response_raise_for_status_error( + response: httpx.Response, expected: AbstractContextManager[pytest.ExceptionInfo[Any]] +) -> None: + """Test the wrapper around httpx.Response.raise_for_status when the response contains an error. + + The wrapper re-packs the httpx.HTTPStatusError into a custom APIError, sets + the latter's message to the error detail (if available), and propagates the + original exception as cause for the APIError. + """ + # Set dummy request (required to call raise_for_status). + response.request = httpx.Request(method="GET", url="https://example.com") + + with expected as excinfo: + http_response_raise_for_status(response) + + # Test cases all derive from a HTTP error status. + # Check that the exception chain has the relevant information. + status_error = excinfo.value.__cause__ + assert isinstance(status_error, httpx.HTTPStatusError) + assert status_error.response.status_code == response.status_code diff --git a/test/test_resource.py b/test/test_resource.py index 23e540a..c5f9d33 100644 --- a/test/test_resource.py +++ b/test/test_resource.py @@ -32,6 +32,7 @@ from qiskit_aqt_provider.api_client import models as api_models from qiskit_aqt_provider.api_client import models_direct as api_models_direct +from qiskit_aqt_provider.api_client.errors import APIError from qiskit_aqt_provider.aqt_job import AQTJob from qiskit_aqt_provider.aqt_options import AQTDirectAccessOptions, AQTOptions from qiskit_aqt_provider.aqt_resource import AQTResource @@ -328,16 +329,20 @@ def handle_submit(request: httpx.Request) -> httpx.Response: def test_submit_bad_request(httpx_mock: HTTPXMock) -> None: - """Check that AQTResource.submit raises an HTTPError if the request + """Check that AQTResource.submit raises an APIError if the request is flagged invalid by the server. """ backend = DummyResource("") httpx_mock.add_response(status_code=httpx.codes.BAD_REQUEST) job = AQTJob(backend, circuits=[empty_circuit(2)], options=AQTOptions(shots=10)) - with pytest.raises(httpx.HTTPError): + with pytest.raises(APIError) as excinfo: job.submit() + status_error = excinfo.value.__cause__ + assert isinstance(status_error, httpx.HTTPStatusError) + assert status_error.response.status_code == httpx.codes.BAD_REQUEST + def test_result_valid_response(httpx_mock: HTTPXMock) -> None: """Check that AQTResource.result passes the authorization token @@ -369,15 +374,19 @@ def handle_result(request: httpx.Request) -> httpx.Response: def test_result_bad_request(httpx_mock: HTTPXMock) -> None: - """Check that AQTResource.result raises an HTTPError if the request + """Check that AQTResource.result raises an APIError if the request is flagged invalid by the server. """ backend = DummyResource("") httpx_mock.add_response(status_code=httpx.codes.BAD_REQUEST) - with pytest.raises(httpx.HTTPError): + with pytest.raises(APIError) as excinfo: backend.result(uuid.uuid4()) + status_error = excinfo.value.__cause__ + assert isinstance(status_error, httpx.HTTPStatusError) + assert status_error.response.status_code == httpx.codes.BAD_REQUEST + def test_result_unknown_job(httpx_mock: HTTPXMock) -> None: """Check that AQTResource.result raises UnknownJobError if the API @@ -467,14 +476,36 @@ def test_offline_simulator_resource_propagate_memory_option( def test_direct_access_bad_request(httpx_mock: HTTPXMock) -> None: - """Check that direct-access resources raise a httpx.HTTPError on bad requests.""" + """Check that direct-access resources raise an APIError on bad requests.""" backend = DummyDirectAccessResource("token") httpx_mock.add_response(status_code=httpx.codes.BAD_REQUEST) job = backend.run(empty_circuit(2)) - with pytest.raises(httpx.HTTPError): + with pytest.raises(APIError) as excinfo: + job.result() + + status_error = excinfo.value.__cause__ + assert isinstance(status_error, httpx.HTTPStatusError) + assert status_error.response.status_code == httpx.codes.BAD_REQUEST + + +def test_direct_access_too_few_ions_error_message(httpx_mock: HTTPXMock) -> None: + """Check error reporting when requesting more qubits than loaded ions.""" + backend = DummyDirectAccessResource("token") + detail_str = "requested qubits > available qubits" + httpx_mock.add_response( + status_code=httpx.codes.REQUEST_ENTITY_TOO_LARGE, json={"detail": detail_str} + ) + + job = backend.run(empty_circuit(2)) + with pytest.raises(APIError, match=detail_str) as excinfo: job.result() + # The exception chain contains the original HTTP status error + status_error = excinfo.value.__cause__ + assert isinstance(status_error, httpx.HTTPStatusError) + assert status_error.response.status_code == httpx.codes.REQUEST_ENTITY_TOO_LARGE + @pytest.mark.parametrize("success", [False, True]) def test_direct_access_job_status(success: bool, httpx_mock: HTTPXMock) -> None: