Files
Oleksandr Bezdieniezhnykh 72a06edab0 [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>
2026-05-12 01:28:49 +03:00

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.pyFlightsApiClient 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.pybbox_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.pyload_flight_file(*, path) reads JSON via orjson, delegates to the parser; raises FlightFileNotFoundError / FlightsApiSchemaError / WaypointSchemaError as documented.
    • httpx_client.pyHttpxFlightsApiClient 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.pybuild_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__.pybuild_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.tomlhttpx>=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=200WaypointSchemaError naming the field + value green
AC-17 Token redaction across happy, 401, 404, 500 — token literal never in logs green
AC-18 Persistent httpx.ConnectErrorFlightsApiUnreachableError after one 1 s retry green
Bonus Malformed JSON file → FlightsApiSchemaError green
Bonus Negative buffer_mValueError 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/.