- bls_client/retry.py: with_retries() retries timeouts/connection drops and 429/5xx with exponential backoff; honors Retry-After; never retries 4xx or BLSQuotaError (those won't fix themselves) - BLSClient(retries=2, backoff=0.5) wraps the data POST and discovery GETs; qcew CSV fetch wrapped too. retries=0 disables. - tests/test_retry.py: 6 offline tests (injectable sleep, no real delays) - README: retry behavior documented; drop the now-resolved limitation Offline suite 125 passing; live smoke green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
102 lines
3.2 KiB
Python
102 lines
3.2 KiB
Python
"""Offline tests for the retry/backoff layer. No network, no real sleeping."""
|
|
import pytest
|
|
import requests
|
|
|
|
import bls_client.client as client_mod
|
|
from bls_client import BLSClient
|
|
from bls_client.retry import with_retries
|
|
|
|
|
|
class Resp:
|
|
def __init__(self, status_code=200, headers=None):
|
|
self.status_code = status_code
|
|
self.headers = headers or {}
|
|
|
|
def raise_for_status(self):
|
|
pass
|
|
|
|
def json(self):
|
|
return {
|
|
"status": "REQUEST_SUCCEEDED",
|
|
"Results": {"series": [{"seriesID": "X", "data": [{"year": "2024", "period": "M01", "value": "1"}]}]},
|
|
}
|
|
|
|
|
|
def _recorder():
|
|
delays = []
|
|
return delays, (lambda d: delays.append(d))
|
|
|
|
|
|
def test_retries_transient_exception_then_succeeds():
|
|
calls = {"n": 0}
|
|
delays, sleep = _recorder()
|
|
|
|
def send():
|
|
calls["n"] += 1
|
|
if calls["n"] < 3:
|
|
raise requests.exceptions.ConnectionError("boom")
|
|
return Resp(200)
|
|
|
|
resp = with_retries(send, retries=3, backoff=0.5, sleep=sleep)
|
|
assert resp.status_code == 200
|
|
assert calls["n"] == 3
|
|
assert delays == [0.5, 1.0] # exponential backoff between the 3 attempts
|
|
|
|
|
|
def test_gives_up_after_max_retries():
|
|
delays, sleep = _recorder()
|
|
|
|
def send():
|
|
raise requests.exceptions.Timeout("slow")
|
|
|
|
with pytest.raises(requests.exceptions.Timeout):
|
|
with_retries(send, retries=2, backoff=0.5, sleep=sleep)
|
|
assert len(delays) == 2 # retried twice, then re-raised
|
|
|
|
|
|
def test_retries_5xx_but_not_4xx():
|
|
delays, sleep = _recorder()
|
|
seq = [Resp(503), Resp(200)]
|
|
resp = with_retries(lambda: seq.pop(0), retries=3, backoff=0.5, sleep=sleep)
|
|
assert resp.status_code == 200 and len(delays) == 1
|
|
|
|
delays2, sleep2 = _recorder()
|
|
resp2 = with_retries(lambda: Resp(404), retries=3, backoff=0.5, sleep=sleep2)
|
|
assert resp2.status_code == 404 and delays2 == [] # client error: not retried
|
|
|
|
|
|
def test_honors_retry_after_header():
|
|
delays, sleep = _recorder()
|
|
seq = [Resp(429, headers={"Retry-After": "7"}), Resp(200)]
|
|
with_retries(lambda: seq.pop(0), retries=2, backoff=0.5, sleep=sleep)
|
|
assert delays == [7.0]
|
|
|
|
|
|
def test_client_retries_then_succeeds(monkeypatch):
|
|
# no real sleeping
|
|
monkeypatch.setattr("bls_client.retry.time.sleep", lambda d: None)
|
|
state = {"n": 0}
|
|
|
|
def fake_post(url, json=None, timeout=None):
|
|
state["n"] += 1
|
|
if state["n"] == 1:
|
|
raise requests.exceptions.ConnectionError("transient")
|
|
return Resp(200)
|
|
|
|
monkeypatch.setattr(client_mod.requests, "post", fake_post)
|
|
c = BLSClient("key", retries=2)
|
|
out = c.fetch(["CES0000000001"], 2024, 2024)
|
|
assert out and state["n"] == 2 # failed once, retried, succeeded
|
|
assert c.queries_used == 1 # counter only counts the successful POST
|
|
|
|
|
|
def test_retries_zero_disables(monkeypatch):
|
|
monkeypatch.setattr("bls_client.retry.time.sleep", lambda d: None)
|
|
|
|
def fake_post(url, json=None, timeout=None):
|
|
raise requests.exceptions.ConnectionError("transient")
|
|
|
|
monkeypatch.setattr(client_mod.requests, "post", fake_post)
|
|
with pytest.raises(requests.exceptions.ConnectionError):
|
|
BLSClient("key", retries=0).fetch(["CES0000000001"], 2024, 2024)
|