Respecting Retry-After Headers from Geocoding APIs

When a geocoding API answers 429 Too Many Requests with a Retry-After header, that header is the only accurate information anyone has about when the quota window reopens — your own backoff schedule is a guess. Take the maximum of the server’s value and your computed jittered delay, clamp the result so a mistaken Retry-After: 86400 cannot park a worker for a day, and publish the pause to a shared gate so every worker using the same API key waits too. The last step is the one usually missed: geocoder quotas are enforced per key, and a per-process delay leaves fifteen other workers still hammering the endpoint.

When to Use This Pattern

  • The provider publishes a hard rate limit — Nominatim’s one-request-per-second policy, a commercial geocoder’s requests-per-minute plan tier, or a national address service’s per-key daily cap.
  • More than one worker shares a credential, which is the normal case once address matching is parallelised across tiles or municipalities.
  • The endpoint returns Retry-After on 429 or 503. Check first: many providers document a limit but never send the header, in which case the pattern degrades to ordinary exponential backoff.
  • Being throttled is expected, not exceptional — a batch geocode of 400 000 addresses will hit the limit by design, and the schedule needs to absorb that rather than treat it as failure.

Complete Working Example

The client below parses both forms of the header, clamps it, and coordinates the pause through Redis so a 429 seen by one worker pauses the fleet. Redis is incidental — any shared store with an expiring key works, including a row in PostgreSQL.

from __future__ import annotations

import email.utils
import random
import time
from datetime import datetime, timezone
from typing import Optional

import httpx
import redis

RETRY_AFTER_CAP = 300.0        # never honour more than five minutes in one hop
GATE_KEY = "geocoder:pause_until"


def parse_retry_after(raw: Optional[str]) -> Optional[float]:
    """Seconds to wait, from either the delta-seconds or the HTTP-date form."""
    if not raw:
        return None
    raw = raw.strip()
    try:
        return max(0.0, float(raw))                      # "120"
    except ValueError:
        pass
    parsed = email.utils.parsedate_to_datetime(raw)      # "Wed, 21 Oct 2026 07:28:00 GMT"
    if parsed is None:
        return None
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone.utc)
    # Clock skew makes an HTTP-date arrive "in the past"; floor at zero, never negative.
    return max(0.0, (parsed - datetime.now(timezone.utc)).total_seconds())


def wait_for_shared_gate(r: redis.Redis) -> None:
    """Block while another worker's 429 is still in force."""
    while (ttl := r.ttl(GATE_KEY)) and ttl > 0:
        # Jitter the wake-up so the whole fleet does not resume on the same tick.
        time.sleep(min(ttl, 5.0) + random.uniform(0.0, 0.5))


def close_shared_gate(r: redis.Redis, seconds: float) -> None:
    """Pause every worker on this key for `seconds`."""
    r.set(GATE_KEY, "429", ex=int(max(1.0, seconds)))


def geocode(
    client: httpx.Client,
    r: redis.Redis,
    query: str,
    country: str = "no",
    max_attempts: int = 6,
) -> Optional[dict]:
    """Geocode one address, honouring the provider's pacing for the whole fleet."""
    for attempt in range(max_attempts):
        wait_for_shared_gate(r)
        response = client.get(
            "/search",
            params={
                "q": query,
                "countrycodes": country,
                "format": "jsonv2",
                "limit": "1",
                # Ask for the CRS explicitly where the provider supports it; the
                # default is WGS84 lon/lat, and downstream code must know which.
                "addressdetails": "1",
            },
            timeout=httpx.Timeout(5.0, read=20.0),
        )

        if response.status_code in (429, 503):
            hinted = parse_retry_after(response.headers.get("Retry-After"))
            computed = random.uniform(0.0, min(60.0, 1.0 * (2 ** attempt)))
            delay = max(computed, min(hinted, RETRY_AFTER_CAP)) if hinted else computed
            close_shared_gate(r, delay)      # the whole fleet waits, not just us
            time.sleep(delay)
            continue

        response.raise_for_status()
        hits = response.json()
        return hits[0] if hits else None     # no match is a result, not an error

    raise RuntimeError(f"geocoding gave up after {max_attempts} attempts: {query!r}")

Three details in that code are doing more work than they look. wait_for_shared_gate is called at the top of every attempt, not only after a 429, so a worker that was busy elsewhere while the fleet was paused checks the gate before adding to the load. The delay is max(computed, hinted) rather than the header alone, which matters when a provider sends Retry-After: 1 under sustained pressure — your own exponential term keeps growing and eventually dominates, so repeated throttling still backs off. And the gate is written before the local sleep, so the pause is visible to the fleet immediately rather than after this worker finishes waiting.

The remaining subtlety is where the gate is checked relative to the work queue. Blocking inside geocode keeps the task simple but holds a worker slot for the whole pause; for a fleet of sixteen workers and a thirty-second pause that is eight worker-minutes spent asleep. If those slots have other work to do — a different provider, a different flow — it is better to raise the pause as a retriable exception and let the orchestrator reschedule the task after the gate expires, which returns the slot to the pool. Prefect’s Retry(delay=…) and Dagster’s RetryRequested both express this directly.

One 429 pauses the whole fleetWorker A receives a 429 carrying Retry-After thirty seconds and writes a shared pause key. Workers B, C and D read the key and wait rather than issuing requests that would also be rejected.worker A429 Too Many RequestsRetry-After: 30SET pause_until EX 30one shared keyworker Bworker Cworker Dread the gate, sleep, resume jitteredzero rejected requests during the pauseWithout the shared key, workers B, C and D each spend their own retry ladder discovering the same 429.
The quota is enforced per key, so the pause has to be per key too. A purely local sleep leaves the other workers generating exactly the load the provider asked you to stop.

Parameter & Option Reference

Parameter Type Default Spatial notes
RETRY_AFTER_CAP float 300.0 Bounds a hostile or buggy header. A provider that means “come back tomorrow” is telling you the batch cannot finish today — surface that as a failure, not a 24-hour sleep.
GATE_KEY str per credential One key per API key, not per provider. Two plans on the same host have independent quotas and must not pause each other.
max_attempts int 6 With capped exponential growth this covers roughly five minutes of throttling plus whatever the header adds.
countrycodes str "no" Always constrain the search space. An unconstrained query is slower for the provider, which makes throttling more likely, and it returns matches from the wrong continent.
limit str "1" Ask for what you will use. Requesting ten candidates and keeping one triples the response size against every rate limit measured in bytes.
read timeout float 20.0 Geocoders are fast when healthy; a 20-second read timeout catches a stuck connection long before the retry budget matters.
How each header value resolvesA delta-seconds value passes through. An HTTP-date is converted to a delta. A date already in the past floors at zero. A value of eighty-six thousand four hundred seconds is clamped to the three-hundred-second cap.header receiveddelay actually usedRetry-After: 120120 s — used as the floorWed, 21 Oct 2026 07:28:00 GMT45 s — converted against our clocka date five minutes in the past0 s — floored, our jitter decidesRetry-After: 86400300 s — clamped, then fail the task
Every branch has a defined answer, which is what keeps the pacing predictable when a provider changes its header format without telling anyone.

Verification & Testing

Test the header parsing directly — both forms, plus the skew case — and test the gate behaviour with a fake clock rather than by sleeping.

import email.utils
from datetime import datetime, timedelta, timezone


def test_parse_retry_after_handles_both_forms() -> None:
    assert parse_retry_after("120") == 120.0

    future = datetime.now(timezone.utc) + timedelta(seconds=45)
    header = email.utils.format_datetime(future)
    assert 40 <= parse_retry_after(header) <= 50

    # A date already in the past (clock skew) must floor at zero, never go negative:
    past = email.utils.format_datetime(datetime.now(timezone.utc) - timedelta(minutes=5))
    assert parse_retry_after(past) == 0.0

    assert parse_retry_after(None) is None
    assert parse_retry_after("next tuesday") is None


def test_gate_blocks_other_workers(fake_redis) -> None:
    close_shared_gate(fake_redis, 30.0)
    assert fake_redis.ttl(GATE_KEY) > 0      # every other worker will see this

In production the number to watch is the ratio of 429s to successful calls per key. A steady 2–5% means the pacing is close to the provider’s limit and the batch is running as fast as it is allowed to; a spike to 50% means concurrency was raised without raising the plan, and the fleet is now spending most of its wall-clock time being rejected.

Common Pitfalls

  • Sleeping locally and calling it done. The single most common mistake: worker A honours the header perfectly while workers B through P keep sending. The provider sees no reduction in load and may escalate from throttling to a temporary ban on the key.
  • Trusting the header without a cap. A misconfigured gateway that emits Retry-After: 86400 will hold a worker slot for a day if you honour it literally. Clamp, and let the task fail so the scheduler can decide.
  • Ignoring the HTTP-date form. Roughly a third of providers send a date rather than a delta. Parsing only the integer form means those responses fall back to your own guess, which is usually far too short.
  • Treating “no match” as an error worth retrying. An address the geocoder cannot resolve returns 200 with an empty result set. Retrying it burns quota to receive the same empty answer, and the correct destination for those records is a dead-letter queue for manual review.
  • Forgetting that the response is lon/lat. Most geocoders return WGS84 with longitude first in GeoJSON and latitude first in their human-readable fields. Constructing a point from the wrong pair puts Oslo in Somalia, which is why validating coordinate systems before ETL belongs immediately after the geocode step.
Where the quota goes, with and without a shared pauseWithout a fleet-wide gate, rejected requests dominate the request mix during throttling. With the gate, the same wall-clock window is spent almost entirely on successful calls.LOCAL SLEEP ONLY — 16 workers, one key2 100 ok6 800 rejected — quota spent on 429sSHARED GATE — same 16 workers, same ten minutes5 900 ok240Same provider, same plan, same concurrency — the only difference is whether one worker's 429 stops the others.
Throughput goes up when you slow down, because rejected requests still consume the window they are rejected in.

Frequently Asked Questions

Should the shared gate be Redis, or can I use the orchestrator?

Either. Prefect’s global concurrency limits and Dagster’s run-queue tags both give you a fleet-wide gate without another dependency, and they are the better choice if you already run them. Redis wins when the pause has to be sub-second-accurate or when the workers are not all under one orchestrator.

Does honouring `Retry-After` slow the batch down?

It slows the rejected requests down, which is the point — those requests were producing nothing. In practice a fleet that honours the header finishes a large batch sooner than one that does not, because the provider stops escalating and the successful-call rate stays flat rather than collapsing.

What if the provider throttles without any status code, by slowing responses?

Some do. That shows up as a rising p95 latency with a stable error rate, and no header to read. Treat sustained latency growth as a throttling signal: reduce concurrency, and if the latency does not recover, open a circuit breaker so the pipeline stops paying full price for slow answers.

How does this fit a 400 000-address batch?

Size the batch from the quota, not the other way round. At one request per second a 400 000-address batch is four and a half days, so the design question is whether to buy a higher tier, cache aggressively on normalised address strings, or split the work across nights — decisions the pacing code cannot make for you, but which its metrics make obvious.

Exponential Backoff for API Rate Limits