feat(vessels): AISStream (US) + VesselAPI (Hormuz) as two active providers #19

Merged
sirius merged 3 commits from feat/vesselapi-ais-poller into master 2026-08-29 07:23:37 -04:00
4 changed files with 65 additions and 9 deletions
Showing only changes of commit e7429a4161 - Show all commits

View file

@ -68,12 +68,12 @@ KEY_REGISTRY: dict[str, dict] = {
"example": "123456789:AA… (bot token from @BotFather)", "example": "123456789:AA… (bot token from @BotFather)",
}, },
"AISSTREAM_API_KEY": { "AISSTREAM_API_KEY": {
"description": "AISStream WebSocket key — live vessel positions (server-side only).", "description": "AISStream (open/shared) — live US-coast AIS. Server-side WebSocket only.",
"pattern": r"^.{8,}$", "pattern": r"^.{8,}$",
"example": "key from https://aisstream.io/account (GitHub login)", "example": "key from https://aisstream.io/account (GitHub login)",
}, },
"VESSELAPI_API_KEY": { "VESSELAPI_API_KEY": {
"description": "VesselAPI REST AIS (150 calls/mo free; server-side poller).", "description": "VesselAPI (commercial) — Strait of Hormuz AIS, 5×/day cache. Paste the Bearer token from dashboard.vesselapi.com. Not a US-coast feed.",
"pattern": r"^.{8,}$", "pattern": r"^.{8,}$",
"example": "Bearer token from https://dashboard.vesselapi.com/", "example": "Bearer token from https://dashboard.vesselapi.com/",
}, },

View file

@ -1472,10 +1472,11 @@ async def list_vessels(
limit: int = Query(2000, ge=1, le=5000), limit: int = Query(2000, ge=1, le=5000),
timestamp: str | None = Query(None, description="ISO time — DVR 1-min tracks instead of live"), timestamp: str | None = Query(None, description="ISO time — DVR 1-min tracks instead of live"),
): ):
"""AIS last-known from the AISStream worker and/or the VesselAPI poller. """AIS last-known — union of two independent providers.
Empty without either key / until the first successful poll. VesselAPI AISStream (extra.src="aisstream", live US-coast WebSocket) and VesselAPI
positions upsert into the same store (extra.src = "vesselapi"). (extra.src="vesselapi", Strait of Hormuz 5×/day poll) both upsert into the
same store. Empty without either key / until the first successful poll.
""" """
if bbox: if bbox:
_parse_bbox_query(bbox) _parse_bbox_query(bbox)

View file

@ -1,8 +1,10 @@
"""VesselAPI REST poller — quota-capped AIS fallback (free tier 150 calls/mo). """VesselAPI REST poller — quota-capped AIS for the Middle East (free tier 150 calls/mo).
AISStream (WebSocket) keeps US coasts live; VesselAPI fills the Middle East VesselAPI and AISStream are two independent, first-class vessel providers
blind spot (Strait of Hormuz default box). This worker polls the REST not a primary/fallback pair. AISStream (WebSocket) owns live US-coast AIS;
``GET /v1/location/vessels/bounding-box`` endpoint at most VesselAPI (REST) covers the Strait of Hormuz (default box) where AISStream
has no coverage. Missing one key never disables the other. This worker polls
the REST ``GET /v1/location/vessels/bounding-box`` endpoint at most
``VESSELAPI_MAX_CALLS_PER_DAY`` (default 5) *successful 2xx* calls per UTC day ``VESSELAPI_MAX_CALLS_PER_DAY`` (default 5) *successful 2xx* calls per UTC day
and upserts the results into the shared ``vessel_last_known`` store. and upserts the results into the shared ``vessel_last_known`` store.

View file

@ -296,3 +296,56 @@ def _make_get_client(client):
return client return client
return _get_client return _get_client
# ── Independent-provider idle behaviour ───────────────────────────────────
# VesselAPI must never be gated on AISStream (or vice versa): a missing key on
# one provider leaves the other running. ``_StopLoop`` is a BaseException so
# the worker's ``except Exception`` handler can't swallow it — the first
# ``asyncio.sleep`` aborts the loop after exactly one decision.
class _StopLoop(BaseException):
pass
async def _stop_sleep(*_a, **_k):
raise _StopLoop()
async def _resolve(value: str):
return value
def test_worker_idles_without_vesselapi_key_even_if_aisstream_set(monkeypatch):
# AISStream key present, VesselAPI key absent -> no poll, still idles.
monkeypatch.setenv("AISSTREAM_API_KEY", "unused-aisstream-key")
monkeypatch.setenv("VESSELAPI_API_KEY", "")
monkeypatch.setattr(vesselapi, "_resolve_key", lambda: _resolve(""))
monkeypatch.setattr(vesselapi.asyncio, "sleep", _stop_sleep)
poll_calls: list = []
async def _spy_poll(store, boxes, key):
poll_calls.append(key)
return True
monkeypatch.setattr(vesselapi, "poll_once", _spy_poll)
with pytest.raises(_StopLoop):
asyncio.run(vesselapi.run_vesselapi_worker(FakeStore()))
assert poll_calls == []
def test_worker_polls_with_vesselapi_key_even_if_aisstream_unset(monkeypatch):
# AISStream key absent, VesselAPI key present -> still polls exactly once.
monkeypatch.setenv("AISSTREAM_API_KEY", "")
monkeypatch.setattr(vesselapi, "_resolve_key", lambda: _resolve("vesselapi-key"))
monkeypatch.setattr(vesselapi.asyncio, "sleep", _stop_sleep)
poll_calls: list = []
async def _spy_poll(store, boxes, key):
poll_calls.append(key)
return True
monkeypatch.setattr(vesselapi, "poll_once", _spy_poll)
with pytest.raises(_StopLoop):
asyncio.run(vesselapi.run_vesselapi_worker(FakeStore()))
assert poll_calls == ["vesselapi-key"]