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>
This commit is contained in:
@ -12,6 +12,7 @@ from datetime import datetime
|
||||
|
||||
from .cache import FileCache
|
||||
from .errors import BLSError, BLSRequestError, BLSQuotaError
|
||||
from .retry import with_retries
|
||||
|
||||
|
||||
class BLSClient:
|
||||
@ -19,7 +20,8 @@ class BLSClient:
|
||||
MAX_SERIES_PER_CALL = 50
|
||||
MAX_YEARS_PER_CALL = 20
|
||||
|
||||
def __init__(self, api_key: str, cache=False, cache_dir=None, cache_ttl=86400):
|
||||
def __init__(self, api_key: str, cache=False, cache_dir=None, cache_ttl=86400,
|
||||
retries=2, backoff=0.5):
|
||||
"""
|
||||
Args:
|
||||
api_key: BLS registration key.
|
||||
@ -27,6 +29,9 @@ class BLSClient:
|
||||
or pass a custom cache object with get(key)/set(key, value).
|
||||
cache_dir: Override the default cache directory.
|
||||
cache_ttl: Cache entry lifetime in seconds (default 1 day).
|
||||
retries: retries after the first attempt on transient network errors
|
||||
(timeouts, connection drops, 429/5xx). 0 disables retrying.
|
||||
backoff: base exponential-backoff interval in seconds.
|
||||
"""
|
||||
self.api_key = api_key
|
||||
if cache is True:
|
||||
@ -35,8 +40,15 @@ class BLSClient:
|
||||
self.cache = cache # caller-supplied cache object
|
||||
else:
|
||||
self.cache = None
|
||||
self.retries = retries
|
||||
self.backoff = backoff
|
||||
self.queries_used = 0 # network POSTs spent this session (cache hits don't count)
|
||||
|
||||
def _get(self, url, **kwargs):
|
||||
"""GET with retry/backoff."""
|
||||
return with_retries(lambda: requests.get(url, **kwargs),
|
||||
retries=self.retries, backoff=self.backoff)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core fetch
|
||||
# ------------------------------------------------------------------
|
||||
@ -97,7 +109,9 @@ class BLSClient:
|
||||
|
||||
def _fetch_batch(self, payload: dict) -> dict:
|
||||
"""Execute one (uncached) API call and return {series_id: {data, catalog}}."""
|
||||
r = requests.post(f"{self.BASE_URL}/timeseries/data/", json=payload, timeout=30)
|
||||
url = f"{self.BASE_URL}/timeseries/data/"
|
||||
r = with_retries(lambda: requests.post(url, json=payload, timeout=30),
|
||||
retries=self.retries, backoff=self.backoff)
|
||||
r.raise_for_status()
|
||||
self.queries_used += 1
|
||||
body = r.json()
|
||||
@ -155,13 +169,13 @@ class BLSClient:
|
||||
# ------------------------------------------------------------------
|
||||
def surveys(self) -> list[dict]:
|
||||
"""Return all BLS surveys with abbreviation and name."""
|
||||
r = requests.get(f"{self.BASE_URL}/surveys", timeout=15)
|
||||
r = self._get(f"{self.BASE_URL}/surveys", timeout=15)
|
||||
r.raise_for_status()
|
||||
return r.json()["Results"]["survey"]
|
||||
|
||||
def popular(self, survey: str) -> list[str]:
|
||||
"""Return popular series IDs for a given survey abbreviation."""
|
||||
r = requests.get(
|
||||
r = self._get(
|
||||
f"{self.BASE_URL}/timeseries/popular",
|
||||
params={"survey": survey},
|
||||
timeout=15,
|
||||
|
||||
@ -21,6 +21,8 @@ import io
|
||||
|
||||
import requests
|
||||
|
||||
from .retry import with_retries
|
||||
|
||||
QCEW_BASE = "https://data.bls.gov/cew/data/api"
|
||||
_HEADERS = {"User-Agent": "bls-data-library (https://gitea.doesworks.net/giteaadmin/bls-data)"}
|
||||
|
||||
@ -35,8 +37,9 @@ OWNERSHIP = {
|
||||
}
|
||||
|
||||
|
||||
def _fetch_csv(url: str) -> list[dict]:
|
||||
r = requests.get(url, timeout=60, headers=_HEADERS)
|
||||
def _fetch_csv(url: str, retries=2, backoff=0.5) -> list[dict]:
|
||||
r = with_retries(lambda: requests.get(url, timeout=60, headers=_HEADERS),
|
||||
retries=retries, backoff=backoff)
|
||||
r.raise_for_status()
|
||||
return parse_csv(r.text)
|
||||
|
||||
|
||||
58
bls_client/retry.py
Normal file
58
bls_client/retry.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""Retry transient network failures with exponential backoff.
|
||||
|
||||
Shared by the JSON client (POST) and the QCEW CSV client (GET). Retries only
|
||||
genuinely transient conditions — connection drops, timeouts, and 429/5xx — never
|
||||
4xx client errors or a BLS quota rejection (those won't fix themselves).
|
||||
"""
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
# HTTP statuses worth retrying (429 = throttled, 5xx = server-side transient).
|
||||
RETRY_STATUS = {429, 500, 502, 503, 504}
|
||||
|
||||
# Network-level exceptions worth retrying.
|
||||
RETRY_EXCEPTIONS = (
|
||||
requests.exceptions.ConnectionError,
|
||||
requests.exceptions.Timeout,
|
||||
requests.exceptions.ChunkedEncodingError,
|
||||
)
|
||||
|
||||
|
||||
def _retry_after(resp) -> float | None:
|
||||
"""Honor a numeric Retry-After header if the server sent one."""
|
||||
ra = getattr(resp, "headers", {}).get("Retry-After") if resp is not None else None
|
||||
if ra and str(ra).isdigit():
|
||||
return float(ra)
|
||||
return None
|
||||
|
||||
|
||||
def with_retries(send, *, retries=2, backoff=0.5, backoff_max=8.0, sleep=time.sleep):
|
||||
"""
|
||||
Call ``send()`` (which returns a requests.Response), retrying transient
|
||||
failures. Backoff is ``backoff * 2**attempt`` seconds, capped at ``backoff_max``.
|
||||
|
||||
Args:
|
||||
send: zero-arg callable issuing the request and returning a Response.
|
||||
retries: max retries after the first attempt (so retries=2 -> 3 tries).
|
||||
backoff: base backoff in seconds.
|
||||
backoff_max: cap on a single backoff interval.
|
||||
sleep: sleep function (injectable for tests).
|
||||
"""
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
resp = send()
|
||||
except RETRY_EXCEPTIONS:
|
||||
if attempt >= retries:
|
||||
raise
|
||||
sleep(min(backoff * (2 ** attempt), backoff_max))
|
||||
attempt += 1
|
||||
continue
|
||||
|
||||
if getattr(resp, "status_code", None) in RETRY_STATUS and attempt < retries:
|
||||
sleep(_retry_after(resp) or min(backoff * (2 ** attempt), backoff_max))
|
||||
attempt += 1
|
||||
continue
|
||||
|
||||
return resp
|
||||
Reference in New Issue
Block a user