[AZ-316] Implement C11 HttpTileDownloader (batch 40)

Lands the operator-side pre-flight download path: authenticated
httpx GETs against satellite-provider, RESTRICT-SAT-4 (>= 0.5 m/px)
enforcement at the C11 boundary, c6 writes via consumer-side cuts
(_TileWriterLike, _BudgetEnforcerLike), per-(flight_id, request_hash)
journal under cache_root/.c11/journal/ for idempotent re-runs (AC-8,
AC-12), 429 Retry-After + 5xx exponential backoff handling, fail-fast
on TLS / 401 / 403, and a redacted-bearer auth-header policy.

Architecture:
- AZ-507 cross-component rule held: tile_downloader.py imports zero
  c6 symbols; the composition-root _C6DownloadAdapter in
  runtime_root/c11_factory.py absorbs c6's TileMetadata / TileSource /
  FreshnessLabel / VotingStatus enum assembly.
- Sleep-callable injection (not full Clock) per Batch 39 precedent;
  default routes through WallClock.sleep_until_ns to keep the AZ-398
  invariant intact.
- No FDR records on the download path; spec mandates structured logs
  only (8 log kinds wired: session.start/end, resolution_rejected,
  freshness_rejected_summary, freshness_downgraded, batch.retry,
  provider.failed, budget.exceeded, idempotent_no_op).

Tests: 14 new downloader unit tests covering AC-1..AC-9, AC-11, AC-12
plus throughput NFR + 429 HTTP-date + 429 budget exhaustion; 2 new
TileDownloader Protocol conformance tests (AC-10). Full unit suite:
1420 passed, 80 skipped (env-gated), 0 failed.

Code review: PASS_WITH_WARNINGS (5 Low findings, all documentation
or downstream-blocked). See _docs/03_implementation/reviews/
batch_40_review.md and batch_40_cycle1_report.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 07:01:14 +03:00
parent 3a61a4f5bf
commit 90f4ac78f4
13 changed files with 2513 additions and 62 deletions
@@ -1,18 +1,20 @@
"""C11 TileManager config block (AZ-319).
"""C11 TileManager config block (AZ-316, AZ-319).
Registered into ``config.components['c11_tile_manager']`` by the
package ``__init__.py``. The composition-root factory
:func:`gps_denied_onboard.runtime_root.c11_factory.build_tile_uploader`
reads this block to drive the upload path's HTTP behaviour and to
identify the producing companion against the parent suite's voting
layer.
package ``__init__.py``. Two composition-root factories read this
block:
The four fields below match the AZ-319 task spec § ``Outcome`` —
``config.c11.satellite_provider_ingest_url``,
``config.c11.upload_batch_size``, ``config.c11.upload_http_timeout_s``,
``config.c11.companion_id``. The ``upload_max_retry_after_s`` cap is
the Risk-3 ceiling on cumulative ``Retry-After`` budget for 429
responses (see :class:`RateLimitedError`).
* :func:`gps_denied_onboard.runtime_root.c11_factory.build_tile_uploader`
reads the ``upload_*`` fields and ``companion_id`` to drive AZ-319.
* :func:`gps_denied_onboard.runtime_root.c11_factory.build_tile_downloader`
reads the ``satellite_provider_url``, ``service_api_key``, and
``download_*`` fields to drive AZ-316.
All defaults are conservative no-op values so unit tests / replay
runs that do not exercise C11 keep working without YAML; the factory
raises :class:`ConfigError` when an empty production-required field
(``service_api_key``, ``companion_id``, etc.) is observed in operator
wiring.
"""
from __future__ import annotations
@@ -28,25 +30,42 @@ _DEFAULT_BATCH_SIZE: int = 25
_DEFAULT_HTTP_TIMEOUT_S: float = 30.0
_DEFAULT_MAX_RETRY_AFTER_S: int = 600
_MAX_BATCH_SIZE: int = 200
_DEFAULT_DOWNLOAD_RESOLUTION_FLOOR: float = 0.5
_DEFAULT_DOWNLOAD_MAX_5XX_RETRIES: int = 4
_MIN_DOWNLOAD_RETRIES: int = 1
_MAX_DOWNLOAD_RETRIES: int = 16
@dataclass(frozen=True)
class C11Config:
"""Per-component config for C11 tile manager (upload path).
"""Per-component config for C11 tile manager (upload + download paths).
``satellite_provider_ingest_url`` is the parent-suite ingest base
URL (e.g. ``https://satellite-provider.example.com``); the
uploader appends ``/api/satellite/tiles/ingest`` to it. Defaulted
to empty so unit tests / replay runs that do not exercise the
upload path stay no-op; production configuration MUST set this
via YAML / env override or :class:`HttpTileUploader` raises
:class:`SatelliteProviderError` on the first attempt.
Upload-side fields (AZ-319):
``companion_id`` is the stable per-companion identifier the
parent suite's voting layer uses to attribute uploads to one
physical airframe. Defaulted to empty so test runs without a
paired companion stay valid; the factory raises ``ConfigError``
when the empty default is used in operator / production wiring.
* ``satellite_provider_ingest_url`` — base URL for the upload
endpoint; ``HttpTileUploader`` appends
``/api/satellite/tiles/ingest``. Empty → upload factory raises
:class:`ConfigError`.
* ``upload_batch_size`` — tiles per multipart POST.
* ``upload_http_timeout_s`` — per-request timeout (seconds).
* ``upload_max_retry_after_s`` — cumulative 429 ``Retry-After``
cap before :class:`RateLimitedError`.
* ``companion_id`` — stable per-companion id for D-PROJ-2 voting.
Download-side fields (AZ-316):
* ``satellite_provider_url`` — base URL for the GET surface;
``HttpTileDownloader`` appends per-tile / list paths.
* ``service_api_key`` — bearer token for authenticated GETs;
logged ONLY redacted (``Bearer ***``). Empty → download factory
raises :class:`ConfigError`.
* ``download_http_timeout_s`` — per-request timeout (seconds).
* ``download_max_5xx_retries`` — exponential-backoff cap before
:class:`SatelliteProviderError`.
* ``download_max_retry_after_s`` — cumulative 429 ``Retry-After``
cap before :class:`RateLimitedError`.
* ``download_resolution_floor_m_per_px`` — RESTRICT-SAT-4 lower
bound for the C11 boundary check; defaults to 0.5 m/px.
"""
satellite_provider_ingest_url: str = ""
@@ -55,6 +74,13 @@ class C11Config:
upload_max_retry_after_s: int = _DEFAULT_MAX_RETRY_AFTER_S
companion_id: str = ""
satellite_provider_url: str = ""
service_api_key: str = ""
download_http_timeout_s: float = _DEFAULT_HTTP_TIMEOUT_S
download_max_5xx_retries: int = _DEFAULT_DOWNLOAD_MAX_5XX_RETRIES
download_max_retry_after_s: int = _DEFAULT_MAX_RETRY_AFTER_S
download_resolution_floor_m_per_px: float = _DEFAULT_DOWNLOAD_RESOLUTION_FLOOR
def __post_init__(self) -> None:
if not 1 <= self.upload_batch_size <= _MAX_BATCH_SIZE:
raise ConfigError(
@@ -71,3 +97,24 @@ class C11Config:
"C11Config.upload_max_retry_after_s must be > 0; "
f"got {self.upload_max_retry_after_s}"
)
if self.download_http_timeout_s <= 0:
raise ConfigError(
"C11Config.download_http_timeout_s must be > 0; "
f"got {self.download_http_timeout_s}"
)
if not _MIN_DOWNLOAD_RETRIES <= self.download_max_5xx_retries <= _MAX_DOWNLOAD_RETRIES:
raise ConfigError(
"C11Config.download_max_5xx_retries must be in "
f"[{_MIN_DOWNLOAD_RETRIES}, {_MAX_DOWNLOAD_RETRIES}]; "
f"got {self.download_max_5xx_retries}"
)
if self.download_max_retry_after_s <= 0:
raise ConfigError(
"C11Config.download_max_retry_after_s must be > 0; "
f"got {self.download_max_retry_after_s}"
)
if self.download_resolution_floor_m_per_px <= 0:
raise ConfigError(
"C11Config.download_resolution_floor_m_per_px must be > 0; "
f"got {self.download_resolution_floor_m_per_px}"
)