Merge pull request 'feat(vessels): AISStream (US) + VesselAPI (Hormuz) as two active providers' (#19) from feat/vesselapi-ais-poller into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 2m51s
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 2m51s
Reviewed-on: #19
This commit is contained in:
commit
7b48caa793
9 changed files with 866 additions and 5 deletions
18
.env.example
18
.env.example
|
|
@ -59,6 +59,24 @@ FIRMS_INTERVAL=900
|
||||||
# Set to 0 to disable the fire loop entirely.
|
# Set to 0 to disable the fire loop entirely.
|
||||||
INGEST_FIRES=1
|
INGEST_FIRES=1
|
||||||
|
|
||||||
|
# ── VesselAPI (commercial REST AIS — Hormuz, 5×/day, 150 calls/mo cap) ─────
|
||||||
|
# Independent of AISStream (open/shared live US-coast WebSocket). Both stay
|
||||||
|
# on when their keys are set; missing one never disables the other.
|
||||||
|
# Prefer pasting VESSELAPI_API_KEY on the dashboard Keys page.
|
||||||
|
# The poller idles when the key is unset. Never called from map pans
|
||||||
|
# (GET /api/vessels serves the shared last-known cache only).
|
||||||
|
VESSELAPI_API_KEY=
|
||||||
|
# Bounding box(es) as minlat,minlon,maxlat,maxlon (lat/lon order). Semicolon-
|
||||||
|
# separated for multiple boxes. Default = Strait of Hormuz (span 3.6 ≤ 4° cap).
|
||||||
|
VESSELAPI_BBOX=25.5,55.4,27.3,57.2
|
||||||
|
# Poll cadence in seconds (17280 = 4.8h → 5 polls/day = 150/mo).
|
||||||
|
VESSELAPI_INTERVAL=17280
|
||||||
|
# Local hard cap on successful 2xx calls per UTC day (persisted in Postgres).
|
||||||
|
VESSELAPI_MAX_CALLS_PER_DAY=5
|
||||||
|
# 1 = run the poller inside the dashboard process (default); ingester off.
|
||||||
|
VESSELAPI_IN_APP=1
|
||||||
|
VESSELAPI_IN_INGEST=0
|
||||||
|
|
||||||
# ── API keys (managed from the dashboard UI) ──────────────────────────────
|
# ── API keys (managed from the dashboard UI) ──────────────────────────────
|
||||||
# Keys such as NOUS_API_KEY and TELEGRAM_TOKEN are stored in the Postgres
|
# Keys such as NOUS_API_KEY and TELEGRAM_TOKEN are stored in the Postgres
|
||||||
# `api_keys` table and managed from the dashboard's "Keys" tab
|
# `api_keys` table and managed from the dashboard's "Keys" tab
|
||||||
|
|
|
||||||
|
|
@ -81,3 +81,19 @@ AISSTREAM_BBOX = os.getenv("AISSTREAM_BBOX", "24,-125,50,-66")
|
||||||
# without the ingest profile). Set 0 if the ingester owns the only connection.
|
# without the ingest profile). Set 0 if the ingester owns the only connection.
|
||||||
AISSTREAM_IN_APP = os.getenv("AISSTREAM_IN_APP", "1").lower() in ("1", "true", "yes")
|
AISSTREAM_IN_APP = os.getenv("AISSTREAM_IN_APP", "1").lower() in ("1", "true", "yes")
|
||||||
AISSTREAM_IN_INGEST = os.getenv("AISSTREAM_IN_INGEST", "0").lower() in ("1", "true", "yes")
|
AISSTREAM_IN_INGEST = os.getenv("AISSTREAM_IN_INGEST", "0").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
# VesselAPI (quota-capped REST AIS poller — free tier 150 calls/mo).
|
||||||
|
# AISStream keeps US coasts; VesselAPI fills the Middle East blind spot. The
|
||||||
|
# poller idles when VESSELAPI_API_KEY is unset (never from GET /api/vessels).
|
||||||
|
VESSELAPI_API_KEY = os.getenv("VESSELAPI_API_KEY", "")
|
||||||
|
# Bounding box(es) as minlat,minlon,maxlat,maxlon — note lat/lon order (same as
|
||||||
|
# AISSTREAM_BBOX). Semicolon-separated for multiple boxes. Default: Strait of
|
||||||
|
# Hormuz (|dLat|+|dLon| = 3.6 ≤ 4° span cap). VesselAPI 400s any box over 4°.
|
||||||
|
VESSELAPI_BBOX = os.getenv("VESSELAPI_BBOX", "25.5,55.4,27.3,57.2")
|
||||||
|
# Poll cadence in seconds. 17280 = 4.8h → 5 polls/day (150/mo free tier).
|
||||||
|
VESSELAPI_INTERVAL = int(os.getenv("VESSELAPI_INTERVAL", "17280"))
|
||||||
|
# Local hard cap on successful 2xx calls per UTC day (persisted in Postgres).
|
||||||
|
VESSELAPI_MAX_CALLS_PER_DAY = int(os.getenv("VESSELAPI_MAX_CALLS_PER_DAY", "5"))
|
||||||
|
# Run the VesselAPI poller inside the dashboard process (default on, like AIS).
|
||||||
|
VESSELAPI_IN_APP = os.getenv("VESSELAPI_IN_APP", "1").lower() in ("1", "true", "yes")
|
||||||
|
VESSELAPI_IN_INGEST = os.getenv("VESSELAPI_IN_INGEST", "0").lower() in ("1", "true", "yes")
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,15 @@ 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": {
|
||||||
|
"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,}$",
|
||||||
|
"example": "Bearer token from https://dashboard.vesselapi.com/",
|
||||||
|
},
|
||||||
"OPENSKY_CLIENT_ID": {
|
"OPENSKY_CLIENT_ID": {
|
||||||
"description": "OpenSky OAuth client id — optional ADS-B fallback (unused until enabled).",
|
"description": "OpenSky OAuth client id — optional ADS-B fallback (unused until enabled).",
|
||||||
"example": "client id from opensky-network.org account",
|
"example": "client id from opensky-network.org account",
|
||||||
|
|
|
||||||
17
app/main.py
17
app/main.py
|
|
@ -71,18 +71,26 @@ async def _lifespan(app: FastAPI):
|
||||||
await refresh_cache()
|
await refresh_cache()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
from config import AISSTREAM_IN_APP
|
from config import AISSTREAM_IN_APP, VESSELAPI_IN_APP
|
||||||
ais_task = None
|
ais_task = None
|
||||||
|
vesselapi_task = None
|
||||||
adsb_task = None
|
adsb_task = None
|
||||||
if AISSTREAM_IN_APP:
|
if AISSTREAM_IN_APP:
|
||||||
from ais_stream import run_ais_worker
|
from ais_stream import run_ais_worker
|
||||||
ais_task = asyncio.create_task(run_ais_worker())
|
ais_task = asyncio.create_task(run_ais_worker())
|
||||||
|
if VESSELAPI_IN_APP:
|
||||||
|
from vesselapi import run_vesselapi_worker
|
||||||
|
vesselapi_task = asyncio.create_task(run_vesselapi_worker())
|
||||||
adsb_task = asyncio.create_task(_adsb_refresh_loop())
|
adsb_task = asyncio.create_task(_adsb_refresh_loop())
|
||||||
yield
|
yield
|
||||||
if ais_task is not None:
|
if ais_task is not None:
|
||||||
ais_task.cancel()
|
ais_task.cancel()
|
||||||
|
if vesselapi_task is not None:
|
||||||
|
vesselapi_task.cancel()
|
||||||
if adsb_task is not None:
|
if adsb_task is not None:
|
||||||
adsb_task.cancel()
|
adsb_task.cancel()
|
||||||
|
from vesselapi import close_client
|
||||||
|
await close_client()
|
||||||
await close_http()
|
await close_http()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1464,7 +1472,12 @@ 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 server-side AISStream worker. Empty without a key."""
|
"""AIS last-known — union of two independent providers.
|
||||||
|
|
||||||
|
AISStream (extra.src="aisstream", live US-coast WebSocket) and 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)
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import sys
|
||||||
|
|
||||||
sys.path.insert(0, sys_path)
|
sys.path.insert(0, sys_path)
|
||||||
|
|
||||||
from config import NATS_URL, FIRMS_INTERVAL, FIRMS_DATASET, AISSTREAM_IN_INGEST # noqa: E402
|
from config import NATS_URL, FIRMS_INTERVAL, FIRMS_DATASET, AISSTREAM_IN_INGEST, VESSELAPI_IN_INGEST # noqa: E402
|
||||||
from sources import ingest_rss_feed, ingest_gdelt, ingest_earthquakes, ingest_eonet, ingest_cisa_kev # noqa: E402
|
from sources import ingest_rss_feed, ingest_gdelt, ingest_earthquakes, ingest_eonet, ingest_cisa_kev # noqa: E402
|
||||||
from fire_sources import ingest_fires # noqa: E402
|
from fire_sources import ingest_fires # noqa: E402
|
||||||
from ingestor import ingest_event, start_nats_consumer # noqa: E402
|
from ingestor import ingest_event, start_nats_consumer # noqa: E402
|
||||||
|
|
@ -134,6 +134,9 @@ async def main() -> None:
|
||||||
if AISSTREAM_IN_INGEST:
|
if AISSTREAM_IN_INGEST:
|
||||||
from ais_stream import run_ais_worker # noqa: E402
|
from ais_stream import run_ais_worker # noqa: E402
|
||||||
tasks.append(asyncio.create_task(run_ais_worker()))
|
tasks.append(asyncio.create_task(run_ais_worker()))
|
||||||
|
if VESSELAPI_IN_INGEST:
|
||||||
|
from vesselapi import run_vesselapi_worker # noqa: E402
|
||||||
|
tasks.append(asyncio.create_task(run_vesselapi_worker()))
|
||||||
await asyncio.gather(producer_loop(), consumer_loop(), *tasks)
|
await asyncio.gather(producer_loop(), consumer_loop(), *tasks)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -805,7 +805,7 @@
|
||||||
<label class="lp-name"><input type="checkbox" id="lp-vessels-on" onchange="toggleVessels()"> <span class="lp-dot vessels"></span> Vessels · AIS</label>
|
<label class="lp-name"><input type="checkbox" id="lp-vessels-on" onchange="toggleVessels()"> <span class="lp-dot vessels"></span> Vessels · AIS</label>
|
||||||
<span class="lp-count" id="lp-vessels-count">0</span>
|
<span class="lp-count" id="lp-vessels-count">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="lp-note">Needs AISSTREAM_API_KEY. Magenta = military · gold = law/gov · teal = underway. Click for type / dest / IMO.</div>
|
<div class="lp-note">Two feeds: AISStream (US live) and VesselAPI (Hormuz, 5×/day). Paste both keys on the Keys page. Magenta = military · gold = law/gov · teal = underway. Click for type / dest / IMO.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lp-layer">
|
<div class="lp-layer">
|
||||||
<div class="lp-row">
|
<div class="lp-row">
|
||||||
|
|
|
||||||
445
app/vesselapi.py
Normal file
445
app/vesselapi.py
Normal file
|
|
@ -0,0 +1,445 @@
|
||||||
|
"""VesselAPI REST poller — quota-capped AIS for the Middle East (free tier 150 calls/mo).
|
||||||
|
|
||||||
|
VesselAPI and AISStream are two independent, first-class vessel providers —
|
||||||
|
not a primary/fallback pair. AISStream (WebSocket) owns live US-coast AIS;
|
||||||
|
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
|
||||||
|
and upserts the results into the shared ``vessel_last_known`` store.
|
||||||
|
|
||||||
|
Idle (no crash) when VESSELAPI_API_KEY is unset. Never called from the GET
|
||||||
|
/api/vessels path — map pans must not hit upstream. One request per poll,
|
||||||
|
``pagination.limit=50``, never follow ``nextToken``, never send ``filter.sat``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import calendar
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import Column, Date, DateTime, Integer, Table, func, select, text
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
OSINT_USER_AGENT,
|
||||||
|
VESSELAPI_API_KEY,
|
||||||
|
VESSELAPI_BBOX,
|
||||||
|
VESSELAPI_INTERVAL,
|
||||||
|
VESSELAPI_MAX_CALLS_PER_DAY,
|
||||||
|
)
|
||||||
|
from database import async_session, engine, metadata
|
||||||
|
from live_layers import to_marker, upsert_vessel
|
||||||
|
|
||||||
|
logger = logging.getLogger("osint.vesselapi")
|
||||||
|
|
||||||
|
BASE_URL = "https://api.vesselapi.com/v1"
|
||||||
|
ENDPOINT = f"{BASE_URL}/location/vessels/bounding-box"
|
||||||
|
MAX_SPAN_DEG = 4.0 # |dLat| + |dLon| — VesselAPI 400s above this.
|
||||||
|
PAGE_LIMIT = 50 # pagination.limit; never follow nextToken on the free tier.
|
||||||
|
|
||||||
|
_client: httpx.AsyncClient | None = None
|
||||||
|
_client_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Box parsing / span validation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
class BboxError(ValueError):
|
||||||
|
"""A configured VesselAPI box violates the 4° span rule or is malformed."""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_bbox_span(
|
||||||
|
minlat: float, minlon: float, maxlat: float, maxlon: float,
|
||||||
|
) -> None:
|
||||||
|
"""Reject boxes VesselAPI would 400 on (span > 4°, bad order, bad range)."""
|
||||||
|
if not (-90 <= minlat <= 90 and -90 <= maxlat <= 90
|
||||||
|
and -180 <= minlon <= 180 and -180 <= maxlon <= 180):
|
||||||
|
raise BboxError("coordinates out of range")
|
||||||
|
if minlat >= maxlat or minlon >= maxlon:
|
||||||
|
raise BboxError("bbox must have min < max on both axes")
|
||||||
|
dlat = abs(maxlat - minlat)
|
||||||
|
dlon = abs(maxlon - minlon)
|
||||||
|
if dlat + dlon > MAX_SPAN_DEG:
|
||||||
|
raise BboxError(
|
||||||
|
f"span |dLat|+|dLon| = {dlat + dlon:.2f}° exceeds {MAX_SPAN_DEG}° cap"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_boxes(raw: str) -> list[tuple[float, float, float, float]]:
|
||||||
|
"""Env format: ``minlat,minlon,maxlat,maxlon[; ...]`` (lat/lon order)."""
|
||||||
|
out: list[tuple[float, float, float, float]] = []
|
||||||
|
for chunk in (raw or "").split(";"):
|
||||||
|
parts = [p.strip() for p in chunk.split(",") if p.strip()]
|
||||||
|
if len(parts) != 4:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
minlat = float(parts[0])
|
||||||
|
minlon = float(parts[1])
|
||||||
|
maxlat = float(parts[2])
|
||||||
|
maxlon = float(parts[3])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
out.append((minlat, minlon, maxlat, maxlon))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_boxes_validated(raw: str) -> list[tuple[float, float, float, float]]:
|
||||||
|
"""Parse boxes, log + skip any that violate the span/order/range rules."""
|
||||||
|
valid: list[tuple[float, float, float, float]] = []
|
||||||
|
for box in parse_boxes(raw):
|
||||||
|
try:
|
||||||
|
validate_bbox_span(*box)
|
||||||
|
valid.append(box)
|
||||||
|
except BboxError as exc:
|
||||||
|
logger.warning("VesselAPI bbox %r skipped: %s", box, exc)
|
||||||
|
return valid
|
||||||
|
|
||||||
|
|
||||||
|
# ── Position → marker transform ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _f(value: object) -> float | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _s(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def transform_vesselapi_position(obj: dict | None) -> dict | None:
|
||||||
|
"""Map one VesselAPI position object to the shared marker contract.
|
||||||
|
|
||||||
|
Returns None for glitch rows, missing MMSI, or missing coordinates.
|
||||||
|
"""
|
||||||
|
if not obj or not isinstance(obj, dict):
|
||||||
|
return None
|
||||||
|
if obj.get("suspected_glitch") is True:
|
||||||
|
return None
|
||||||
|
mmsi = obj.get("mmsi")
|
||||||
|
if mmsi is None:
|
||||||
|
return None
|
||||||
|
lat = _f(obj.get("latitude"))
|
||||||
|
lon = _f(obj.get("longitude"))
|
||||||
|
if lat is None or lon is None:
|
||||||
|
return None
|
||||||
|
mmsi_s = str(mmsi)
|
||||||
|
name = _s(obj.get("vessel_name") or obj.get("name"))
|
||||||
|
heading = _f(obj.get("heading"))
|
||||||
|
if heading is None:
|
||||||
|
heading = _f(obj.get("cog"))
|
||||||
|
sog = _f(obj.get("sog"))
|
||||||
|
extra: dict = {
|
||||||
|
"src": "vesselapi",
|
||||||
|
"mmsi": mmsi_s,
|
||||||
|
"cog": obj.get("cog"),
|
||||||
|
"sog": obj.get("sog"),
|
||||||
|
"navstat": obj.get("nav_status"),
|
||||||
|
}
|
||||||
|
imo = obj.get("imo")
|
||||||
|
if imo:
|
||||||
|
extra["imo"] = imo
|
||||||
|
dest = _s(obj.get("dest") or obj.get("destination"))
|
||||||
|
if dest:
|
||||||
|
extra["dest"] = dest
|
||||||
|
ts = obj.get("timestamp") or obj.get("processed_timestamp")
|
||||||
|
if ts:
|
||||||
|
extra["timestamp"] = ts
|
||||||
|
return to_marker(
|
||||||
|
mmsi_s, lat, lon,
|
||||||
|
heading=heading,
|
||||||
|
speed=sog,
|
||||||
|
label=name or mmsi_s,
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def transform_vesselapi_payload(payload: dict | None) -> list[dict]:
|
||||||
|
"""Flatten a bounding-box response ``{vessels: [...]}`` to markers."""
|
||||||
|
if not payload or not isinstance(payload, dict):
|
||||||
|
return []
|
||||||
|
rows = payload.get("vessels") or []
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
marker = transform_vesselapi_position(row)
|
||||||
|
if marker:
|
||||||
|
out.append(marker)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── Durable daily quota (Postgres, survives restarts) ─────────────────────
|
||||||
|
# Mirrors keystore.api_keys: lazy CREATE TABLE IF NOT EXISTS, no alembic fork.
|
||||||
|
|
||||||
|
vesselapi_quota = Table(
|
||||||
|
"vesselapi_quota",
|
||||||
|
metadata,
|
||||||
|
Column("day", Date, primary_key=True),
|
||||||
|
Column("calls", Integer, nullable=False, server_default="0"),
|
||||||
|
Column("remaining", Integer, nullable=True),
|
||||||
|
Column("updated_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
_CREATE_QUOTA_SQL = text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS vesselapi_quota (
|
||||||
|
day DATE PRIMARY KEY,
|
||||||
|
calls INTEGER NOT NULL DEFAULT 0,
|
||||||
|
remaining INTEGER,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_quota_lock = asyncio.Lock()
|
||||||
|
_quota_ensured = False
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_quota_table() -> None:
|
||||||
|
global _quota_ensured
|
||||||
|
if _quota_ensured:
|
||||||
|
return
|
||||||
|
async with _quota_lock:
|
||||||
|
if _quota_ensured:
|
||||||
|
return
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(_CREATE_QUOTA_SQL)
|
||||||
|
_quota_ensured = True
|
||||||
|
|
||||||
|
|
||||||
|
class PgQuotaStore:
|
||||||
|
"""Postgres-backed daily call counter. Injected for tests."""
|
||||||
|
|
||||||
|
async def calls_today(self, day: date) -> int:
|
||||||
|
await ensure_quota_table()
|
||||||
|
async with async_session() as session:
|
||||||
|
row = (await session.execute(
|
||||||
|
select(vesselapi_quota.c.calls).where(vesselapi_quota.c.day == day)
|
||||||
|
)).scalar()
|
||||||
|
return int(row) if row else 0
|
||||||
|
|
||||||
|
async def remaining_today(self, day: date) -> int | None:
|
||||||
|
await ensure_quota_table()
|
||||||
|
async with async_session() as session:
|
||||||
|
row = (await session.execute(
|
||||||
|
select(vesselapi_quota.c.remaining).where(vesselapi_quota.c.day == day)
|
||||||
|
)).scalar()
|
||||||
|
return int(row) if row is not None else None
|
||||||
|
|
||||||
|
async def bump(self, day: date, remaining: int | None) -> int:
|
||||||
|
await ensure_quota_table()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
async with async_session() as session:
|
||||||
|
existing = (await session.execute(
|
||||||
|
select(vesselapi_quota.c.calls).where(vesselapi_quota.c.day == day)
|
||||||
|
)).scalar()
|
||||||
|
if existing is None:
|
||||||
|
await session.execute(
|
||||||
|
vesselapi_quota.insert().values(
|
||||||
|
day=day, calls=1, remaining=remaining, updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await session.execute(
|
||||||
|
vesselapi_quota.update()
|
||||||
|
.where(vesselapi_quota.c.day == day)
|
||||||
|
.values(
|
||||||
|
calls=vesselapi_quota.c.calls + 1,
|
||||||
|
remaining=remaining,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return (int(existing) if existing else 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Budget / scheduling (pure, unit-testable) ─────────────────────────────
|
||||||
|
|
||||||
|
def days_left_in_month(now: datetime) -> int:
|
||||||
|
"""UTC days remaining in the current month, inclusive of today."""
|
||||||
|
_, last = calendar.monthrange(now.year, now.month)
|
||||||
|
return last - now.day + 1
|
||||||
|
|
||||||
|
|
||||||
|
def budget_allows(
|
||||||
|
calls_today: int,
|
||||||
|
remaining: int | None,
|
||||||
|
days_left: int,
|
||||||
|
max_per_day: int,
|
||||||
|
) -> bool:
|
||||||
|
"""True if another poll is permitted today.
|
||||||
|
|
||||||
|
Local hard cap: fewer than ``max_per_day`` successful calls today.
|
||||||
|
Monthly floor: if ``X-RateLimit-Remaining`` is known, keep at least
|
||||||
|
``max_per_day * days_left`` in reserve for the rest of the month.
|
||||||
|
"""
|
||||||
|
if calls_today >= max_per_day:
|
||||||
|
return False
|
||||||
|
if remaining is not None and remaining <= max_per_day * days_left:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def choose_box(
|
||||||
|
boxes: list[tuple[float, float, float, float]],
|
||||||
|
calls_today: int,
|
||||||
|
max_per_day: int,
|
||||||
|
) -> int:
|
||||||
|
"""Index into ``boxes`` for the next poll.
|
||||||
|
|
||||||
|
Prefer refreshing the first (primary) box rather than spraying one call
|
||||||
|
across every region — round-robin only when the remaining daily budget is
|
||||||
|
enough to cover all boxes.
|
||||||
|
"""
|
||||||
|
if len(boxes) <= 1:
|
||||||
|
return 0
|
||||||
|
budget_left = max_per_day - calls_today
|
||||||
|
if budget_left >= len(boxes):
|
||||||
|
return calls_today % len(boxes)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP / poll ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _resolve_key() -> str:
|
||||||
|
from keystore import get_api_key
|
||||||
|
return (
|
||||||
|
os.getenv("VESSELAPI_API_KEY")
|
||||||
|
or VESSELAPI_API_KEY
|
||||||
|
or (await get_api_key("VESSELAPI_API_KEY"))
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_client() -> httpx.AsyncClient:
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
async with _client_lock:
|
||||||
|
if _client is None:
|
||||||
|
_client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(15.0, connect=5.0),
|
||||||
|
follow_redirects=True,
|
||||||
|
headers={"User-Agent": OSINT_USER_AGENT, "Accept": "application/json"},
|
||||||
|
limits=httpx.Limits(max_connections=1, max_keepalive_connections=1),
|
||||||
|
)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_client() -> None:
|
||||||
|
global _client
|
||||||
|
if _client is not None:
|
||||||
|
await _client.aclose()
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def _int_header(value: str | None) -> int | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def poll_once(store, boxes: list[tuple[float, float, float, float]], key: str) -> bool:
|
||||||
|
"""One quota-checked poll. Returns True if a successful 2xx was made.
|
||||||
|
|
||||||
|
Only successful 2xx responses count against the monthly quota; 4xx/5xx/429
|
||||||
|
are skipped without retry-storming (Retry-After respected by simply
|
||||||
|
sleeping the interval).
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
today = now.date()
|
||||||
|
calls = await store.calls_today(today)
|
||||||
|
remaining = await store.remaining_today(today)
|
||||||
|
days_left = days_left_in_month(now)
|
||||||
|
if not budget_allows(calls, remaining, days_left, VESSELAPI_MAX_CALLS_PER_DAY):
|
||||||
|
logger.info(
|
||||||
|
"VesselAPI quota reached (calls_today=%d, remaining=%s, days_left=%d) — skip poll",
|
||||||
|
calls, remaining, days_left,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
idx = choose_box(boxes, calls, VESSELAPI_MAX_CALLS_PER_DAY)
|
||||||
|
minlat, minlon, maxlat, maxlon = boxes[idx]
|
||||||
|
client = await _get_client()
|
||||||
|
params = {
|
||||||
|
"filter.latBottom": str(minlat),
|
||||||
|
"filter.latTop": str(maxlat),
|
||||||
|
"filter.lonLeft": str(minlon),
|
||||||
|
"filter.lonRight": str(maxlon),
|
||||||
|
"pagination.limit": str(PAGE_LIMIT),
|
||||||
|
}
|
||||||
|
headers = {"Authorization": f"Bearer {key}"}
|
||||||
|
try:
|
||||||
|
resp = await client.get(ENDPOINT, params=params, headers=headers)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.warning("VesselAPI request failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if resp.status_code == 429:
|
||||||
|
logger.warning(
|
||||||
|
"VesselAPI rate-limited (Retry-After=%s) — skip poll",
|
||||||
|
resp.headers.get("Retry-After"),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
logger.warning("VesselAPI HTTP %d — not counted against quota", resp.status_code)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2xx success — counts against the monthly quota.
|
||||||
|
remaining = _int_header(resp.headers.get("X-RateLimit-Remaining"))
|
||||||
|
calls = await store.bump(today, remaining)
|
||||||
|
try:
|
||||||
|
data = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("VesselAPI 2xx with non-JSON body — counted but ignored")
|
||||||
|
return True
|
||||||
|
markers = transform_vesselapi_payload(data)
|
||||||
|
for m in markers:
|
||||||
|
await upsert_vessel(m)
|
||||||
|
logger.info(
|
||||||
|
"VesselAPI poll OK: %d vessels (remaining=%s, calls_today=%d)",
|
||||||
|
len(markers), remaining, calls,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Worker loop ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def run_vesselapi_worker(store: PgQuotaStore | None = None) -> None:
|
||||||
|
"""Long-lived poll loop. Idle when the key is unset; never crashes the app."""
|
||||||
|
if store is None:
|
||||||
|
store = PgQuotaStore()
|
||||||
|
boxes = parse_boxes_validated(VESSELAPI_BBOX)
|
||||||
|
if not boxes:
|
||||||
|
logger.warning(
|
||||||
|
"VESSELAPI_BBOX has no valid boxes (span ≤ %.1f°) — poller idle", MAX_SPAN_DEG,
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if not boxes:
|
||||||
|
await asyncio.sleep(VESSELAPI_INTERVAL)
|
||||||
|
continue
|
||||||
|
key = await _resolve_key()
|
||||||
|
if not key:
|
||||||
|
logger.warning(
|
||||||
|
"VESSELAPI_API_KEY not set — VesselAPI poller idle. "
|
||||||
|
"Create a free key at https://dashboard.vesselapi.com/"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(VESSELAPI_INTERVAL)
|
||||||
|
continue
|
||||||
|
await poll_once(store, boxes, key)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception: # noqa: BLE001 — keep the loop alive across transient failures
|
||||||
|
logger.exception("VesselAPI poll error")
|
||||||
|
await asyncio.sleep(VESSELAPI_INTERVAL)
|
||||||
|
|
@ -97,6 +97,11 @@ services:
|
||||||
AISSTREAM_API_KEY: ${AISSTREAM_API_KEY:-}
|
AISSTREAM_API_KEY: ${AISSTREAM_API_KEY:-}
|
||||||
AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66}
|
AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66}
|
||||||
AISSTREAM_IN_INGEST: ${AISSTREAM_IN_INGEST:-0}
|
AISSTREAM_IN_INGEST: ${AISSTREAM_IN_INGEST:-0}
|
||||||
|
VESSELAPI_API_KEY: ${VESSELAPI_API_KEY:-}
|
||||||
|
VESSELAPI_BBOX: ${VESSELAPI_BBOX:-25.5,55.4,27.3,57.2}
|
||||||
|
VESSELAPI_INTERVAL: ${VESSELAPI_INTERVAL:-17280}
|
||||||
|
VESSELAPI_MAX_CALLS_PER_DAY: ${VESSELAPI_MAX_CALLS_PER_DAY:-5}
|
||||||
|
VESSELAPI_IN_INGEST: ${VESSELAPI_IN_INGEST:-0}
|
||||||
command: ["python", "app/run_ingester.py"]
|
command: ["python", "app/run_ingester.py"]
|
||||||
entrypoint: ["python", "app/run_ingester.py"]
|
entrypoint: ["python", "app/run_ingester.py"]
|
||||||
|
|
||||||
|
|
@ -131,6 +136,11 @@ services:
|
||||||
AISSTREAM_API_KEY: ${AISSTREAM_API_KEY:-}
|
AISSTREAM_API_KEY: ${AISSTREAM_API_KEY:-}
|
||||||
AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66}
|
AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66}
|
||||||
AISSTREAM_IN_APP: ${AISSTREAM_IN_APP:-1}
|
AISSTREAM_IN_APP: ${AISSTREAM_IN_APP:-1}
|
||||||
|
VESSELAPI_API_KEY: ${VESSELAPI_API_KEY:-}
|
||||||
|
VESSELAPI_BBOX: ${VESSELAPI_BBOX:-25.5,55.4,27.3,57.2}
|
||||||
|
VESSELAPI_INTERVAL: ${VESSELAPI_INTERVAL:-17280}
|
||||||
|
VESSELAPI_MAX_CALLS_PER_DAY: ${VESSELAPI_MAX_CALLS_PER_DAY:-5}
|
||||||
|
VESSELAPI_IN_APP: ${VESSELAPI_IN_APP:-1}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8000:8000"
|
- "127.0.0.1:8000:8000"
|
||||||
deploy:
|
deploy:
|
||||||
|
|
|
||||||
351
tests/test_vesselapi.py
Normal file
351
tests/test_vesselapi.py
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
"""Unit tests for the VesselAPI poller (no network, no DB).
|
||||||
|
|
||||||
|
Covers box span validation, position → marker mapping, glitch skipping,
|
||||||
|
and the daily-quota gate (6th 2xx attempt skipped). ``poll_once`` is driven
|
||||||
|
with an in-memory fake quota store + fake HTTP client; ``upsert_vessel``'s
|
||||||
|
DB/WS side effects are monkeypatched to no-ops so markers can be asserted in
|
||||||
|
``vessel_last_known``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import vesselapi
|
||||||
|
from vesselapi import (
|
||||||
|
BboxError,
|
||||||
|
budget_allows,
|
||||||
|
choose_box,
|
||||||
|
days_left_in_month,
|
||||||
|
parse_boxes,
|
||||||
|
parse_boxes_validated,
|
||||||
|
poll_once,
|
||||||
|
transform_vesselapi_payload,
|
||||||
|
transform_vesselapi_position,
|
||||||
|
validate_bbox_span,
|
||||||
|
)
|
||||||
|
from live_layers import vessel_last_known
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fake quota store (in-memory, injected) ────────────────────────────────
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self, calls: int = 0, remaining: int | None = None):
|
||||||
|
self.calls = calls
|
||||||
|
self.remaining = remaining
|
||||||
|
self.bumps = 0
|
||||||
|
|
||||||
|
async def calls_today(self, day):
|
||||||
|
return self.calls
|
||||||
|
|
||||||
|
async def remaining_today(self, day):
|
||||||
|
return self.remaining
|
||||||
|
|
||||||
|
async def bump(self, day, remaining):
|
||||||
|
self.calls += 1
|
||||||
|
self.remaining = remaining
|
||||||
|
self.bumps += 1
|
||||||
|
return self.calls
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fake HTTP client ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class FakeResp:
|
||||||
|
def __init__(self, status: int = 200, headers: dict | None = None, data: dict | None = None):
|
||||||
|
self.status_code = status
|
||||||
|
self.headers = headers or {}
|
||||||
|
self._data = data or {}
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, *responses: FakeResp):
|
||||||
|
self.responses = list(responses)
|
||||||
|
self.calls: list[tuple] = []
|
||||||
|
|
||||||
|
async def get(self, url, params=None, headers=None):
|
||||||
|
self.calls.append((url, params, headers))
|
||||||
|
return self.responses.pop(0)
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_payload() -> dict:
|
||||||
|
return {
|
||||||
|
"vessels": [
|
||||||
|
{
|
||||||
|
"mmsi": 422050100,
|
||||||
|
"imo": 9321483,
|
||||||
|
"vessel_name": "HORMUZ STAR",
|
||||||
|
"latitude": 26.5,
|
||||||
|
"longitude": 56.3,
|
||||||
|
"cog": 88.0,
|
||||||
|
"sog": 12.4,
|
||||||
|
"heading": 90,
|
||||||
|
"nav_status": 0,
|
||||||
|
"timestamp": "2026-08-29T12:00:00Z",
|
||||||
|
"suspected_glitch": False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mmsi": 422050101,
|
||||||
|
"vessel_name": "GLITCHY",
|
||||||
|
"latitude": 26.6,
|
||||||
|
"longitude": 56.4,
|
||||||
|
"cog": 45.0,
|
||||||
|
"sog": 5.0,
|
||||||
|
"heading": 45,
|
||||||
|
"nav_status": 5,
|
||||||
|
"suspected_glitch": True,
|
||||||
|
},
|
||||||
|
{"mmsi": 422050102, "vessel_name": "NOFIX"}, # no coords → drop
|
||||||
|
],
|
||||||
|
"nextToken": "deadbeef",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Box span / parsing ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_validate_bbox_span_accepts_hormuz():
|
||||||
|
validate_bbox_span(25.5, 55.4, 27.3, 57.2) # span 3.6 — no raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_bbox_span_rejects_conus():
|
||||||
|
with pytest.raises(BboxError):
|
||||||
|
validate_bbox_span(24.0, -125.0, 50.0, -66.0) # span 85
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_bbox_span_rejects_marine_regions_gazetteer_box():
|
||||||
|
# 25.2732–27.3713 N, 55.1647–57.3419 E → span 4.28 > 4.0.
|
||||||
|
with pytest.raises(BboxError):
|
||||||
|
validate_bbox_span(25.2732, 55.1647, 27.3713, 57.3419)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_bbox_span_rejects_inverted_axes():
|
||||||
|
with pytest.raises(BboxError):
|
||||||
|
validate_bbox_span(27.0, 55.0, 25.0, 57.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_boxes_semicolon_and_skip_malformed():
|
||||||
|
boxes = parse_boxes("25.5,55.4,27.3,57.2; 10,20,11,21; garbage")
|
||||||
|
assert boxes == [(25.5, 55.4, 27.3, 57.2), (10.0, 20.0, 11.0, 21.0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_boxes_validated_skips_over_span():
|
||||||
|
# Second box is CONUS-sized → dropped, first kept.
|
||||||
|
valid = parse_boxes_validated("25.5,55.4,27.3,57.2;24,-125,50,-66")
|
||||||
|
assert valid == [(25.5, 55.4, 27.3, 57.2)]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Position → marker mapping ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_transform_position_maps_shared_marker_contract():
|
||||||
|
m = transform_vesselapi_position({
|
||||||
|
"mmsi": 422050100, "imo": 9321483, "vessel_name": "HORMUZ STAR",
|
||||||
|
"latitude": 26.5, "longitude": 56.3, "heading": 90, "cog": 88.0,
|
||||||
|
"sog": 12.4, "nav_status": 0, "timestamp": "2026-08-29T12:00:00Z",
|
||||||
|
"suspected_glitch": False,
|
||||||
|
})
|
||||||
|
assert m is not None
|
||||||
|
assert m["id"] == "422050100"
|
||||||
|
assert m["lat"] == 26.5
|
||||||
|
assert m["lon"] == 56.3
|
||||||
|
assert m["label"] == "HORMUZ STAR"
|
||||||
|
assert m["heading"] == 90
|
||||||
|
assert m["speed"] == 12.4
|
||||||
|
assert m["extra"]["src"] == "vesselapi"
|
||||||
|
assert m["extra"]["mmsi"] == "422050100"
|
||||||
|
assert m["extra"]["imo"] == 9321483
|
||||||
|
assert m["extra"]["navstat"] == 0
|
||||||
|
assert m["extra"]["cog"] == 88.0
|
||||||
|
assert m["extra"]["sog"] == 12.4
|
||||||
|
assert m["extra"]["timestamp"] == "2026-08-29T12:00:00Z"
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_position_heading_falls_back_to_cog():
|
||||||
|
m = transform_vesselapi_position({
|
||||||
|
"mmsi": 123456789, "vessel_name": "X", "latitude": 1.0, "longitude": 2.0,
|
||||||
|
"heading": None, "cog": 123.4, "sog": 5.0,
|
||||||
|
})
|
||||||
|
assert m["heading"] == 123.4
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_position_skips_glitch():
|
||||||
|
assert transform_vesselapi_position({
|
||||||
|
"mmsi": 123456789, "latitude": 1.0, "longitude": 2.0,
|
||||||
|
"suspected_glitch": True,
|
||||||
|
}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_position_skips_missing_coords():
|
||||||
|
assert transform_vesselapi_position({"mmsi": 123456789, "vessel_name": "NOFIX"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_payload_skips_glitch_and_nofix_rows():
|
||||||
|
rows = transform_vesselapi_payload(_sample_payload())
|
||||||
|
assert [r["id"] for r in rows] == ["422050100"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Quota budget / scheduling ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_days_left_in_month():
|
||||||
|
assert days_left_in_month(datetime(2026, 8, 29, tzinfo=timezone.utc)) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_allows_local_daily_cap():
|
||||||
|
# 5 calls already made → 6th is blocked regardless of remaining.
|
||||||
|
assert budget_allows(5, remaining=1000, days_left=3, max_per_day=5) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_allows_monthly_floor():
|
||||||
|
# remaining 14 ≤ 5*3=15 → skip; 16 > 15 → allow.
|
||||||
|
assert budget_allows(2, remaining=14, days_left=3, max_per_day=5) is False
|
||||||
|
assert budget_allows(2, remaining=16, days_left=3, max_per_day=5) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_budget_allows_unknown_remaining():
|
||||||
|
assert budget_allows(2, remaining=None, days_left=3, max_per_day=5) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_choose_box_prefers_primary_when_budget_tight():
|
||||||
|
boxes = [(1, 1, 2, 2), (3, 3, 4, 4), (5, 5, 6, 6)]
|
||||||
|
# 4 calls made, 1 left → always box 0.
|
||||||
|
assert choose_box(boxes, 4, max_per_day=5) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_choose_box_round_robins_when_budget_covers_all():
|
||||||
|
boxes = [(1, 1, 2, 2), (3, 3, 4, 4), (5, 5, 6, 6)]
|
||||||
|
# 0 calls made, 5 left ≥ 3 boxes → round-robin.
|
||||||
|
assert choose_box(boxes, 0, max_per_day=5) == 0
|
||||||
|
assert choose_box(boxes, 1, max_per_day=5) == 1
|
||||||
|
assert choose_box(boxes, 2, max_per_day=5) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── poll_once integration (fake store + fake client) ─────────────────────
|
||||||
|
|
||||||
|
def _patch_side_effects(monkeypatch):
|
||||||
|
async def _noop(*a, **k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("tracks.record_position", _noop)
|
||||||
|
monkeypatch.setattr("geofence.record_and_notify", _noop)
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_once_lands_markers_in_vessel_last_known(monkeypatch):
|
||||||
|
_patch_side_effects(monkeypatch)
|
||||||
|
vessel_last_known.clear()
|
||||||
|
client = FakeClient(
|
||||||
|
FakeResp(200, {"X-RateLimit-Remaining": "140"}, _sample_payload()),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(vesselapi, "_get_client", _make_get_client(client))
|
||||||
|
store = FakeStore()
|
||||||
|
|
||||||
|
ok = asyncio.run(poll_once(store, [(25.5, 55.4, 27.3, 57.2)], "test-key"))
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert store.calls == 1
|
||||||
|
assert "422050100" in vessel_last_known
|
||||||
|
assert vessel_last_known["422050100"]["extra"]["src"] == "vesselapi"
|
||||||
|
assert "422050101" not in vessel_last_known # glitch skipped
|
||||||
|
# One HTTP call, bounding-box params, no sat / no nextToken follow.
|
||||||
|
assert len(client.calls) == 1
|
||||||
|
_url, params, headers = client.calls[0]
|
||||||
|
assert params["filter.latBottom"] == "25.5"
|
||||||
|
assert params["filter.latTop"] == "27.3"
|
||||||
|
assert params["filter.lonLeft"] == "55.4"
|
||||||
|
assert params["filter.lonRight"] == "57.2"
|
||||||
|
assert params["pagination.limit"] == "50"
|
||||||
|
assert "sat" not in params
|
||||||
|
assert headers["Authorization"] == "Bearer test-key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_once_sixth_2xx_is_skipped_with_zero_http(monkeypatch):
|
||||||
|
client = FakeClient()
|
||||||
|
monkeypatch.setattr(vesselapi, "_get_client", _make_get_client(client))
|
||||||
|
# 5 successful calls already today → 6th poll makes no HTTP request.
|
||||||
|
store = FakeStore(calls=5, remaining=1000)
|
||||||
|
|
||||||
|
ok = asyncio.run(poll_once(store, [(25.5, 55.4, 27.3, 57.2)], "test-key"))
|
||||||
|
|
||||||
|
assert ok is False
|
||||||
|
assert store.bumps == 0
|
||||||
|
assert client.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_once_4xx_not_counted_and_no_upsert(monkeypatch):
|
||||||
|
_patch_side_effects(monkeypatch)
|
||||||
|
vessel_last_known.clear()
|
||||||
|
client = FakeClient(FakeResp(400, {}, {"error": {}}))
|
||||||
|
monkeypatch.setattr(vesselapi, "_get_client", _make_get_client(client))
|
||||||
|
store = FakeStore()
|
||||||
|
|
||||||
|
ok = asyncio.run(poll_once(store, [(25.5, 55.4, 27.3, 57.2)], "test-key"))
|
||||||
|
|
||||||
|
assert ok is False
|
||||||
|
assert store.bumps == 0 # 4xx does not count against quota
|
||||||
|
assert vessel_last_known == {}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_get_client(client):
|
||||||
|
async def _get_client():
|
||||||
|
return 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"]
|
||||||
Loading…
Add table
Reference in a new issue