Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve reporting of known API calls error scenarios #211

Merged
merged 3 commits into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading