ADR-010 primary cold-start path now has a real source for the cache bbox and the takeoff origin. Single concrete strategy (`HttpxFlightsApiClient`) behind a `@runtime_checkable` Protocol; offline JSON fallback (`load_flight_file`) shares the same DTO shape per FAC-INV-1. * `flights_api/interface.py` — `FlightsApiClient` Protocol + `FlightDto` + `WaypointDto` + `WaypointObjective` / `WaypointSource` enums (plain frozen-slotted dataclasses, matching project's LatLonAlt / PoseEstimate pattern). * `flights_api/errors.py` — 8-class hierarchy under `FlightsApiError`. * `flights_api/_parser.py` — shared JSON validator: range checks, lat/lon bounds, contiguous ordinals, finite floats, enum membership. * `flights_api/bbox.py` — `bbox_from_waypoints` envelopes lat/lon and inflates by a horizontal-distance buffer via WgsConverter ENU round-trip (NOT degree-space); `takeoff_origin_from_flight` passes waypoints[0] through unrounded. * `flights_api/file_loader.py` — orjson-backed offline loader. * `flights_api/httpx_client.py` — concrete client with ONE retry on transient 5xx + connect errors; token redaction at every log site; test-injectable transport + sleep. * `runtime_root/c12_factory.py` — `build_flights_api_client(config)`; re-exported from `runtime_root/__init__.py`. OperatorToolServices aggregate intentionally deferred to AZ-328 per scope discipline. * `pyproject.toml` — `httpx>=0.28,<1.0` added (chosen over `requests` for native `MockTransport` testing). Tests: 28 cases across AC-1..AC-18 plus extras (malformed JSON, negative buffer, zero buffer, missing top-level fields, negative ordinal, empty-flight takeoff). Full repo run: 713 passed, 2 skipped. Co-authored-by: Cursor <cursoragent@cursor.com>
8.7 KiB
Batch 21 — AZ-489 C12 FlightsApiClient
Date: 2026-05-12 Tracker: Jira AZ-489 (Epic AZ-253 / E-C12) — transitioned To Do → In Progress → Done. Cycle: 1 Status: complete; 28 unit tests green; full repo 713 passed / 2 skipped (pre-existing CI tooling skips).
Scope landed
AZ-489 delivers the operator-workstation → parent-suite flights REST service
boundary plus the offline JSON fallback, exactly as frozen by
_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md
v1.0.0. ADR-010's primary cold-start path now has a real source for both the
cache bbox and the takeoff origin.
The implementation is strictly scoped to AZ-489 — the CLI flag plumbing
(AZ-326), the orchestrator phase 0 (AZ-328), and the
OperatorToolServices aggregate dataclass all stay out of scope.
Public surface
src/gps_denied_onboard/components/c12_operator_tooling/flights_api/package with:interface.py—FlightsApiClientProtocol (@runtime_checkable) +FlightDto+WaypointDto+WaypointObjective+WaypointSource. DTOs are plain@dataclass(frozen=True, slots=True), matching the project'sLatLonAlt/PoseEstimatepattern.errors.py— exception hierarchy:FlightsApiError(base) →FlightsApiUnreachableError,FlightsApiAuthError,FlightNotFoundError,FlightsApiSchemaError,FlightFileNotFoundError,EmptyWaypointsError,WaypointSchemaError._parser.py— shared JSON-payload →FlightDtovalidator used by both online and offline paths so they satisfy FAC-INV-1 (same shape, same error types).bbox.py—bbox_from_waypoints(waypoints, *, buffer_m=1000.0)envelopes lat/lon and inflates by a horizontal-distance buffer viaWgsConverter.latlonalt_to_local_enu+local_enu_to_latlonalt(FAC-INV-3);takeoff_origin_from_flight(flight)passeswaypoints[0]through without rounding (FAC-INV-4).file_loader.py—load_flight_file(*, path)reads JSON viaorjson, delegates to the parser; raisesFlightFileNotFoundError/FlightsApiSchemaError/WaypointSchemaErroras documented.httpx_client.py—HttpxFlightsApiClientagainst the parent-suite REST API. Single retry on transient 5xx + connection errors per FAC-INV-5. Token redaction enforced at every log site.transportsleepare constructor-injectable for unit tests.
src/gps_denied_onboard/runtime_root/c12_factory.py—build_flights_api_client(config) -> FlightsApiClient. Tiny factory: one concrete strategy; httpx's defaults already enforce TLS verify ON and the system trust store.src/gps_denied_onboard/runtime_root/__init__.py—build_flights_api_clientre-exported alongside the otherbuild_*factories.src/gps_denied_onboard/components/c12_operator_tooling/__init__.py— flights_api surface re-exported as the C12 public API.pyproject.toml—httpx>=0.28,<1.0added to main deps.
HTTP client choice
Selected httpx over requests (user-confirmed). Rationale:
httpx.MockTransportis part of httpx — noresponses/requests-mocktest dep required.- No prior C12 / C11 HTTP code yet uses
requests; the only listing is a pyproject.toml dep entry without a consumer. Pickinghttpxcosts one new dep but avoids two (httpx vs requests + responses). - TLS verify is on by default; the constructor does not accept a
verify=Falsetoggle from config.
The AZ-489 spec doc + the C12 description + the flights_api_client
contract already reference httpx; no design drift was introduced
by this choice.
Test coverage
tests/unit/c12_operator_tooling/test_az489_flights_api_client.py — 28
tests across AC-1..AC-18 plus extra coverage.
| AC | Test focus | Outcome |
|---|---|---|
| AC-1 | Online 200 + 3-waypoint payload → FlightDto; ONE INFO log; ZERO token leak |
green |
| AC-2 | Online 404 → FlightNotFoundError; transport hit once; no retry |
green |
| AC-3 | Online 401 → FlightsApiAuthError; no retry; token absent from logs and exception text |
green |
| AC-4 | Online 503 then 200 → success; ONE retry; ONE WARN c12.flights.fetch.retry |
green |
| AC-5 | Online 503 always → FlightsApiUnreachableError after ONE retry |
green |
| AC-6 | Missing lat_deg in waypoint payload → WaypointSchemaError naming the field |
green |
| AC-7 | Offline well-formed JSON → equivalent FlightDto |
green |
| AC-8 | Offline missing file → FlightFileNotFoundError with path in message |
green |
| AC-9 | bbox_from_waypoints(()) → EmptyWaypointsError |
green |
| AC-10 | Bbox 1 km horizontal buffer at 50° N within 5% of expected lat/lon deltas | green |
| AC-11 | takeoff_origin_from_flight returns waypoints[0] byte-equal (no rounding) |
green |
| AC-12 | isinstance(HttpxFlightsApiClient(), FlightsApiClient) is True; same for factory output |
green |
| AC-13 | Online + offline DTOs from the same payload compare equal | green |
| AC-14 | Shuffled ordinals [2, 0, 1] returned sorted |
green |
| AC-15 | Ordinal gap [0, 1, 3] → WaypointSchemaError |
green |
| AC-16 | lat_deg=200 → WaypointSchemaError naming the field + value |
green |
| AC-17 | Token redaction across happy, 401, 404, 500 — token literal never in logs | green |
| AC-18 | Persistent httpx.ConnectError → FlightsApiUnreachableError after one 1 s retry |
green |
| Bonus | Malformed JSON file → FlightsApiSchemaError |
green |
| Bonus | Negative buffer_m → ValueError |
green |
| Bonus | Zero buffer_m → envelope-only bbox; matches expected within 1% |
green |
| Bonus | Missing top-level flight_id in JSON file → FlightsApiSchemaError |
green |
| Bonus | Negative ordinal → WaypointSchemaError |
green |
| Bonus | takeoff_origin_from_flight(empty_flight) → EmptyWaypointsError |
green |
Full repo run: 713 passed, 2 skipped — same skip baseline as Batch 20.
Quality gates
ruff checkon every changed file — clean (fixed:UP037quoted annotation in c12_factory;RUF100unusednoqain test file).ReadLintson the changed surface — no diagnostics.- Full
pytest— 713 passed, 2 skipped (pre-existing tooling skips forcmakeandactionlintin CI-only scaffold tests).
Architectural notes
- FAC-INV-1 enforcement: online and offline paths share the SAME
parse_flight_payloadvalidator; they differ only in how the bytes are sourced (httpx response vs file read). The AC-13 equality test is the visible proof. - FAC-INV-3 horizontal-distance buffer: implemented as a local-ENU round-trip centred at the bbox envelope's midpoint. Picking the midpoint as the ENU origin (NOT one of the corners) keeps the inflation symmetric at high latitudes; the AC-10 test exercises 50° N and validates against the metres-per-degree expectations.
- FAC-INV-5 retry semantics: the implementation issues at most ONE
retry — on transient 5xx OR connection error. 401/403/404 + schema
errors are never retried. The retry path emits a single WARN log
c12.flights.fetch.retry; the test fixture injects a fakesleepto avoid the real 1 s backoff while still asserting the timing. - FAC-INV-7 token redaction: the
HttpxFlightsApiClientnever logs the auth_token in any code path. Every structured log emits the literal"<redacted>". The AC-17 parametrised test exercises happy, 401, 404, and 500 paths and asserts the token literal is absent from the captured log buffer.
Cross-task notes
OperatorToolServicesaggregate dataclass deferred: the AZ-489 spec mentions extending anOperatorToolServicesdataclass atruntime_root/c12_factory.py, but that dataclass doesn't exist yet — it's part of AZ-328's territory (the build-cache orchestrator). Per scope-discipline this batch ships ONLY thebuild_flights_api_clientfactory function; AZ-328 will create the aggregate and wire the flights client through it.- AZ-326 (CLI flag plumbing) can now consume the new
FlightsApiClientand--flight-filepath; the CLI must validate the--flight-idvs--flight-fileexclusivity and translateFlightsApiErrorsubclasses to the documented exit codes. - AZ-328 (build-cache orchestrator) can now wire the flight-resolve phase 0 against the new client + DTOs.
- AZ-419 (FT-P-11 cold-start scenario) has both forward deps
resolved on this side (AZ-489 done) and waits on AZ-490 for the C5
set_takeoff_originentrypoint.
Tracker
- Jira AZ-489 transitioned To Do → In Progress → Done.
- Comment added on AZ-489 summarising deliverables + deviations from the original Jira description (Jira description predates the AZ-489 spec doc; the doc is the authoritative source).
- AZ-489 spec file moved from
_docs/02_tasks/todo/to_docs/02_tasks/done/.