[AZ-489] C12 FlightsApiClient + offline JSON loader + bbox helper

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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 01:28:49 +03:00
parent e0be591b06
commit 72a06edab0
15 changed files with 2057 additions and 2 deletions
@@ -0,0 +1,166 @@
# 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 `"<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
* **`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/`.