"""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