Retry Logic That Doesn't Make Things Worse

The naive retry loop turns a brief rate limit into a sustained one. Getting backoff right takes about forty lines.

Profile picture of Serply
Serply
Retry attempts spaced by exponential backoff with jitter

Everyone writes the same first retry loop: catch the exception, sleep one second, try again, three times. It works in testing and it’s actively harmful in production, because the failures that matter aren’t independent. When you’re rate limited, so is every other worker, and a fixed-delay retry synchronises them all into a thundering herd against a service that just told you to slow down.

Classify before you retry

Not every error deserves a retry. Some are permanent, and retrying them wastes budget and delays the real answer:

from dataclasses import dataclass


@dataclass
class Decision:
    retry: bool
    reason: str
    honor_retry_after: bool = False


def classify(status: int | None, exc: Exception | None) -> Decision:
    if exc is not None:
        name = type(exc).__name__
        if "Timeout" in name or "Connection" in name:
            return Decision(True, "network")
        return Decision(False, f"unexpected: {name}")

    if status == 429:
        return Decision(True, "rate_limited", honor_retry_after=True)
    if status in (500, 502, 503, 504):
        return Decision(True, "server_error")
    if status == 404:
        return Decision(False, "not_found")
    if status == 422:
        return Decision(False, "bad_request")
    if status and 400 <= status < 500:
        return Decision(False, f"client_error_{status}")
    return Decision(False, "ok")

422 is the one people retry by mistake. It means the request itself is malformed — with this API, usually an unencoded query or a ?q= querystring where the query belongs in the path. Retrying it identically will fail identically, forever.

404 is similarly terminal for a given URL. Retry it and you’ve spent three calls confirming the same thing.

Exponential backoff with full jitter

import random
import time


def backoff_delay(attempt: int, base: float = 0.5, cap: float = 30.0) -> float:
    """Full jitter: uniform in [0, min(cap, base * 2**attempt)]."""
    ceiling = min(cap, base * (2 ** attempt))
    return random.uniform(0, ceiling)

Full jitter — a uniform draw from zero to the ceiling, not the ceiling itself — is the version that actually decorrelates concurrent clients. “Exponential backoff” with a deterministic delay keeps every worker in lockstep; they all wait 4 seconds and all retry at the same instant.

Randomising across the whole range means retries spread out, and the service sees a smooth ramp instead of repeated spikes.

The wrapper

import requests
from urllib.parse import quote_plus

API_KEY = os.environ["SERPLY_API_KEY"]


class RetryExhausted(Exception):
    pass


def request_with_retry(
    method: str, url: str, *, max_attempts: int = 4,
    timeout: float = 30.0, **kwargs
) -> requests.Response:
    last = None

    for attempt in range(max_attempts):
        try:
            resp = requests.request(method, url, timeout=timeout, **kwargs)
            decision = classify(resp.status_code, None)

            if not decision.retry:
                return resp

            last = f"HTTP {resp.status_code} ({decision.reason})"

            delay = backoff_delay(attempt)
            if decision.honor_retry_after:
                retry_after = resp.headers.get("Retry-After")
                if retry_after:
                    try:
                        delay = max(delay, float(retry_after))
                    except ValueError:
                        pass

        except requests.RequestException as e:
            decision = classify(None, e)
            if not decision.retry:
                raise
            last = f"{type(e).__name__}: {e}"
            delay = backoff_delay(attempt)

        if attempt == max_attempts - 1:
            break

        time.sleep(delay)

    raise RetryExhausted(f"{max_attempts} attempts failed. Last: {last}")

Note it returns the response for non-retryable statuses rather than raising. A 404 is information the caller wants, not an exception — the agent should be told the page doesn’t exist so it tries another source.

Honouring Retry-After when present, but never going below the jittered delay, means you respect an explicit instruction without collapsing back to synchronised retries when the header is absent.

Watch the budget headers instead

Retrying a 429 is recovery. Not hitting it is better, and the API tells you where you stand:

class RateTracker:
    def __init__(self):
        self.limit: int | None = None
        self.remaining: int | None = None

    def update(self, resp: requests.Response) -> None:
        try:
            self.limit = int(resp.headers["x-ratelimit-requests-limit"])
            self.remaining = int(resp.headers["x-ratelimit-requests-remaining"])
        except (KeyError, ValueError):
            pass

    @property
    def pressure(self) -> float:
        if not self.limit:
            return 0.0
        return 1.0 - (self.remaining or 0) / self.limit

    def throttle_delay(self) -> float:
        """Slow down as the budget depletes, before hitting the wall."""
        p = self.pressure
        if p < 0.8:
            return 0.0
        if p < 0.95:
            return 0.25
        return 1.0

Every response carries x-ratelimit-requests-limit and x-ratelimit-requests-remaining. Reading them and easing off at 80% consumption is far better than sprinting into a 429 and backing off afterwards — you never lose a request, and the slowdown is gradual instead of a cliff.

Circuit breaking for sustained failure

If the service is genuinely down, retrying every call individually turns a fast failure into a slow one across your whole fleet:

class CircuitBreaker:
    def __init__(self, threshold: int = 5, cooldown: float = 60.0):
        self.threshold = threshold
        self.cooldown = cooldown
        self.failures = 0
        self.opened_at: float | None = None

    def allow(self) -> bool:
        if self.opened_at is None:
            return True
        if time.monotonic() - self.opened_at >= self.cooldown:
            self.opened_at = None      # half-open: let one through
            self.failures = self.threshold - 1
            return True
        return False

    def record(self, ok: bool) -> None:
        if ok:
            self.failures = 0
            self.opened_at = None
        else:
            self.failures += 1
            if self.failures >= self.threshold:
                self.opened_at = time.monotonic()

Half-open recovery matters: after the cooldown, let exactly one request through rather than reopening the floodgates. If it succeeds, the counter resets; if it fails, you’re closed again with one wasted call instead of hundreds.

Putting it in the tool

tracker = RateTracker()
breaker = CircuitBreaker()


def search(query: str, num: int = 10) -> str:
    if not breaker.allow():
        return ("The search service is currently unavailable. Answer from what "
                "you already have, and state that you could not verify further.")

    delay = tracker.throttle_delay()
    if delay:
        time.sleep(delay)

    try:
        resp = request_with_retry(
            "GET",
            f"https://api.serply.io/v1/search/q={quote_plus(query)}&num={num}",
            headers={"X-Api-Key": API_KEY, "X-Proxy-Location": "US"},
        )
    except RetryExhausted as e:
        breaker.record(False)
        return f"Search unavailable after retries ({e}). Proceed without it."

    tracker.update(resp)
    breaker.record(resp.ok)

    if resp.status_code == 404:
        return f'No results for "{query}". Try different keywords.'
    if not resp.ok:
        return f"Search failed with HTTP {resp.status_code}."

    results = resp.json().get("results", [])
    if not results:
        return f'No results for "{query}". Try broader keywords.'

    return "\n\n".join(
        f"{r.get('title')}\n{r.get('link')}\n{r.get('description', '')}"
        for r in results
    )

Every failure path returns a string the model can act on. That’s the design principle worth carrying across all of this: retries and circuit breakers are for the infrastructure, but when they’re exhausted the agent still needs to know what to do, and “proceed without it and say what you couldn’t verify” is almost always the right instruction.

What to instrument

Log the attempt count on every call, not just the final outcome. A rising average retry count is the earliest signal of a degrading dependency, and it shows up well before your error rate does — because the retries are still succeeding.