|
| 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