SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
5.6 KB · 126 lines python
Raw Blame History
1"""Pipeline reliability: HTTP retries/backoff honouring Retry-After, country mapping, never publishing a shrunken snapshot,2source-health meta and build logs."""3from __future__ import annotations45import json67import duckdb8import httpx9import pytest10import respx1112from countryatlas.config import settings13from countryatlas.connectors import base as base_mod14from countryatlas.connectors.base import Connector, retry_after_seconds15from countryatlas.pipeline import build as build_mod16from countryatlas.registry import lookup17from tests.test_build import _tiny_staging181920class _Dummy(Connector):21    id = "worldbank"22    name = "dummy"23    rate_per_minute = 100_0002425    def fetch(self, spec):  # pragma: no cover - not used26        raise NotImplementedError2728    def normalize(self, raw, spec):  # pragma: no cover - not used29        return []303132def test_retry_after_parsing() -> None:33    assert retry_after_seconds(None) is None and retry_after_seconds("abc") is None34    assert retry_after_seconds("7") == 7.035    assert retry_after_seconds("999") == base_mod.RETRY_AFTER_CAP36    assert retry_after_seconds("Wed, 21 Oct 2015 07:28:00 GMT") == 0.0  # in the past → no wait373839@respx.mock40def test_get_retries_on_transient_statuses_then_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:41    sleeps: list[float] = []42    monkeypatch.setattr(base_mod.time, "sleep", lambda s: sleeps.append(s))  # Retry-After sleep43    # tenacity's wait: make it instant but record the computed deterministic delays44    waits: list[float] = []45    orig_wait = _Dummy.get.retry.wait4647    def fake_wait(retry_state):48        w = orig_wait(retry_state)49        waits.append(w)50        return 05152    monkeypatch.setattr(_Dummy.get.retry, "wait", fake_wait)53    route = respx.get("https://example.test/x").mock(side_effect=[54        httpx.Response(429, headers={"Retry-After": "3"}),55        httpx.Response(503),56        httpx.Response(200, json={"ok": True}),57    ])58    c = _Dummy()59    r = c.get("https://example.test/x")60    assert r.status_code == 200 and route.call_count == 361    assert [s for s in sleeps if s] == [3.0]  # Retry-After honoured once (zero-length tenacity naps filtered)62    assert waits == [2.0, 4.0]  # deterministic exponential backoff, no jitter63    c.close()646566@respx.mock67def test_get_gives_up_after_five_attempts(monkeypatch: pytest.MonkeyPatch) -> None:68    monkeypatch.setattr(_Dummy.get.retry, "wait", lambda rs: 0)69    route = respx.get("https://example.test/down").mock(return_value=httpx.Response(502))70    c = _Dummy()71    with pytest.raises(base_mod.TransientHTTPError):72        c.get("https://example.test/down")73    assert route.call_count == base_mod.RETRY_ATTEMPTS74    c.close()75    # a 404 is not retried76    route2 = respx.get("https://example.test/missing").mock(return_value=httpx.Response(404))77    c2 = _Dummy()78    with pytest.raises(httpx.HTTPStatusError):79        c2.get("https://example.test/missing")80    assert route2.call_count == 181    c2.close()828384def test_country_mapping_drops_aggregates_and_unknowns() -> None:85    lk = lookup()86    assert lk.any("CAN") == "CAN" and lk.any("ca") == "CAN" and lk.any("124") == "CAN"87    assert lk.any("Viet Nam") == "VNM" and lk.any("Türkiye") == "TUR" and lk.any("Kosovo") == "XKX"88    for agg in ("WLD", "OED", "EUU", "HIC", "EU27_2020", "OAVG"):89        assert lk.from_iso3(agg) is None90    assert lk.any("Atlantis") is None and lk.any("ZZZ") is None and lk.any("999") is None91    assert lk.from_iso2("EL") == "GRC" and lk.from_iso2("UK") == "GBR" and lk.from_iso2("XK") == "XKX"929394@pytest.mark.usefixtures("data_dir")95def test_shrunken_snapshot_is_never_published_and_meta_has_health() -> None:96    _tiny_staging()97    first = build_mod.build(run_id="20260101T000000Z", strict=False)98    con = duckdb.connect(str(settings.db_path), read_only=True)99    meta = dict(con.execute("SELECT key, value FROM meta").fetchall())100    con.close()101    assert meta["previous_observation_count"] == ""  # no previous snapshot102    health = json.loads(meta["source_health"])103    assert health["worldbank"]["ok"] >= 5 and set(health["worldbank"]) == {"ok", "partial", "failed", "quarantined"}104    log_path = settings.logs_dir / f"build-{first.run_id}.json"105    assert log_path.exists()106    doc = json.loads(log_path.read_text())107    assert doc["published"] is True and doc["counts"]["observations"] > 0 and any(f.startswith("worldbank/") for f in doc["staging_files"])108    before = settings.db_path.stat()109    # second build sees the previous count110    second = build_mod.build(run_id="20260102T000000Z", strict=False)111    con = duckdb.connect(str(settings.db_path), read_only=True)112    meta2 = dict(con.execute("SELECT key, value FROM meta").fetchall())113    con.close()114    assert int(meta2["previous_observation_count"]) == first.counts["observations"] and second.counts["observations"] == first.counts["observations"]115    after = settings.db_path.stat()116    # drop most staging files → < 90 % retention → strict integrity failure, live DB untouched, build log says not published117    for f in sorted(settings.staging_dir.glob("*/*.parquet"))[:-1]:118        f.unlink()119    with pytest.raises(build_mod.IntegrityError, match="refusing to publish"):120        build_mod.build(run_id="20260103T000000Z", strict=True)121    assert settings.db_path.stat().st_ino == after.st_ino and settings.db_path.stat().st_mtime_ns == after.st_mtime_ns122    assert not list(settings.build_dir.glob("atlas-20260103*"))123    failed_log = json.loads((settings.logs_dir / "build-20260103T000000Z.json").read_text())124    assert failed_log["published"] is False and "refusing to publish" in (failed_log["error"] or "")125    assert before.st_ino != after.st_ino or before.st_mtime_ns != after.st_mtime_ns  # the second build did publish126