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-Afteron 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.
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. |
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: 86400will 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.
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.
Related
- Exponential backoff for API rate limits — the schedule the header overrides
- Implementing retry logic for slow WFS endpoints — the same problem where the server is slow rather than strict
- Circuit breakers for external WMS services — the fleet-wide stop when throttling becomes an outage
- Storing failed geometries in a PostGIS dead-letter queue — where unresolvable addresses go