Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 126a178

Browse files
committed
feat(http): add upstream error translation
- Add translate_upstream_errors for httpx status and request failures - Introduce upstream exception types handled by default registration - Document integration and cover decorator and handler behavior
1 parent 99784de commit 126a178

8 files changed

Lines changed: 433 additions & 9 deletions

File tree

docs/api/exceptions.md

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,49 @@ ZodiacCore includes several common exceptions ready to use:
6262
| `NotFoundException` | 404 | Resource does not exist. |
6363
| `ConflictException` | 409 | Resource state conflict (e.g., duplicate entry). |
6464
| `UnprocessableEntityException` | 422 | Business/semantic validation failed (entity well-formed but not processable). |
65+
| `UpstreamServiceError` | 400 | A third-party/upstream service failed, timed out, or returned an unexpected status. |
66+
| `UpstreamRequestError` | 400 | The upstream service rejected this service's request, usually HTTP 400 or 422. |
6567

6668
Built-in exception families have fixed HTTP statuses. If you subclass `BadRequestException`, the response status remains HTTP 400; overriding `http_code` on that subclass does not change the family status. Use `code` for business-specific error codes inside the response body.
6769

6870
---
6971

70-
## 4. Custom Exceptions
72+
## 4. Upstream Service Errors
73+
74+
Use `translate_upstream_errors` around third-party `httpx` calls to convert known upstream failures into standardized ZodiacCore exceptions. The decorated function should call `response.raise_for_status()` so non-2xx responses can be classified.
75+
76+
```python
77+
from zodiac_core.http import ZodiacClient, translate_upstream_errors
78+
79+
80+
@translate_upstream_errors(service="identity_and_access")
81+
async def get_permissions(client: ZodiacClient):
82+
response = await client.post("/api/auth/by_user_id", json={"user_id": "..."})
83+
response.raise_for_status()
84+
return response.json()
85+
```
86+
87+
Classification:
88+
89+
- Upstream HTTP 400 or 422 becomes `UpstreamRequestError`.
90+
- Other upstream HTTP status failures become `UpstreamServiceError`.
91+
- Other `httpx.RequestError` failures become `UpstreamServiceError`.
92+
93+
These upstream exceptions are part of ZodiacCore's built-in exception hierarchy and standard exception registration:
94+
95+
```python
96+
from fastapi import FastAPI
97+
from zodiac_core.exception_handlers import register_exception_handlers
98+
99+
app = FastAPI()
100+
register_exception_handlers(app)
101+
```
102+
103+
With the standard handlers registered, unhandled upstream exceptions return HTTP 400 and are not treated as uncaught HTTP 500 errors from the current service. If your service catches `UpstreamServiceError` or `UpstreamRequestError` itself, that local handling wins and the global handler is not involved.
104+
105+
---
106+
107+
## 5. Custom Exceptions
71108

72109
For a business error that belongs to a built-in HTTP status family, subclass the built-in exception and set a business `code`, `message`, and optional `data`:
73110

@@ -113,13 +150,14 @@ If a direct `ZodiacException` subclass does not define `http_code`, it inherits
113150

114151
---
115152

116-
## 5. Integration
153+
## 6. Integration
117154

118155
To enable global exception handling in your FastAPI app, use `register_exception_handlers`. This will catch:
119156

120157
1. All `ZodiacException` subclasses.
121158
2. Pydantic `ValidationError` and FastAPI `RequestValidationError` (mapped to 422).
122-
3. Any uncaught `Exception` (mapped to 500 with secure logging).
159+
3. Upstream errors produced by `translate_upstream_errors` (mapped to 400).
160+
4. Any uncaught `Exception` (mapped to 500 with secure logging).
123161

124162
```python
125163
from fastapi import FastAPI
@@ -131,7 +169,7 @@ register_exception_handlers(app)
131169

132170
---
133171

134-
## 6. API Reference
172+
## 7. API Reference
135173

136174
### Exception Base & Subclasses
137175
::: zodiac_core.exceptions
@@ -146,6 +184,8 @@ register_exception_handlers(app)
146184
- NotFoundException
147185
- ConflictException
148186
- UnprocessableEntityException
187+
- UpstreamServiceError
188+
- UpstreamRequestError
149189

150190
### Global Handler Registration
151191
::: zodiac_core.exception_handlers
@@ -154,3 +194,11 @@ register_exception_handlers(app)
154194
show_root_heading: false
155195
members:
156196
- register_exception_handlers
197+
198+
### Upstream HTTP Decorators
199+
::: zodiac_core.http
200+
options:
201+
heading_level: 3
202+
show_root_heading: false
203+
members:
204+
- translate_upstream_errors

tests/test_exception_handlers.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from zodiac_core.exception_handlers import (
77
handler_global_exception,
8+
handler_upstream_service_error,
89
handler_validation_exception,
910
handler_zodiac_exception,
1011
)
@@ -15,6 +16,7 @@
1516
NotFoundException,
1617
UnauthorizedException,
1718
UnprocessableEntityException,
19+
UpstreamRequestError,
1820
ZodiacException,
1921
)
2022

@@ -142,6 +144,52 @@ def __init__(self, current_balance: float):
142144
assert data["message"] == "Your account balance is too low."
143145
assert data["data"] == {"current_balance": 50.5}
144146

147+
@pytest.mark.asyncio
148+
async def test_upstream_service_error_handler(self, mock_request):
149+
"""Upstream errors are handled by the standard upstream handler."""
150+
151+
exc = UpstreamRequestError(service="production", upstream_status=422)
152+
resp = await handler_upstream_service_error(mock_request, exc)
153+
154+
assert resp.status_code == 400
155+
data = json.loads(resp.body)
156+
assert data == {
157+
"code": 400,
158+
"message": "Upstream request failed",
159+
"data": {
160+
"service": "production",
161+
"error_code": "UPSTREAM_REQUEST_ERROR",
162+
},
163+
}
164+
165+
@pytest.mark.asyncio
166+
async def test_register_exception_handlers_handles_upstream_errors(self):
167+
"""Default exception registration handles translated upstream errors."""
168+
from fastapi import FastAPI
169+
from fastapi.testclient import TestClient
170+
171+
from zodiac_core.exception_handlers import register_exception_handlers
172+
173+
app = FastAPI()
174+
register_exception_handlers(app)
175+
176+
@app.get("/upstream")
177+
def raise_upstream():
178+
raise UpstreamRequestError(service="production", upstream_status=422)
179+
180+
client = TestClient(app, raise_server_exceptions=False)
181+
resp = client.get("/upstream")
182+
183+
assert resp.status_code == 400
184+
assert resp.json() == {
185+
"code": 400,
186+
"message": "Upstream request failed",
187+
"data": {
188+
"service": "production",
189+
"error_code": "UPSTREAM_REQUEST_ERROR",
190+
},
191+
}
192+
145193
@pytest.mark.asyncio
146194
async def test_direct_zodiac_subclass_uses_declared_http_code(self, mock_request):
147195
"""A direct ZodiacException subclass should use its declared HTTP status."""

tests/test_exceptions.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
NotFoundException,
88
UnauthorizedException,
99
UnprocessableEntityException,
10+
UpstreamRequestError,
11+
UpstreamServiceError,
1012
ZodiacException,
1113
)
1214

@@ -55,3 +57,36 @@ def test_all_params(self, exception_class, expected_http_code):
5557
assert exc.message == msg
5658
assert exc.code == code
5759
assert exc.data == data
60+
61+
62+
class TestUpstreamExceptions:
63+
def test_upstream_service_error_defaults_to_bad_request_shape(self):
64+
exc = UpstreamServiceError(service="production", upstream_status=503)
65+
66+
assert isinstance(exc, BadRequestException)
67+
assert isinstance(exc, ZodiacException)
68+
assert exc.code == 400
69+
assert exc.message == "Upstream service unavailable"
70+
assert exc.service == "production"
71+
assert exc.error_code == "UPSTREAM_SERVICE_ERROR"
72+
assert exc.upstream_status == 503
73+
assert exc.data == {
74+
"service": "production",
75+
"error_code": "UPSTREAM_SERVICE_ERROR",
76+
}
77+
78+
def test_upstream_request_error_uses_request_failure_code(self):
79+
exc = UpstreamRequestError(service="identity_and_access", upstream_status=422)
80+
81+
assert isinstance(exc, UpstreamServiceError)
82+
assert isinstance(exc, BadRequestException)
83+
assert isinstance(exc, ZodiacException)
84+
assert exc.code == 400
85+
assert exc.message == "Upstream request failed"
86+
assert exc.service == "identity_and_access"
87+
assert exc.error_code == "UPSTREAM_REQUEST_ERROR"
88+
assert exc.upstream_status == 422
89+
assert exc.data == {
90+
"service": "identity_and_access",
91+
"error_code": "UPSTREAM_REQUEST_ERROR",
92+
}

tests/test_http.py

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import uuid
22

3+
import httpx
34
import pytest
45
import respx
56
from httpx import Response
67

78
from zodiac_core.context import set_request_id
8-
from zodiac_core.http import ZodiacClient, ZodiacSyncClient, init_http_client
9+
from zodiac_core.exceptions import UpstreamRequestError, UpstreamServiceError
10+
from zodiac_core.http import (
11+
ZodiacClient,
12+
ZodiacSyncClient,
13+
init_http_client,
14+
translate_upstream_errors,
15+
)
916

1017

1118
class TestZodiacHttpClients:
@@ -128,3 +135,130 @@ async def custom_hook(request):
128135
headers = mock.calls.last.request.headers
129136
assert headers["X-Request-ID"] == trace_id
130137
assert headers["X-Resource"] == "yes"
138+
139+
@pytest.mark.asyncio
140+
async def test_translate_upstream_errors_maps_async_422_to_request_error(self):
141+
"""HTTP 400/422 status errors are treated as upstream request failures."""
142+
143+
async with respx.mock(base_url="http://test") as mock:
144+
mock.get("/invalid").mock(return_value=Response(422, json={"code": 422}))
145+
146+
async with ZodiacClient(base_url="http://test") as client:
147+
148+
@translate_upstream_errors(service="identity_and_access")
149+
async def fetch_invalid():
150+
response = await client.get("/invalid")
151+
response.raise_for_status()
152+
153+
with pytest.raises(UpstreamRequestError) as exc_info:
154+
await fetch_invalid()
155+
156+
exc = exc_info.value
157+
assert exc.service == "identity_and_access"
158+
assert exc.error_code == "UPSTREAM_REQUEST_ERROR"
159+
assert exc.upstream_status == 422
160+
161+
@pytest.mark.asyncio
162+
async def test_translate_upstream_errors_maps_async_5xx_to_service_error(self):
163+
"""Non-contract HTTP status errors are treated as upstream service failures."""
164+
165+
async with respx.mock(base_url="http://test") as mock:
166+
mock.get("/unavailable").mock(return_value=Response(503, json={"code": 503}))
167+
168+
async with ZodiacClient(base_url="http://test") as client:
169+
170+
@translate_upstream_errors(service="production")
171+
async def fetch_unavailable():
172+
response = await client.get("/unavailable")
173+
response.raise_for_status()
174+
175+
with pytest.raises(UpstreamServiceError) as exc_info:
176+
await fetch_unavailable()
177+
178+
exc = exc_info.value
179+
assert not isinstance(exc, UpstreamRequestError)
180+
assert exc.service == "production"
181+
assert exc.error_code == "UPSTREAM_SERVICE_ERROR"
182+
assert exc.upstream_status == 503
183+
184+
def test_translate_upstream_errors_maps_sync_transport_error(self):
185+
"""Transport failures are treated as upstream service failures."""
186+
187+
@translate_upstream_errors(service="deliverable_hub")
188+
def fetch_with_transport_failure():
189+
request = httpx.Request("GET", "http://deliverable-hub.test")
190+
raise httpx.ConnectError("connect failed", request=request)
191+
192+
with pytest.raises(UpstreamServiceError) as exc_info:
193+
fetch_with_transport_failure()
194+
195+
exc = exc_info.value
196+
assert exc.service == "deliverable_hub"
197+
assert exc.error_code == "UPSTREAM_SERVICE_ERROR"
198+
assert exc.upstream_status is None
199+
200+
def test_translate_upstream_errors_maps_sync_request_error(self):
201+
"""Non-transport request failures are also treated as upstream service failures."""
202+
203+
@translate_upstream_errors(service="redirecting_service")
204+
def fetch_with_request_failure():
205+
request = httpx.Request("GET", "http://redirecting-service.test")
206+
raise httpx.TooManyRedirects("too many redirects", request=request)
207+
208+
with pytest.raises(UpstreamServiceError) as exc_info:
209+
fetch_with_request_failure()
210+
211+
exc = exc_info.value
212+
assert exc.service == "redirecting_service"
213+
assert exc.error_code == "UPSTREAM_SERVICE_ERROR"
214+
assert exc.upstream_status is None
215+
216+
def test_translate_upstream_errors_preserves_local_exception_handling(self):
217+
"""If user code catches the httpx error itself, the decorator does not interfere."""
218+
219+
@translate_upstream_errors(service="billing")
220+
def fetch_with_local_handling():
221+
request = httpx.Request("GET", "http://billing.test")
222+
try:
223+
raise httpx.ConnectError("connect failed", request=request)
224+
except httpx.ConnectError:
225+
return {"handled": True}
226+
227+
assert fetch_with_local_handling() == {"handled": True}
228+
229+
@pytest.mark.asyncio
230+
async def test_translated_upstream_error_is_handled_by_registered_fastapi_app(self):
231+
"""Decorator + register_exception_handlers is the complete integration path."""
232+
from fastapi import FastAPI
233+
234+
from zodiac_core.exception_handlers import register_exception_handlers
235+
236+
app = FastAPI()
237+
register_exception_handlers(app)
238+
239+
@translate_upstream_errors(service="identity_and_access")
240+
async def call_upstream():
241+
async with ZodiacClient(base_url="http://upstream") as client:
242+
response = await client.get("/invalid")
243+
response.raise_for_status()
244+
245+
@app.get("/proxy")
246+
async def proxy():
247+
await call_upstream()
248+
return {"ok": True}
249+
250+
async with respx.mock(base_url="http://upstream") as mock:
251+
mock.get("/invalid").mock(return_value=Response(422, json={"code": 422}))
252+
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
253+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
254+
response = await client.get("/proxy")
255+
256+
assert response.status_code == 400
257+
assert response.json() == {
258+
"code": 400,
259+
"message": "Upstream request failed",
260+
"data": {
261+
"service": "identity_and_access",
262+
"error_code": "UPSTREAM_REQUEST_ERROR",
263+
},
264+
}

zodiac_core/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,16 @@
1010
NotFoundException,
1111
UnauthorizedException,
1212
UnprocessableEntityException,
13+
UpstreamRequestError,
14+
UpstreamServiceError,
1315
ZodiacException,
1416
)
15-
from .http import ZodiacClient, ZodiacSyncClient, init_http_client
17+
from .http import (
18+
ZodiacClient,
19+
ZodiacSyncClient,
20+
init_http_client,
21+
translate_upstream_errors,
22+
)
1623
from .logging import LogFileOptions, setup_loguru
1724
from .middleware import AccessLogMiddleware, ServiceNameMiddleware, TraceIDMiddleware, register_middleware
1825
from .pagination import PagedResponse, PageParams
@@ -73,6 +80,7 @@
7380
"ZodiacClient",
7481
"ZodiacSyncClient",
7582
"init_http_client",
83+
"translate_upstream_errors",
7684
# pagination
7785
"PageParams",
7886
"PagedResponse",
@@ -83,6 +91,8 @@
8391
"NotFoundException",
8492
"ConflictException",
8593
"UnprocessableEntityException",
94+
"UpstreamServiceError",
95+
"UpstreamRequestError",
8696
"ZodiacException",
8797
"register_exception_handlers",
8898
# logging

0 commit comments

Comments
 (0)