Caching and Rate Limiting Geocoding Requests

This page solves one exact task: putting a persistent result store and a client-side rate limiter in front of a geocoding service, so a pipeline pays for each distinct address once and never overwhelms the provider it depends on.

Geocoding is the stage where a careless pipeline discovers its costs. The same store estate re-geocoded on every nightly run is thousands of wasted requests a week; a customer backfill issued without pacing is a rate-limit wall and, on a self-hosted service, a queue that turns a ten-minute job into a timeout cascade. Both problems have the same two-part answer, and both are cheaper to build than to retrofit.

Prerequisites

  • Python packages: httpx for the client, redis or a database of your choice for the shared store, and pandas for batch work. Install with pip install httpx redis pandas.
  • Normalized addresses with a stable key from parsing and standardizing US store addresses. Caching on the raw string is the mistake this whole page exists to prevent.
  • A known provider limit — requests per second, per minute or per day — and, for a self-hosted service, a measured saturation point rather than a guessed one.

Configuration and execution parameters

Parameter Value for this task Notes
cache_key normalized components + reference build Never the raw string
negative_ttl 30 days A no-match is a result worth remembering
positive_ttl until the build changes Not a duration
rate_limit_qps 80% of the provider ceiling Leave headroom for retries
burst 2× the steady rate Token bucket depth
max_concurrency 8 Independent of the rate limit and equally necessary
backoff 1s, 4s, 16s, jittered Honour Retry-After when present
stampede_guard single-flight per key One request per key in flight, ever

Two of those settings are routinely conflated. Rate limits how many requests start per second; concurrency limits how many are outstanding at once. A pipeline with a 10 requests-per-second limit and no concurrency cap will, when the provider slows down, accumulate hundreds of in-flight requests and time them all out simultaneously. Both controls are needed, and they fail differently when missing.

A request's path through the store and the limiter A normalized key is looked up in the result store. A positive hit returns immediately; a negative hit returns a known no-match without calling the provider; a miss passes through the single-flight guard and the token bucket to the provider, and the result — success or no-match — is written back. Three outcomes, and only one of them costs a request normalized key + reference build in the store? positive · negative · miss positive hit → return, 0 requests negative hit → no-match, 0 requests single flight one per key token bucket → provider every result written back — including the no-matches Negative caching is what stops a permanently unmatchable address being retried on every run forever.

Annotated implementation

The client below composes the three pieces — store lookup, single-flight guard, token bucket — around whatever geocoding call sits underneath.

python
from __future__ import annotations

import asyncio
import json
import time

import httpx
import redis.asyncio as redis

REFERENCE_BUILD = "openaddresses-2026-07-31"
NEGATIVE_TTL = 60 * 60 * 24 * 30          # a no-match is a result worth keeping
RATE_QPS = 8.0
BURST = 16


class TokenBucket:
    """Paces request starts. Independent of how many are in flight."""

    def __init__(self, rate: float, burst: int) -> None:
        self._rate, self._capacity = rate, float(burst)
        self._tokens, self._updated = float(burst), time.monotonic()
        self._lock = asyncio.Lock()

    async def take(self) -> None:
        async with self._lock:
            now = time.monotonic()
            self._tokens = min(self._capacity,
                               self._tokens + (now - self._updated) * self._rate)
            self._updated = now
            if self._tokens < 1.0:
                wait = (1.0 - self._tokens) / self._rate
                await asyncio.sleep(wait)
                self._tokens = 0.0
            else:
                self._tokens -= 1.0


class GeocodeCache:
    def __init__(self, store: redis.Redis, bucket: TokenBucket) -> None:
        self.store, self.bucket = store, bucket
        self._inflight: dict[str, asyncio.Future] = {}

    def _key(self, address_key: str) -> str:
        # The reference build is part of the key: a new build is a new cache,
        # and the old entries expire on their own rather than being purged.
        return f"geo:{REFERENCE_BUILD}:{address_key}"

    async def resolve(self, address_key: str, components: dict,
                      client: httpx.AsyncClient) -> dict | None:
        key = self._key(address_key)
        cached = await self.store.get(key)
        if cached is not None:
            payload = json.loads(cached)
            return payload or None            # {} encodes a remembered no-match

        # Single flight: concurrent callers for one key share one request.
        if key in self._inflight:
            return await self._inflight[key]

        loop = asyncio.get_running_loop()
        fut: asyncio.Future = loop.create_future()
        self._inflight[key] = fut
        try:
            await self.bucket.take()
            resp = await client.get("/v1/search/structured", params=components,
                                    timeout=20.0)
            resp.raise_for_status()
            hits = resp.json().get("features") or []
            result = hits[0] if hits else None
            await self.store.set(key, json.dumps(result or {}),
                                 ex=None if result else NEGATIVE_TTL)
            fut.set_result(result)
            return result
        except Exception as exc:              # do not cache transport failures
            fut.set_exception(exc)
            raise
        finally:
            self._inflight.pop(key, None)

Two decisions in that class are the ones worth copying. A transport failure is never cached — caching it would turn a thirty-second network blip into thirty days of missing coordinates. And a positive result has no expiry, because its validity is tied to the reference build already in the key; giving it a time-based expiry would re-request unchanged data on a schedule that has nothing to do with when the answer could change.

Failure modes and debugging

A hit rate that stays near zero. Almost always a key that varies when it should not — the raw string instead of components, a timestamp, a request identifier, or full float precision on a coordinate. Log a sample of keys and read them; the offending component is usually obvious within ten lines.

The thundering herd after a build change. Changing the reference build invalidates every entry at once, so the first run afterwards is a full cold pass. That is correct, and it should be expected rather than discovered: schedule the first post-build run when the provider can take it, or pre-warm the store from the previous build’s key set.

Retry storms. A provider returning 429 to a client that retries immediately produces more 429s. Honouring Retry-After, adding jitter so parallel workers do not resynchronise, and capping total attempts converts a storm into a slower but successful run. The retry policy discipline that applies to pipeline tasks applies here at request granularity.

An unbounded in-flight map. The single-flight dictionary grows with distinct keys in flight and shrinks as they complete — unless an exception path forgets to remove the entry. The finally block above is not decoration; without it, a run that hits errors leaks futures until it exhausts memory.

What the store is worth on a two-million-row backfill Two million customer rows contain 840,000 distinct normalized addresses. Without a store the run issues two million requests; with one it issues 840,000 on the first pass and 19,000 on the next monthly refresh, because only genuinely new addresses miss. Distinct addresses, not rows, are what a pipeline should pay for no store · first run 2,000,000 requests no store · monthly refresh 2,000,000 requests — every month with a store · first run 840,000 distinct addresses with a store · monthly refresh 19,000 — new addresses only The second row is the one that matters: without a store the monthly cost never falls, however stable the customer base is, because the pipeline has no memory of what it already knows. Deduplication before the geocoder call is the same saving expressed differently, and both are worth having.

Verification

  • Assert the key is stable. Build the key twice from differently-formatted versions of one address and confirm they match. This is the single test that protects the whole mechanism.
  • Confirm negative caching works. Geocode a deliberately unmatchable address twice and confirm the second call issues no request. Without negative caching, the worst addresses are retried forever.
  • Measure the achieved rate under load. Run a batch and count request starts per second against the configured limit. A limiter that is never reached is either badly configured or the workload is smaller than assumed — both worth knowing.
  • Test the single-flight guard. Issue fifty concurrent requests for one key and confirm exactly one reaches the provider. Concurrency bugs in this layer are invisible until a stampede.
Three pacing strategies against the same provider limit Unpaced, a batch triggers 1,840 rate-limit responses and completes in 41 minutes. With naive fixed-delay retries it triggers 210 and takes 26 minutes. With a token bucket at eighty per cent of the limit it triggers none and completes in 19 minutes — the fastest option is also the politest. Pacing below the limit finishes sooner than racing it strategy rate-limit responses wall clock provider view unpaced, as fast as possible 1,840 41 min abusive fixed-delay retries 210 26 min bursty token bucket at 80% 0 19 min steady Every rejected request is work done twice, so a client that never gets rejected does strictly less work — which is why the polite strategy is also the fast one. The same holds against a self-hosted service, where the penalty for exceeding saturation is queueing rather than rejection, and the queue eventually becomes a timeout.

Frequently Asked Questions

Should the cache be shared across pipelines or per-job?

Shared, in a store every job can reach. A per-job cache helps within one run and forgets everything between runs, which means the nightly refresh and the ad-hoc analysis and the backfill each pay separately for the same addresses. The shared store also makes the hit rate a meaningful operational metric rather than a property of whichever job happened to run.

What belongs in the cache value?

The full provider response, not just the coordinate. Storing the whole payload means a later change — starting to record the precision tier, the matched reference identifier, the match score — is a re-read rather than a re-request. Storage is cheap and geocoding requests are not, so the asymmetry favours keeping everything.

How should the store handle a provider that changes its response format?

By keying on the provider and its API version alongside the reference build, so a format change starts a new namespace rather than mixing shapes in one. Code that has to handle two response formats because the cache contains both is code that will eventually mis-parse one of them, and the failure will look like a data problem rather than a versioning one.

Does a self-hosted geocoder still need rate limiting?

Yes, and the limit is a measured saturation point rather than a published quota. A self-hosted service will accept far more concurrent requests than it can serve well, and past saturation the latency rises until the client’s own timeouts start firing — at which point the client retries, adding load to an already-saturated service. Pacing at eighty per cent of measured saturation gives the same protection a provider’s quota would.

How large does the store get, and does it need eviction?

Smaller than teams expect. A full provider response is a few kilobytes, so a million distinct addresses is a few gigabytes — trivial for a database and modest even for an in-memory store. Eviction is therefore not about space but about correctness: entries keyed on a superseded reference build are dead weight and can be dropped wholesale by prefix once nothing reads them, which is a single operation rather than a scan. Where memory genuinely is tight, a two-tier arrangement works well, with recent keys in memory and the full history in object storage behind them.

← Back to Geocoding & Address Normalization