|
1 | 1 | """Load-shed and rate-limit keying for the API.""" |
2 | 2 |
|
3 | 3 | import queue |
| 4 | +import time |
4 | 5 | from types import SimpleNamespace |
5 | 6 |
|
| 7 | +import duckdb |
6 | 8 | import pytest |
7 | | -from litestar.exceptions import TooManyRequestsException |
| 9 | +from litestar.exceptions import HTTPException, TooManyRequestsException |
8 | 10 |
|
9 | 11 | from api import duck |
10 | 12 | from api.app import _client_identifier, app, rate_limit_config |
11 | 13 |
|
12 | 14 |
|
| 15 | +class _FakeCon: |
| 16 | + def interrupt(self): |
| 17 | + pass |
| 18 | + |
| 19 | + def close(self): |
| 20 | + pass |
| 21 | + |
| 22 | + |
| 23 | +def _stub_pool(monkeypatch): |
| 24 | + """Point _run at fake connections so it never touches DuckDB/Postgres.""" |
| 25 | + monkeypatch.setattr(duck, "_sources", lambda: None) |
| 26 | + monkeypatch.setattr(duck, "_connect", lambda: _FakeCon()) |
| 27 | + pool: queue.Queue = queue.Queue() |
| 28 | + pool.put(_FakeCon()) |
| 29 | + monkeypatch.setattr(duck, "_pool_ready", lambda: pool) |
| 30 | + |
| 31 | + |
13 | 32 | def test_acquire_returns_free_connection(): |
14 | 33 | pool: queue.Queue = queue.Queue() |
15 | 34 | sentinel = object() |
@@ -48,3 +67,62 @@ def test_rate_limit_middleware_is_wired(): |
48 | 67 | assert [m.middleware for m in app.middleware] == [RateLimitMiddleware] |
49 | 68 | assert rate_limit_config.rate_limit == ("minute", 120) |
50 | 69 | assert rate_limit_config.identifier_for_request is _client_identifier |
| 70 | + |
| 71 | + |
| 72 | +def _fake_query(name): |
| 73 | + def fn(*args, **kwargs): |
| 74 | + return None |
| 75 | + |
| 76 | + fn.__name__ = name |
| 77 | + return fn |
| 78 | + |
| 79 | + |
| 80 | +def test_enqueue_warm_single_flight_dedups(monkeypatch): |
| 81 | + monkeypatch.setattr(duck, "_QUERY_CACHE_DIR", "/tmp/qc") |
| 82 | + submitted: list = [] |
| 83 | + monkeypatch.setattr(duck._warm_pool, "submit", lambda *a: submitted.append(a)) |
| 84 | + duck._warm_inflight.clear() |
| 85 | + summary = _fake_query("summary") |
| 86 | + allt = {"start": None, "end": None} |
| 87 | + duck._enqueue_warm(summary, "hotosm", allt) |
| 88 | + duck._enqueue_warm(summary, "hotosm", allt) # same key -> dropped |
| 89 | + duck._enqueue_warm(summary, "osmnepal", allt) # different key -> submitted |
| 90 | + assert len(submitted) == 2 |
| 91 | + assert len(duck._warm_inflight) == 2 |
| 92 | + |
| 93 | + |
| 94 | +def test_enqueue_warm_noop_when_cache_disabled(monkeypatch): |
| 95 | + monkeypatch.setattr(duck, "_QUERY_CACHE_DIR", None) |
| 96 | + submitted: list = [] |
| 97 | + monkeypatch.setattr(duck._warm_pool, "submit", lambda *a: submitted.append(a)) |
| 98 | + duck._warm_inflight.clear() |
| 99 | + duck._enqueue_warm(_fake_query("summary"), "hotosm", {"start": None, "end": None}) |
| 100 | + assert submitted == [] |
| 101 | + assert len(duck._warm_inflight) == 0 |
| 102 | + |
| 103 | + |
| 104 | +def test_run_maps_interrupt_to_503(monkeypatch): |
| 105 | + monkeypatch.setattr(duck, "_QUERY_TIMEOUT", 0.0) # watchdog fires immediately -> interrupted |
| 106 | + monkeypatch.setattr(duck, "_QUERY_CACHE_DIR", None) # skip the warm side effect |
| 107 | + _stub_pool(monkeypatch) |
| 108 | + |
| 109 | + def boom(con, hashtag, sources, **kwargs): |
| 110 | + time.sleep(0.05) # let the zero-timeout watchdog set `interrupted` before we raise |
| 111 | + raise duckdb.Error("interrupted") |
| 112 | + |
| 113 | + boom.__name__ = "summary" |
| 114 | + with pytest.raises(HTTPException) as ei: |
| 115 | + duck._run(boom, "hotosm", start=None, end=None) |
| 116 | + assert ei.value.status_code == 503 |
| 117 | + |
| 118 | + |
| 119 | +def test_run_reraises_real_db_error_not_as_busy(monkeypatch): |
| 120 | + monkeypatch.setattr(duck, "_QUERY_TIMEOUT", 100.0) # watchdog never fires |
| 121 | + _stub_pool(monkeypatch) |
| 122 | + |
| 123 | + def boom(con, hashtag, sources, **kwargs): |
| 124 | + raise duckdb.Error("real failure") |
| 125 | + |
| 126 | + boom.__name__ = "summary" |
| 127 | + with pytest.raises(duckdb.Error): # a genuine error is a 500, not masked as 503 busy |
| 128 | + duck._run(boom, "hotosm", start=None, end=None) |
0 commit comments