Skip to content

Commit

Permalink
Improve reporting of known API calls error scenarios (#211)
Browse files Browse the repository at this point in the history
  • Loading branch information
airwoodix authored Jan 20, 2025
1 parent 09c0a41 commit 1407502
Show file tree
Hide file tree
Showing 10 changed files with 201 additions and 21 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions docs/apidoc/api_client.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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__
58 changes: 58 additions & 0 deletions qiskit_aqt_provider/api_client/errors.py
Original file line number Diff line number Diff line change
@@ -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 <https://www.python-httpx.org/exceptions/#:~:text=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
7 changes: 4 additions & 3 deletions qiskit_aqt_provider/api_client/portal_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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())
10 changes: 10 additions & 0 deletions qiskit_aqt_provider/aqt_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()})")
Expand All @@ -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()))

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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))
Expand Down
3 changes: 2 additions & 1 deletion qiskit_aqt_provider/aqt_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 13 additions & 11 deletions qiskit_aqt_provider/aqt_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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())


Expand Down Expand Up @@ -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:
Expand All @@ -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())


Expand Down
1 change: 1 addition & 0 deletions tach.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions test/api_client/test_errors.py
Original file line number Diff line number Diff line change
@@ -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
43 changes: 37 additions & 6 deletions test/test_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down

0 comments on commit 1407502

Please sign in to comment.