Skip to content

Commit

Permalink
HTTP Status Errors: enrich-and-reraise (#309)
Browse files Browse the repository at this point in the history
* DataAPIHttpException and its raising

* add DataAPIHttpException with extensive unit tests, diagram and readme

* refine type for error_descriptors (no optional)
  • Loading branch information
hemidactylus authored Aug 28, 2024
1 parent 304cb51 commit 550c208
Show file tree
Hide file tree
Showing 6 changed files with 302 additions and 14 deletions.
1 change: 1 addition & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Method 'update_one' of [Async]Collection: now invokes the corresponding API comm
Better URL-parsing error messages for the API endpoint (with guidance on expected format)
Improved __repr__ for: token/auth-related items, Database/Client classes, response+info objects
DataAPIErrorDescriptor can parse 'extend error' in the responses
Introduced DataAPIHttpException (subclass of both httpx.HTTPStatusError and DataAPIException)
testing on HCD:
- DockerCompose tweaked to invoke `docker compose`
- HCD 1.0.0 and Data API 1.0.15 as test targets
Expand Down
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,24 +473,25 @@ Exceptions:

```python
from astrapy.exceptions import (
DevOpsAPIException,
DevOpsAPIResponseException,
DevOpsAPIErrorDescriptor,
DataAPIErrorDescriptor,
BulkWriteException,
CollectionAlreadyExistsException,
CollectionNotFoundException,
CumulativeOperationException,
CursorIsStartedException,
DataAPIDetailedErrorDescriptor,
DataAPIErrorDescriptor,
DataAPIException,
DataAPITimeoutException,
CursorIsStartedException,
CollectionNotFoundException,
CollectionAlreadyExistsException,
TooManyDocumentsToCountException,
DataAPIFaultyResponseException,
DataAPIHttpException,
DataAPIResponseException,
CumulativeOperationException,
InsertManyException,
DataAPITimeoutException,
DeleteManyException,
DevOpsAPIErrorDescriptor,
DevOpsAPIException,
DevOpsAPIResponseException,
InsertManyException,
TooManyDocumentsToCountException,
UpdateManyException,
BulkWriteException,
)
```

Expand Down
79 changes: 77 additions & 2 deletions astrapy/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ def from_response(

@dataclass
class DataAPIErrorDescriptor:
"""list of
Ans object representing a single error returned from the Data API,
"""
An object representing a single error returned from the Data API,
typically with an error code and a text message.
An API request would return with an HTTP 200 success error code,
but contain a nonzero amount of these.
Expand Down Expand Up @@ -211,6 +211,77 @@ class DataAPIException(ValueError):
pass


@dataclass
class DataAPIHttpException(DataAPIException, httpx.HTTPStatusError):
"""
A request to the Data API resulted in an HTTP 4xx or 5xx response.
In most cases this comes with additional information: the purpose
of this class is to present such information in a structured way,
akin to what happens for the DataAPIResponseException, while
still raising (a subclass of) `httpx.HTTPStatusError`.
Attributes:
text: a text message about the exception.
error_descriptors: a list of all DataAPIErrorDescriptor objects
found in the response.
"""

text: Optional[str]
error_descriptors: List[DataAPIErrorDescriptor]

def __init__(
self,
text: Optional[str],
*,
httpx_error: httpx.HTTPStatusError,
error_descriptors: List[DataAPIErrorDescriptor],
) -> None:
DataAPIException.__init__(self, text)
httpx.HTTPStatusError.__init__(
self,
message=str(httpx_error),
request=httpx_error.request,
response=httpx_error.response,
)
self.text = text
self.httpx_error = httpx_error
self.error_descriptors = error_descriptors

def __str__(self) -> str:
return self.text or str(self.httpx_error)

@classmethod
def from_httpx_error(
cls,
httpx_error: httpx.HTTPStatusError,
**kwargs: Any,
) -> DataAPIHttpException:
"""Parse a httpx status error into this exception."""

raw_response: Dict[str, Any]
# the attempt to extract a response structure cannot afford failure.
try:
raw_response = httpx_error.response.json()
except Exception:
raw_response = {}
error_descriptors = [
DataAPIErrorDescriptor(error_dict)
for error_dict in raw_response.get("errors") or []
]
if error_descriptors:
text = f"{error_descriptors[0].message}. {str(httpx_error)}"
else:
text = str(httpx_error)

return cls(
text=text,
httpx_error=httpx_error,
error_descriptors=error_descriptors,
**kwargs,
)


@dataclass
class DataAPITimeoutException(DataAPIException):
"""
Expand Down Expand Up @@ -694,6 +765,8 @@ def _wrapped_sync(*pargs: Any, **kwargs: Any) -> Any:
raise DataAPIResponseException.from_response(
command=exc.payload, raw_response=exc.response.json()
)
except httpx.HTTPStatusError as exc:
raise DataAPIHttpException.from_httpx_error(exc)
except httpx.TimeoutException as texc:
raise to_dataapi_timeout_exception(texc)

Expand All @@ -717,6 +790,8 @@ async def _wrapped_async(*pargs: Any, **kwargs: Any) -> Any:
raise DataAPIResponseException.from_response(
command=exc.payload, raw_response=exc.response.json()
)
except httpx.HTTPStatusError as exc:
raise DataAPIHttpException.from_httpx_error(exc)
except httpx.TimeoutException as texc:
raise to_dataapi_timeout_exception(texc)

Expand Down
Binary file modified pictures/astrapy_exceptions.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
210 changes: 210 additions & 0 deletions tests/idiomatic/unit/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,227 @@

from __future__ import annotations

import json

import pytest
from httpx import HTTPStatusError, Response
from pytest_httpserver import HTTPServer

from astrapy import DataAPIClient
from astrapy.exceptions import (
DataAPIDetailedErrorDescriptor,
DataAPIErrorDescriptor,
DataAPIHttpException,
DataAPIResponseException,
DeleteManyException,
InsertManyException,
)
from astrapy.results import DeleteResult, InsertManyResult

SAMPLE_API_MESSAGE = "da_message"
SAMPLE_API_ERROR_OBJECT = {
"title": "da_title",
"errorCode": "DA_ERRORCODE",
"message": SAMPLE_API_MESSAGE,
}
FULL_RESPONSE_OF_500 = json.dumps({"errors": [SAMPLE_API_ERROR_OBJECT]})


@pytest.mark.describe("test DataAPIHttpException")
def test_dataapihttpexception() -> None:
"""Test that regardless of how incorrect the input httpx error, nothing breaks."""
se0 = HTTPStatusError(message="httpx_message", request="req", response=None) # type: ignore[arg-type]
se1 = HTTPStatusError(message="httpx_message", request="req", response="blah") # type: ignore[arg-type]
se2 = HTTPStatusError(
message="httpx_message",
request="req", # type: ignore[arg-type]
response=Response(status_code=500, text="blah"),
)
se3 = HTTPStatusError(
message="httpx_message",
request="req", # type: ignore[arg-type]
response=Response(status_code=500, text='{"blabla": 1}'),
)
se4 = HTTPStatusError(
message="httpx_message",
request="req", # type: ignore[arg-type]
response=Response(status_code=500, text='{"errors": []}'),
)
se5 = HTTPStatusError(
message="httpx_message",
request="req", # type: ignore[arg-type]
response=Response(
status_code=500,
text=FULL_RESPONSE_OF_500,
),
)

de0 = DataAPIHttpException.from_httpx_error(se0)
de1 = DataAPIHttpException.from_httpx_error(se1)
de2 = DataAPIHttpException.from_httpx_error(se2)
de3 = DataAPIHttpException.from_httpx_error(se3)
de4 = DataAPIHttpException.from_httpx_error(se4)
de5 = DataAPIHttpException.from_httpx_error(se5)

repr(de0)
repr(de1)
repr(de2)
repr(de3)
repr(de4)
repr(de5)
str(de0)
str(de1)
str(de2)
str(de3)
str(de4)
assert SAMPLE_API_MESSAGE in str(de5)


@pytest.mark.describe("test DataAPIHttpException raising 500 from a mock server, sync")
def test_dataapihttpexception_raising_500_sync(httpserver: HTTPServer) -> None:
"""
testing that:
- the request gets sent
- the correct exception is raised, with the expected members:
- X its request and response fields are those of the httpx object
- its DataAPIHttpException fields are set
- it is caught with an httpx "except" clause all right
"""
root_endpoint = httpserver.url_for("/")
client = DataAPIClient(environment="other")
database = client.get_database(root_endpoint, namespace="xnamespace")
collection = database.get_collection("xcoll")
expected_url = "/v1/xnamespace/xcoll"
httpserver.expect_request(
expected_url,
method="POST",
).respond_with_data(
FULL_RESPONSE_OF_500,
status=500,
)
exc = None
try:
collection.find_one()
except HTTPStatusError as e:
exc = e

assert isinstance(exc, DataAPIHttpException)
httpx_payload = json.loads(exc.request.content.decode())
assert "find" in httpx_payload
assert SAMPLE_API_MESSAGE in exc.response.text
assert exc.error_descriptors == [DataAPIErrorDescriptor(SAMPLE_API_ERROR_OBJECT)]
assert SAMPLE_API_MESSAGE in str(exc)


@pytest.mark.describe("test DataAPIHttpException raising 404 from a mock server, sync")
def test_dataapihttpexception_raising_404_sync(httpserver: HTTPServer) -> None:
"""
testing that:
- the request gets sent
- the correct exception is raised, with the expected members:
- X its request and response fields are those of the httpx object
- its DataAPIHttpException fields are set
- it is caught with an httpx "except" clause all right
"""
root_endpoint = httpserver.url_for("/")
client = DataAPIClient(environment="other")
database = client.get_database(root_endpoint, namespace="xnamespace")
collection = database.get_collection("xcoll")
expected_url = "/v1/xnamespace/xcoll"
httpserver.expect_request(
expected_url,
method="POST",
).respond_with_data(
"blah",
status=404,
)
exc = None
try:
collection.find_one()
except HTTPStatusError as e:
exc = e

assert isinstance(exc, DataAPIHttpException)
httpx_payload = json.loads(exc.request.content.decode())
assert "find" in httpx_payload
assert "blah" in exc.response.text
assert exc.error_descriptors == []
# not parsable into a DataAPIErrorDescriptor -> not in the error str repr
assert "blah" not in str(exc)


@pytest.mark.describe("test DataAPIHttpException raising 500 from a mock server, async")
async def test_dataapihttpexception_raising_500_async(httpserver: HTTPServer) -> None:
"""
testing that:
- the request gets sent
- the correct exception is raised, with the expected members:
- X its request and response fields are those of the httpx object
- its DataAPIHttpException fields are set
- it is caught with an httpx "except" clause all right
"""
root_endpoint = httpserver.url_for("/")
client = DataAPIClient(environment="other")
adatabase = client.get_async_database(root_endpoint, namespace="xnamespace")
acollection = await adatabase.get_collection("xcoll")
expected_url = "/v1/xnamespace/xcoll"
httpserver.expect_request(
expected_url,
method="POST",
).respond_with_data(
FULL_RESPONSE_OF_500,
status=500,
)
exc = None
try:
await acollection.find_one()
except HTTPStatusError as e:
exc = e

assert isinstance(exc, DataAPIHttpException)
httpx_payload = json.loads(exc.request.content.decode())
assert "find" in httpx_payload
assert SAMPLE_API_MESSAGE in exc.response.text
assert exc.error_descriptors == [DataAPIErrorDescriptor(SAMPLE_API_ERROR_OBJECT)]
assert SAMPLE_API_MESSAGE in str(exc)


@pytest.mark.describe("test DataAPIHttpException raising 404 from a mock server, async")
async def test_dataapihttpexception_raising_404_async(httpserver: HTTPServer) -> None:
"""
testing that:
- the request gets sent
- the correct exception is raised, with the expected members:
- X its request and response fields are those of the httpx object
- its DataAPIHttpException fields are set
- it is caught with an httpx "except" clause all right
"""
root_endpoint = httpserver.url_for("/")
client = DataAPIClient(environment="other")
adatabase = client.get_async_database(root_endpoint, namespace="xnamespace")
acollection = await adatabase.get_collection("xcoll")
expected_url = "/v1/xnamespace/xcoll"
httpserver.expect_request(
expected_url,
method="POST",
).respond_with_data(
"blah",
status=404,
)
exc = None
try:
await acollection.find_one()
except HTTPStatusError as e:
exc = e

assert isinstance(exc, DataAPIHttpException)
httpx_payload = json.loads(exc.request.content.decode())
assert "find" in httpx_payload
assert "blah" in exc.response.text
assert exc.error_descriptors == []
# not parsable into a DataAPIErrorDescriptor -> not in the error str repr
assert "blah" not in str(exc)


@pytest.mark.describe("test DataAPIResponseException")
def test_dataapiresponseexception() -> None:
Expand Down
1 change: 1 addition & 0 deletions tests/idiomatic/unit/test_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ def test_imports() -> None:
DataAPIErrorDescriptor,
DataAPIException,
DataAPIFaultyResponseException,
DataAPIHttpException,
DataAPIResponseException,
DataAPITimeoutException,
DeleteManyException,
Expand Down

0 comments on commit 550c208

Please sign in to comment.