Files
bls-data/tests/test_retry.py
Dave Boyd 0d0d6dabe2 Add transient-network retry with exponential backoff
- 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>
2026-06-22 10:49:18 -04:00

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)