Skip to content

Commit fea27ea

Browse files
authored
feat: collect upstream api exceptions in a custom header (#13)
* feat: collect upstream api exceptions in a custom header * fix: expose the X-Failed-Upstream-Apis header via CORS
1 parent 4080934 commit fea27ea

7 files changed

Lines changed: 743 additions & 57 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ wheels/
1010
.venv
1111

1212
/docs/.ipynb_checkpoints/
13+
14+
# Execution plans
15+
dev-docs/plans/

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ A collection-search-only STAC API that aggregates collection search results from
1010
- Supports standard STAC collection search parameters (bbox, datetime, limit, fields, sortby, filter, free text)
1111
- Token-based pagination across multiple APIs
1212
- Health check endpoint for monitoring upstream API availability and collection-search capability
13+
- Graceful handling of upstream API failures with the `strict` query parameter. When `strict=false` (default), failed upstream APIs are skipped and the problematic URLs are returned in the `X-Failed-Upstream-Apis` response header. When `strict=true`, the search fails fast if any upstream API errors.
1314

1415
## Running it locally
1516

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
# Spec: Graceful Upstream API Failure Handling
2+
3+
## Context
4+
5+
The `stac-fastapi-collection-discovery` application federates collection-search
6+
requests across multiple upstream STAC APIs. Currently, if any single upstream API
7+
request in `all_collections()` raises an exception (network error, timeout, 5xx,
8+
malformed response), the entire search fails. The `conformance_classes()` and
9+
`health_check()` endpoints already handle individual API failures gracefully,
10+
using per-task exception catching. The goal is to apply the same resilience to
11+
the search path.
12+
13+
This spec must remain conformant with the STAC API specification, which defines
14+
the `/collections` response as a `Collections` object containing `collections`,
15+
`links`, and optional `numberReturned`/`numberMatched` fields.
16+
17+
## Goals
18+
19+
- **Primary:** Ensure that a single upstream API failure does not cause the
20+
entire federated search to fail.
21+
- **Secondary:** Provide observability into which upstream APIs failed via
22+
structured logging.
23+
- **Tertiary:** Communicate upstream failures to the client via an HTTP response
24+
header without breaking STAC API conformance.
25+
- **Non-goal:** Implement automatic retry logic for transient failures (can be
26+
added later without breaking changes).
27+
- **Non-goal:** Add retry logic.
28+
- **Non-goal:** Change the behavior of `conformance_classes()` or
29+
`health_check()` (they already handle failures correctly).
30+
31+
## Constraints & Assumptions
32+
33+
- The STAC API Collection Search spec (OGC API - Features / STAC API Collections)
34+
does not define a standard way to communicate partial upstream failures in the
35+
response body. The response must remain a valid `Collections` object.
36+
- The `Collections` model from `stac_pydantic` supports optional `numberReturned`
37+
and `numberMatched` fields.
38+
- Pagination state is maintained via base64-encoded tokens that track the current,
39+
previous, and next URLs for each upstream API.
40+
- The application is built on `stac-fastapi`, which wraps client return values in
41+
FastAPI responses. Adding custom HTTP headers requires returning a
42+
`fastapi.Response` (or subclass) from the endpoint rather than a plain model.
43+
- A `strict` query parameter on `GET /collections` controls fail-fast vs
44+
graceful-degradation behavior.
45+
46+
## Architecture Overview
47+
48+
The change is localized to the `fetch_api_data` logic within
49+
`CollectionSearchClient.all_collections()`. Instead of using `asyncio.gather()`
50+
with the default behavior (first exception aborts all), we use
51+
`return_exceptions=True` and inspect each result individually.
52+
53+
```
54+
+--------------+ +----------------+ +--------------------+
55+
| GET /collections |---->| all_collections |-->| Fetch from API 1 |
56+
| ?strict=false | | | | Fetch from API 2 |
57+
+--------------+ +----------------+ | Fetch from API 3 |
58+
+--------------------+
59+
|
60+
v
61+
+--------------------+
62+
| Gather with |
63+
| return_exceptions=True|
64+
+--------------------+
65+
|
66+
+------------------+--+------------------+
67+
v v v
68+
+----------+ +----------+ +----------+
69+
| Success | | Success | | Failure |
70+
| (include)| | (include)| | (skip) |
71+
+----------+ +----------+ +----------+
72+
| | |
73+
+---------------------+---------------------+
74+
v
75+
+--------------------+
76+
| Merge collections |
77+
| Merge pagination |
78+
| Return Collections |
79+
| + X-Failed-... hdr |
80+
+--------------------+
81+
```
82+
83+
## Detailed Design
84+
85+
### `GET /collections` Parameters
86+
87+
| Parameter | Type | Default | Description |
88+
|-----------|--------|---------|-------------|
89+
| `strict` | `bool` | `false` | When `true`, the entire search fails if any upstream API returns an error or times out. When `false`, failed APIs are skipped, a warning is logged, and their URLs are returned in a response header. |
90+
91+
All other existing query parameters (`bbox`, `datetime`, `limit`, `q`, `token`,
92+
etc.) remain unchanged.
93+
94+
### Response Headers
95+
96+
| Header | Value | Condition |
97+
|--------|-------|-----------|
98+
| `X-Failed-Upstream-Apis` | Comma-separated list of failed API URLs | Present when `strict=false` and at least one upstream API failed. Omitted when all APIs succeeded. |
99+
100+
Example:
101+
102+
```
103+
X-Failed-Upstream-Apis: https://unhealthy.api.example.com/collections
104+
```
105+
106+
### Error Handling Behavior
107+
108+
For each upstream API request:
109+
110+
1. **HTTP 2xx with valid JSON:** Include the collections in the result. Extract
111+
`next` and `previous` links for pagination state.
112+
2. **HTTP 4xx/5xx:** Log a warning with the API URL, status code, and response
113+
body (truncated). Skip this API's collections. Do not include this API in the
114+
pagination state for subsequent pages.
115+
3. **Network error / timeout:** Log a warning with the API URL and exception.
116+
Skip this API's collections. Do not include this API in the pagination state.
117+
4. **Malformed JSON response:** Log a warning. Skip this API's collections.
118+
5. **All APIs fail:** Return a valid `Collections` object with an empty
119+
`collections` array, `numberReturned: 0`, and only a `self` link.
120+
121+
When `strict=true`, any of the above failure conditions (2-4) immediately raise
122+
an error and abort the entire search, preserving the current behavior.
123+
124+
### Pagination State for Failed APIs
125+
126+
The token state structure is:
127+
128+
```python
129+
{
130+
"current": {"api1": "url1", "api2": "url2"},
131+
"previous": {"api1": "prev1"},
132+
"next": {"api1": "next1", "api2": "next2"},
133+
"is_first_page": bool,
134+
}
135+
```
136+
137+
When an API fails (in non-strict mode):
138+
- Do **not** add it to `new_state["current"]` (it has no current page).
139+
- Do **not** add it to `new_state["previous"]` (we don't know its previous link).
140+
- Do **not** add it to `new_state["next"]` (we don't want to query it again).
141+
142+
This means a failed API is effectively dropped from the federated search for the
143+
remainder of the pagination session. If the user wants to retry, they must start
144+
a fresh search (omit the `token` parameter). This keeps the token state compact
145+
and avoids retrying known-bad APIs on every page.
146+
147+
### Logging
148+
149+
Each failure is logged at `WARNING` level with a structured message:
150+
151+
```python
152+
logger.warning(
153+
f"Upstream API returned error status: {api=}, {url=}, "
154+
f"status_code={api_response.status_code}, "
155+
f"response_body={api_response.text[:500]}"
156+
)
157+
```
158+
159+
and for exceptions:
160+
161+
```python
162+
logger.warning(f"Upstream API request failed: {api=}, {url=}, error={e}")
163+
```
164+
165+
### Response Fields
166+
167+
- **`collections`:** Only includes collections from successfully queried APIs.
168+
- **`links`:** Standard STAC links (`self`, optional `next`, optional `previous`)
169+
plus `canonical` links for each successful API.
170+
- **`numberReturned`:** Count of collections from successful APIs only.
171+
- **`numberMatched`:** **Omitted.** Since upstream APIs may or may not return
172+
this, and we can't accurately compute it when some APIs fail, we omit it
173+
entirely rather than return a misleading value.
174+
175+
## API / Interface Design
176+
177+
### Public API Changes
178+
179+
The `GET /collections` endpoint gains an optional `strict` query parameter. The
180+
response body remains a valid STAC `Collections` object; failed upstream APIs
181+
are communicated via the `X-Failed-Upstream-Apis` response header.
182+
183+
### How to inject headers
184+
185+
`stac-fastapi`'s `create_async_endpoint()` returns a Pydantic model (or dict),
186+
and FastAPI serializes it into a response via the route's `response_class`.
187+
This gives us no hook to add headers. The cleanest approach is to bypass
188+
`create_async_endpoint()` entirely for `/collections` and write a custom
189+
endpoint that returns a `JSONResponse` directly.
190+
191+
We override `register_get_collections()` in `StacCollectionSearchApi`. The
192+
override defines its own endpoint `async def get_collections(request, ...)` that:
193+
1. Accepts the request model via `Depends(...)`.
194+
2. Calls `self.client.all_collections(...)` directly.
195+
3. Converts the `Collections` result to a JSON-serializable dict.
196+
4. Returns `JSONResponse(content=body, headers={"X-Failed-Upstream-Apis": ...})`.
197+
198+
The route still declares `response_model=Collections` so OpenAPI documentation
199+
and client generators remain accurate. FastAPI skips `response_model`
200+
serialization when the endpoint already returns a `Response` subclass.
201+
202+
## Data Model
203+
204+
### `CollectionSearchResult`
205+
206+
A new internal dataclass to convey both the search results and failure
207+
metadata:
208+
209+
```python
210+
from dataclasses import dataclass
211+
212+
@dataclass
213+
class CollectionSearchResult:
214+
collections: Collections
215+
failed_apis: list[str]
216+
```
217+
218+
No other data model changes.
219+
220+
## Integration Points
221+
222+
- **`FederatedApisGetRequest`:** New `strict` field added to the request model.
223+
- **`StacCollectionSearchApi.register_get_collections`:** Fully overridden to
224+
define a custom endpoint that returns `JSONResponse` directly, bypassing
225+
`create_async_endpoint()`. This lets us attach the `X-Failed-Upstream-Apis`
226+
header while keeping `response_model=Collections` for OpenAPI docs.
227+
- **Logging:** Integrates with the standard library `logging` already used
228+
throughout the module.
229+
230+
## Testing Strategy
231+
232+
### Unit Tests
233+
234+
1. **One API fails, others succeed (strict=false):**
235+
- Mock 3 APIs: 2 return 200, 1 returns 500.
236+
- Verify the result contains collections from the 2 successful APIs.
237+
- Verify `numberReturned` reflects only successful API collections.
238+
- Verify the failed API is not in `new_state["next"]`.
239+
- Verify `failed_apis` list contains the failed API URL.
240+
241+
2. **Strict mode enabled (strict=true):**
242+
- Mock 2 APIs: one returns 500, one returns 200.
243+
- Verify the search raises an exception (fail-fast behavior).
244+
245+
3. **All APIs fail with strict=false:**
246+
- Mock all configured APIs to return 5xx.
247+
- Verify the result is a valid `Collections` with empty `collections` and
248+
`numberReturned: 0`.
249+
- Verify `failed_apis` contains all API URLs.
250+
251+
4. **Network timeout:**
252+
- Mock one API to raise `httpx.TimeoutException`.
253+
- Verify search continues with remaining APIs.
254+
- Verify failed API appears in `failed_apis`.
255+
256+
5. **Malformed JSON:**
257+
- Mock one API to return 200 with invalid JSON.
258+
- Verify search continues and the failure is logged.
259+
- Verify failed API appears in `failed_apis`.
260+
261+
6. **Pagination with failed API:**
262+
- First page: 3 APIs, 1 fails. Verify `next` token only contains 2 APIs.
263+
- Second page (using token): Verify only 2 APIs are queried.
264+
- Verify `failed_apis` is empty on the second page if none fail.
265+
266+
7. **Empty result from healthy API:**
267+
- One API returns 200 with empty `collections` array.
268+
- Verify it is handled correctly (no exception, included in state if it has
269+
a next link).
270+
271+
### Integration Tests
272+
273+
- Test against real APIs by temporarily blocking one via firewall/mock to verify
274+
the application doesn't crash.
275+
- Verify `X-Failed-Upstream-Apis` header appears in the HTTP response when an
276+
upstream API fails.
277+
278+
### App-level Tests
279+
280+
- Test the FastAPI endpoint directly using `TestClient`:
281+
- Request with `?strict=false` and a mocked failing API → verify 200 with
282+
header.
283+
- Request with `?strict=true` and a mocked failing API → verify error status.
284+
- Request with no `strict` param (default) → verify graceful handling.
285+
286+
## Decision Log
287+
288+
| Decision | Options Considered | Rationale |
289+
|----------|-------------------|-----------|
290+
| Exclude failed APIs from pagination state | Continue including failed APIs in state for retry; Add retry logic | Dropping failed APIs keeps tokens small and avoids repeated failures. Retry logic can be added later as a separate feature. |
291+
| HTTP header for failed APIs | Add `failed_apis` field to response body; Add HTTP headers | Header preserves strict STAC API conformance. Body field is simpler for clients to parse but risks validation failures with strict STAC validators. The header keeps the payload clean. |
292+
| Per-request `strict` parameter | Global setting only (`strict_upstream_mode`) | Per-request control allows clients to opt into fail-fast behavior when they need it (e.g., debugging), while keeping graceful handling as the production default. |
293+
| Omit `numberMatched` | Set `numberMatched` to sum of successful APIs only; Set to `None` | Without all upstream responses, we can't compute an accurate total. Omitting is more honest than a partial sum. |
294+
| No retry logic | Exponential backoff retry; Retry once immediately | Adds significant complexity. Can be added later without breaking changes. |
295+
| Return result dataclass from `all_collections` | Attach failures to request state; Use global context | Explicit return is clearer, easier to test, and avoids hidden side effects. |
296+
| Custom endpoint returning `JSONResponse` | Middleware to inject headers; Custom response class subclass | Middleware adds complexity and runs for every route. Custom response class can't access per-request data. Returning `JSONResponse` directly from the endpoint is the simplest FastAPI-native approach. |
297+
298+
## Open Questions
299+
300+
1. Should we add request-level telemetry (e.g., Prometheus metrics) to track
301+
upstream API failure rates? This could be a follow-up enhancement.
302+
2. Should we consider a circuit-breaker pattern if an API fails repeatedly
303+
across requests?
304+
305+
## Status
306+
307+
- [x] Designing
308+
- [x] Approved — ready to plan
309+
- [x] Implementing
310+
- [x] Implemented
311+
312+
## References
313+
314+
- STAC API Collection Search spec: https://github.com/radiantearth/stac-api-spec/tree/main/collection-search
315+
- `stac_pydantic` Collections model
316+
- Existing `conformance_classes()` and `health_check()` implementations (already
317+
handle per-API failures gracefully)

0 commit comments

Comments
 (0)