# 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` — `FlightsApiClient` Protocol (`@runtime_checkable`) + `FlightDto` + `WaypointDto` + `WaypointObjective` + `WaypointSource`. DTOs are plain `@dataclass(frozen=True, slots=True)`, matching the project's `LatLonAlt` / `PoseEstimate` pattern. * `errors.py` — exception hierarchy: `FlightsApiError` (base) → `FlightsApiUnreachableError`, `FlightsApiAuthError`, `FlightNotFoundError`, `FlightsApiSchemaError`, `FlightFileNotFoundError`, `EmptyWaypointsError`, `WaypointSchemaError`. * `_parser.py` — shared JSON-payload → `FlightDto` validator 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 via `WgsConverter.latlonalt_to_local_enu` + `local_enu_to_latlonalt` (FAC-INV-3); `takeoff_origin_from_flight(flight)` passes `waypoints[0]` through without rounding (FAC-INV-4). * `file_loader.py` — `load_flight_file(*, path)` reads JSON via `orjson`, delegates to the parser; raises `FlightFileNotFoundError` / `FlightsApiSchemaError` / `WaypointSchemaError` as documented. * `httpx_client.py` — `HttpxFlightsApiClient` against the parent-suite REST API. Single retry on transient 5xx + connection errors per FAC-INV-5. Token redaction enforced at every log site. `transport` + `sleep` are 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_client` re-exported alongside the other `build_*` 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.0` added to main deps. ### HTTP client choice Selected `httpx` over `requests` (user-confirmed). Rationale: * `httpx.MockTransport` is part of httpx — no `responses` / `requests-mock` test dep required. * No prior C12 / C11 HTTP code yet uses `requests`; the only listing is a pyproject.toml dep entry without a consumer. Picking `httpx` costs one new dep but avoids two (httpx vs requests + responses). * TLS verify is on by default; the constructor does not accept a `verify=False` toggle 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 check` on every changed file — clean (fixed: `UP037` quoted annotation in c12_factory; `RUF100` unused `noqa` in test file). * `ReadLints` on the changed surface — no diagnostics. * Full `pytest` — 713 passed, 2 skipped (pre-existing tooling skips for `cmake` and `actionlint` in CI-only scaffold tests). ## Architectural notes * **FAC-INV-1 enforcement**: online and offline paths share the SAME `parse_flight_payload` validator; 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 fake `sleep` to avoid the real 1 s backoff while still asserting the timing. * **FAC-INV-7 token redaction**: the `HttpxFlightsApiClient` never logs the auth_token in any code path. Every structured log emits the literal `""`. 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 * **`OperatorToolServices` aggregate dataclass deferred**: the AZ-489 spec mentions extending an `OperatorToolServices` dataclass at `runtime_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 the `build_flights_api_client` factory function; AZ-328 will create the aggregate and wire the flights client through it. * **AZ-326 (CLI flag plumbing)** can now consume the new `FlightsApiClient` and `--flight-file` path; the CLI must validate the `--flight-id` vs `--flight-file` exclusivity and translate `FlightsApiError` subclasses 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_origin` entrypoint. ## 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/`.