mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 12:41:13 +00:00
[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:
@@ -0,0 +1,238 @@
|
||||
# C12 FlightsApiClient — Fetch Flight from suite flights service + offline JSON fallback
|
||||
|
||||
**Task**: AZ-489_c12_flights_api_client
|
||||
**Name**: C12 FlightsApiClient — fetch Flight from suite flights service + offline JSON fallback
|
||||
**Description**: Add a typed client module to C12 that fetches a parent-suite `Flight` (route + waypoints + altitudes) from the parent-suite `flights` REST service so C12 can derive the cache bbox and the takeoff origin directly from the operator-planned mission (ADR-010). The operator runs `operator-tool build-cache --flight-id <Guid>`; C12 calls `GET /flights/{id}` and `GET /flights/{id}/waypoints`, parses into local pydantic DTOs (`FlightDto`, `WaypointDto`) mirroring `suite/flights/Database/Entities/{Flight,Waypoint}.cs`, computes the bbox as the envelope of waypoint lat/lon plus a configurable buffer (default 1 km, horizontal-distance — not degree-space — via `WgsConverter`), and exposes the first-ordered waypoint as the takeoff origin. An `--flight-file <path>` alternative reads the same DTO shape from a local JSON export so the workflow stays usable when the workstation has no path to the flights service. The client is read-only, raises typed errors for every documented failure path, redacts the auth token in all log output, and is consumed by AZ-326 (CLI flags) + AZ-328 (orchestrator phase 0).
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-279_wgs_converter (for the bbox buffer math)
|
||||
**Component**: c12_operator_tooling (epic AZ-253 / E-C12)
|
||||
**Tracker**: AZ-489
|
||||
**Epic**: AZ-253 (E-C12)
|
||||
|
||||
### Document Dependencies
|
||||
|
||||
- `_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md` — produced by this task (frozen Protocol + DTOs + invariants + test cases).
|
||||
- `_docs/02_document/components/13_c12_operator_tooling/description.md` — § 2 (FlightsApiClient interface), § 5 (httpx + pydantic dependencies).
|
||||
- `_docs/02_document/architecture.md` — ADR-010 (operator-planned mission as cold-start trust anchor).
|
||||
- Parent-suite reference (read-only): `suite/flights/Database/Entities/Flight.cs`, `suite/flights/Database/Entities/Waypoint.cs`, `suite/flights/Controllers/FlightsController.cs`.
|
||||
|
||||
## Problem
|
||||
|
||||
Without `FlightsApiClient`:
|
||||
|
||||
- ADR-010's primary cold-start path (operator-planned mission → C10 Manifest `takeoff_origin` → C5 warm-start) cannot be wired. C10 has nowhere to get the origin from; C5 has nothing to seed.
|
||||
- F1 phase 0 (Flight resolve) defined in `_docs/02_document/system-flows.md` cannot run; AZ-328's flight-resolve phase has no service to invoke.
|
||||
- C12-CLI's `--flight-id` / `--flight-file` flags (AZ-326) have nothing to delegate to.
|
||||
- The bbox in `BuildCacheRequest` would need to keep coming from operator-typed CLI args, drifting from the canonical mission authored in the Mission Planner UI — exactly the operator-error vector ADR-010 was created to remove.
|
||||
- The offline path (`--flight-file`) has no implementation, so operator workstations without a path to the flights service can't run F1.
|
||||
- The bbox buffer is currently undefined; without a contract there's no single source of truth for "how big around the route do we cache tiles".
|
||||
|
||||
This task delivers the client + its frozen contract. It does NOT modify the CLI flag parsing (AZ-326), the orchestrator phase ordering (AZ-328), the Manifest schema (AZ-323), or any C5 path.
|
||||
|
||||
## Outcome
|
||||
|
||||
- A `FlightsApiClient` Protocol + concrete `HttpxFlightsApiClient` implementation at `src/operator_tool/flights_api_client.py`:
|
||||
- Constructor: `__init__(self, *, httpx_client: httpx.Client | None = None, wgs_converter: WgsConverter, logger: Logger, clock: Clock)`.
|
||||
- Public methods per the contract: `fetch_flight`, `load_flight_file`, `bbox_from_waypoints`, `takeoff_origin_from_flight`.
|
||||
- DTOs at `src/operator_tool/flights_dto.py`:
|
||||
- `FlightDto` + `WaypointDto` as `@dataclass(frozen=True, slots=True)` with pydantic validators (via `pydantic.dataclasses.dataclass` or a standalone `pydantic.TypeAdapter`).
|
||||
- `WaypointObjective` + `WaypointSource` enums mirroring the C# enums.
|
||||
- Offline loader at `src/operator_tool/flights_file_loader.py`:
|
||||
- `load_flight_file(path: Path) -> FlightDto` — reads JSON via `orjson`, validates against the same pydantic schema, raises `FlightFileNotFoundError` / `FlightsApiSchemaError` / `WaypointSchemaError` per the contract.
|
||||
- Bbox helper at `src/operator_tool/bbox_from_waypoints.py`:
|
||||
- `bbox_from_waypoints(waypoints, *, buffer_m, wgs_converter) -> BoundingBox` — envelopes the lat/lon, then inflates by `buffer_m` horizontal distance (NOT degree-space) using `wgs_converter.metres_to_degrees(lat, buffer_m)`.
|
||||
- Error hierarchy at `src/operator_tool/flights_api_errors.py`:
|
||||
- `FlightsApiError` (base) → `FlightsApiUnreachableError`, `FlightsApiAuthError`, `FlightNotFoundError`, `FlightsApiSchemaError`, `FlightFileNotFoundError`, `EmptyWaypointsError`, `WaypointSchemaError`.
|
||||
- Composition-root factory entry at `src/gps_denied_onboard/runtime_root/c12_factory.py`:
|
||||
- Extend the `OperatorToolServices` dataclass with `flights_api_client: FlightsApiClient`.
|
||||
- `build_flights_api_client(config) -> FlightsApiClient` constructs the httpx client with TLS verify on (no `verify=False`), default timeout `10.0 s`, and the project's `WgsConverter`.
|
||||
- Logging:
|
||||
- INFO on every successful fetch (`kind="c12.flights.fetch.success"`) with `flight_id`, `waypoint_count`, `bbox` summary. NO `auth_token` in any log line.
|
||||
- WARN on retry attempts (`kind="c12.flights.fetch.retry"`).
|
||||
- ERROR on each failure variant (`kind="c12.flights.fetch.failed"` with the resolved error class name).
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
|
||||
- `FlightsApiClient` Protocol + `HttpxFlightsApiClient` concrete impl.
|
||||
- `FlightDto` + `WaypointDto` + enums.
|
||||
- Online path (`fetch_flight`) with one-retry-on-transient-5xx-or-connect-error semantics per FAC-INV-5.
|
||||
- Offline path (`load_flight_file`) reading the same DTO shape.
|
||||
- `bbox_from_waypoints` envelope + horizontal-distance buffer via `WgsConverter`.
|
||||
- `takeoff_origin_from_flight` pass-through of `waypoints[0]`.
|
||||
- Error hierarchy with one-line operator-friendly text per class.
|
||||
- Composition-root factory + service-dataclass extension.
|
||||
- Unit tests covering every AC + Protocol conformance.
|
||||
|
||||
### Excluded
|
||||
|
||||
- Flight authoring / editing (Mission Planner UI owns).
|
||||
- Live updates / websockets.
|
||||
- Caching `FlightDto` across runs.
|
||||
- Writing build status back to the `flights` REST service.
|
||||
- Wiring `--flight-id` / `--flight-file` flags into the CLI (AZ-326).
|
||||
- Wiring the resolved DTO into the orchestrator phase 0 (AZ-328).
|
||||
- Anything that runs on the airborne companion (this is operator-workstation-only).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: Online happy path — `fetch_flight` returns a populated `FlightDto`**
|
||||
Given a fake httpx transport that returns 200 OK with a valid 3-waypoint JSON body
|
||||
When `fetch_flight(flight_id=..., base_url="https://flights.example", auth_token="abc")` is called
|
||||
Then a `FlightDto` is returned with `flight_id`, `name`, and 3 ordered `WaypointDto` entries (ordinals 0, 1, 2); ONE INFO log; ZERO occurrences of `"abc"` in any log line
|
||||
|
||||
**AC-2: Online 404 — `FlightNotFoundError` with NO retry**
|
||||
Given a fake transport that returns 404
|
||||
When `fetch_flight(flight_id=<unknown>)` is called
|
||||
Then `FlightNotFoundError` is raised; the unknown `flight_id` is in the message; the transport is hit exactly once (no retry); ONE ERROR log
|
||||
|
||||
**AC-3: Online 401 — `FlightsApiAuthError` with NO retry; auth_token NOT logged**
|
||||
Given a fake transport that returns 401
|
||||
When `fetch_flight(auth_token="bearer-xyz")` is called
|
||||
Then `FlightsApiAuthError` is raised; transport hit exactly once; the literal `"bearer-xyz"` does NOT appear in any log line
|
||||
|
||||
**AC-4: Online 503 (transient) — one retry, then success**
|
||||
Given a fake transport that returns 503 on the first call and 200 on the second
|
||||
When `fetch_flight(...)` is called
|
||||
Then the call succeeds; transport is hit exactly twice; ONE WARN log `kind="c12.flights.fetch.retry"`
|
||||
|
||||
**AC-5: Online 503 (persistent) — one retry, then `FlightsApiUnreachableError`**
|
||||
Given a fake transport that always returns 503
|
||||
When `fetch_flight(...)` is called
|
||||
Then `FlightsApiUnreachableError` is raised; transport hit exactly twice; ONE ERROR log
|
||||
|
||||
**AC-6: Online schema drift — `FlightsApiSchemaError` with field reference**
|
||||
Given a fake transport that returns 200 OK with a body missing the `lat` field on a waypoint
|
||||
When `fetch_flight(...)` is called
|
||||
Then `FlightsApiSchemaError` is raised; the message names the missing field
|
||||
|
||||
**AC-7: Offline happy path — `load_flight_file` returns equivalent `FlightDto`**
|
||||
Given a JSON file on disk in the documented schema (3 waypoints, ordinals 0/1/2)
|
||||
When `load_flight_file(path)` is called
|
||||
Then a `FlightDto` is returned with the same shape as the online happy path
|
||||
|
||||
**AC-8: Offline file missing — `FlightFileNotFoundError`**
|
||||
Given a path that does not exist
|
||||
When `load_flight_file(path)` is called
|
||||
Then `FlightFileNotFoundError` is raised; the path is in the message
|
||||
|
||||
**AC-9: Empty waypoints — `bbox_from_waypoints` raises `EmptyWaypointsError`**
|
||||
Given a `FlightDto` with zero waypoints
|
||||
When `bbox_from_waypoints(flight.waypoints, buffer_m=1000.0)` is called
|
||||
Then `EmptyWaypointsError` is raised; the message instructs to re-plan in the Mission Planner UI
|
||||
|
||||
**AC-10: Bbox is horizontal-distance buffered (NOT degree-space)**
|
||||
Given 4 waypoints at the corners of a 1 km × 1 km box centred on (50.0 N, 36.2 E)
|
||||
When `bbox_from_waypoints(waypoints, buffer_m=1000.0)` is called
|
||||
Then the returned bbox extends ~1 km outwards on all sides (NOT 1° outwards); the lat-degree extension is approximately `1000 / 111000 ≈ 0.009°`, the lon-degree extension at 50° N is approximately `1000 / (111000 * cos(50°)) ≈ 0.014°`. Asserted within 5% tolerance against `WgsConverter`.
|
||||
|
||||
**AC-11: Takeoff origin is `waypoints[0]` exactly (no rounding)**
|
||||
Given a `FlightDto` with `waypoints[0] = WaypointDto(ordinal=0, lat_deg=50.000000001, lon_deg=36.2, alt_m=200.0, ...)`
|
||||
When `takeoff_origin_from_flight(flight)` is called
|
||||
Then the returned `LatLonAlt` is `LatLonAlt(50.000000001, 36.2, 200.0)` — no rounding, no projection
|
||||
|
||||
**AC-12: Conformance — `isinstance(impl, FlightsApiClient)` is `True`**
|
||||
|
||||
**AC-13: Online + Offline byte-identical output for same source**
|
||||
Given the online happy path returns DTO `online_dto` and the same JSON written to disk loads as `offline_dto`
|
||||
When both are compared
|
||||
Then `online_dto == offline_dto`
|
||||
|
||||
**AC-14: Waypoint ordering — input shuffled but ordinals contiguous returns sorted tuple**
|
||||
Given waypoints with ordinals `[2, 0, 1]` in input order
|
||||
When `fetch_flight` or `load_flight_file` parses them
|
||||
Then the returned `FlightDto.waypoints` is ordered `[0, 1, 2]`
|
||||
|
||||
**AC-15: Waypoint ordinal gap — raises `WaypointSchemaError`**
|
||||
Given waypoints with ordinals `[0, 1, 3]` (gap)
|
||||
When parsing
|
||||
Then `WaypointSchemaError` is raised; gap is named in the message
|
||||
|
||||
**AC-16: Waypoint out-of-range lat raises `WaypointSchemaError`**
|
||||
Given a waypoint with `lat_deg = 200.0`
|
||||
When parsing
|
||||
Then `WaypointSchemaError`; field name in the message
|
||||
|
||||
**AC-17: NFR — `auth_token` redaction**
|
||||
Given any failure or success path
|
||||
When inspecting captured stdout/stderr logs
|
||||
Then the literal `auth_token` value never appears
|
||||
|
||||
**AC-18: NFR — online call timeout default 10 s**
|
||||
Given a fake transport that delays 11 s
|
||||
When `fetch_flight(timeout_s=10.0)` is called
|
||||
Then `FlightsApiUnreachableError` is raised within ~10 s plus the single 1 s retry budget; ONE WARN log on the retry
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
**Performance**
|
||||
- Online fetch wall-clock on the happy path ≤ 1 s (single HTTP call, default 10 s timeout, network is the dominant factor).
|
||||
- Offline load wall-clock ≤ 100 ms for a JSON file under 1 MB.
|
||||
- `bbox_from_waypoints` is O(N) in waypoint count; ≤ 5 ms for N ≤ 1000.
|
||||
|
||||
**Compatibility**
|
||||
- httpx per the project pin (already used by AZ-316). No new third-party deps unless pydantic is missing — verify against `requirements.txt`; if missing, add at the established version.
|
||||
- TLS verify on by default; no `verify=False` anywhere in production code.
|
||||
|
||||
**Reliability**
|
||||
- Auth token never logged.
|
||||
- Online path retries at most once on transient 5xx or connect error; 401/403/404/schema errors are NEVER retried.
|
||||
- Offline path is fully usable on an air-gapped operator workstation.
|
||||
|
||||
## Unit Tests
|
||||
|
||||
| AC Ref | What to Test | Required Outcome |
|
||||
|--------|-------------|------------------|
|
||||
| AC-1 | Happy 200 OK + 3-waypoint body | DTO; INFO log; no token in log |
|
||||
| AC-2 | 404 response | `FlightNotFoundError`; no retry |
|
||||
| AC-3 | 401 response | `FlightsApiAuthError`; no token in log |
|
||||
| AC-4 | 503 then 200 | Success; ONE retry |
|
||||
| AC-5 | 503 always | `FlightsApiUnreachableError` after one retry |
|
||||
| AC-6 | Missing `lat` in response | `FlightsApiSchemaError`; field name |
|
||||
| AC-7 | Offline well-formed JSON | DTO matches online shape |
|
||||
| AC-8 | Offline missing file | `FlightFileNotFoundError` |
|
||||
| AC-9 | Empty waypoints into `bbox_from_waypoints` | `EmptyWaypointsError` |
|
||||
| AC-10 | Bbox buffer math at 50° N | Within 5% of horizontal-distance target |
|
||||
| AC-11 | Takeoff origin pass-through | No rounding |
|
||||
| AC-12 | Conformance | `isinstance` True |
|
||||
| AC-13 | Online vs Offline DTO equality | `==` |
|
||||
| AC-14 | Shuffled ordinals | Sorted output |
|
||||
| AC-15 | Ordinal gap | `WaypointSchemaError` |
|
||||
| AC-16 | lat=200 | `WaypointSchemaError` |
|
||||
| AC-17 | Token redaction across all paths | Token absent from logs |
|
||||
| AC-18 | Timeout + retry budget | Failure within bounded time |
|
||||
|
||||
## Constraints
|
||||
|
||||
- TLS verify on by default. Production composition root MUST NOT pass `verify=False` to httpx; tests use a separate test transport rather than disabling verification.
|
||||
- `auth_token` field uses `pydantic.SecretStr` (or equivalent) and is NEVER `repr()`-logged.
|
||||
- `bbox_from_waypoints` MUST use `WgsConverter` for the horizontal-distance buffer; naive `lat_deg ± 0.01` style buffering is rejected (fails AC-10 at high latitudes).
|
||||
- The DTO shape MUST mirror `suite/flights/Database/Entities/{Flight,Waypoint}.cs`. Any field added to the C# side requires a follow-up task; this task pins the schema at the current shape.
|
||||
- No companion-side code in this task — the entire package is operator-workstation-only.
|
||||
- Offline `--flight-file` accepts JSON only (orjson); YAML is rejected.
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
**Risk 1: parent-suite `Flight` schema drifts silently**
|
||||
- *Risk*: `suite/flights/` adds or renames a field, online fetches succeed but parse loosely and miss the new field.
|
||||
- *Mitigation*: pydantic schema is strict (`extra="forbid"`); AC-6 covers the schema-violation path; a CI tripwire compares the C# entity to the Python DTO once per build.
|
||||
|
||||
**Risk 2: auth_token leaks via `repr()` of an exception**
|
||||
- *Risk*: a caller logs `repr(exc)` and the wrapped httpx response carries the token in headers.
|
||||
- *Mitigation*: `FlightsApiAuthError`'s `__str__` and `__repr__` explicitly redact; AC-3 + AC-17 cover.
|
||||
|
||||
**Risk 3: bbox too tight at high latitudes due to degree-space buffer**
|
||||
- *Risk*: a naive `lat ± 0.01` buffer is ~1.5× too narrow at 60° N and tiles near the route are missing.
|
||||
- *Mitigation*: AC-10 + AC-NFR enforce horizontal-distance buffer via `WgsConverter`; reviewed in code review.
|
||||
|
||||
**Risk 4: offline JSON drifts from the parent-suite serialization**
|
||||
- *Risk*: an operator hand-edits a JSON export and breaks ordinals or waypoint shape.
|
||||
- *Mitigation*: AC-15 / AC-16 cover schema validation; the error message references the exact field so the operator can fix it.
|
||||
|
||||
## Runtime Completeness
|
||||
|
||||
- **Named capability**: read-only Flight resolution for C12 cache provisioning (ADR-010).
|
||||
- **Production code that must exist**: real `HttpxFlightsApiClient` against the parent-suite `flights` REST service with TLS + auth, real `FlightsFileLoader` for the offline path, real `WgsConverter`-backed bbox buffer.
|
||||
- **Allowed external stubs**: tests use httpx `MockTransport` and a fake clock; production wiring uses real httpx + real WgsConverter.
|
||||
- **Unacceptable substitutes**: requests/urllib3 instead of httpx (project pin says httpx); naive degree-space bbox buffer; logging the auth_token "just for debugging"; verifying TLS off in production code.
|
||||
@@ -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/`.
|
||||
@@ -8,7 +8,7 @@ status: in_progress
|
||||
sub_step:
|
||||
phase: 6
|
||||
name: implement-tasks
|
||||
detail: "batch 20 of N committed (AZ-355 c4 PoseEstimator Protocol + factory + DTOs + composition: new PoseEstimate shape (UUID + LatLonAlt + Quat + np.ndarray + CovarianceMode + PoseSourceLabel + emitted_at ns) + errors + ISam2GraphHandle stub + build_pose_estimator with lazy-import + C4PoseConfig; C5 consumers migrated in lockstep; legacy raw-4x4 pose_se3 shape retired)"
|
||||
detail: "batch 22 of N landed (AZ-489 — C12 FlightsApiClient + offline JSON loader + bbox helper + httpx client). httpx>=0.28,<1.0 added to main deps. 28 unit tests covering AC-1..AC-18 plus extras; full repo 713 passed / 2 skipped. Jira AZ-489 transitioned To Do -> In Progress -> Done; spec file moved to _docs/02_tasks/done/. OperatorToolServices aggregate intentionally deferred to AZ-328 per scope discipline. Next: AZ-490 (C5 set_takeoff_origin entrypoint + bounded-delta gate)."
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
tracker: jira
|
||||
|
||||
Reference in New Issue
Block a user