# three_strikes.py: retry like an adult. Standard library only.
import random
import time


class ThreeStrikes(Exception):
    def __init__(self, category, attempts, last):
        super().__init__(f"Three strikes ({category}). Stop and ask a human.")
        self.category, self.attempts, self.last = category, attempts, last


def _status(e):
    r = getattr(e, "response", None)
    return getattr(r, "status_code", None) or getattr(e, "status", None)


RETRYABLE = {408, 421, 423, 425, 429, 500, 502, 503, 504, 507, 520, 522, 524}
MAX_WAIT = 120.0  # a server asking for more than two minutes is asking you to stop, not to sleep


def _category(e):
    s = _status(e)
    if s == 429:
        return "rate_limit"
    if s in (401, 403):
        return "auth"
    if s and s >= 400:
        return "server" if s in RETRYABLE else "client"
    if "timeout" in (type(e).__name__ + str(e)).lower() or "timed out" in str(e).lower():
        return "timeout"
    return "network"


def _retry_after(e):
    r = getattr(e, "response", None) or e
    try:
        v = r.headers.get("Retry-After")
        try:
            n = float(v)
        except ValueError:
            from email.utils import parsedate_to_datetime
            n = parsedate_to_datetime(v).timestamp() - time.time()
        return n if 0 < n < float("inf") else 0.0
    except Exception:
        return 0.0


def with_three_strikes(fn, strikes=3, base=1.0, cap=30.0):
    cat, last = "network", None
    for i in range(1, strikes + 1):
        try:
            return fn(i)
        except Exception as e:  # noqa: BLE001
            last, cat = e, _category(e)
            if cat in ("auth", "client"):
                raise ThreeStrikes(cat, i, e) from e  # the same request will fail the same way
            if i == strikes:
                break
            asked = _retry_after(e)
            if asked > MAX_WAIT:
                raise ThreeStrikes("rate_limit", i, e) from e
            backoff = min(cap, base * 2 ** (i - 1)) * (0.5 + random.random() / 2)
            time.sleep(max(asked, backoff))
    raise ThreeStrikes(cat, strikes, last) from last


# Usage:
#   def call(n):
#       r = requests.get(url, timeout=10)
#       r.raise_for_status()
#       return r.json()
#   data = with_three_strikes(call)
