Skip to content
Back to Blog
pythonfastapiazure-mapscachinggeocodinghttpxrate-limitingobservabilitylanggraph

Cache-First Geocoding with Azure Maps: Key Topology, TTL Heuristics, and Quota Smoothing

Daniel Anthony Romitelli Jr. · March 3, 2026

There's one file in this codebase I wanted to write about: the azure maps module. It defines an Azure Maps client (async, httpx-backed), exposes geocoding methods (including geocode_address, which our LangGraph manager calls by name), and it sits in the enrichment graph as the "last mile" location normalizer. Real code, non-trivial code. The interesting parts are the cache key scheme (tile vs geohash), the TTL heuristics by zoom level, and what happens when we fall through to the upstream geocoder with backoff.

"We call Azure Maps" isn't the sentence that matters about that file. Here's the one that cost us something to learn: the fastest, cheapest API call is the one you never make. So the integration is cache-first by design, with keys that dedupe semantically equivalent queries and TTLs that match how "stable" a location answer is.

What follows is the design, the incident that forced it, and the implementation patterns that keep Azure Maps reliable when load arrives in bursts.

What went wrong first (the real trigger)

The first failure mode wasn't an exception trace. It was a behavioral bug, and it multiplied upstream calls without anything failing.

In the langgraph manager module we literally left ourselves a trail of corrective surgery in comments and log messages:

  • "Fix 2: Address Detection Heuristic - route to correct endpoint"
  • "Fix 7: Short-circuit when both fields set (skip Azure Maps if Firecrawl already set both)"

Neither of those is a stylistic refactor. Scar tissue, both of them.

Here's the scenario that caused the bleed:

  1. We'd run Firecrawl research for an entity.
  2. Firecrawl would often return city/state already (good enough for our product surface).
  3. The graph would call Azure Maps anyway, sometimes more than once, because downstream steps treated geocoding as a mandatory enrichment rather than a conditional completion step.
  4. Worse: we were sometimes routing an address-shaped query ("123 Main St, Seattle") into a POI search endpoint, which tends to be broader and less deterministic, getting no results, then falling back to address geocoding. Two calls where one would do.

Hence the explicit short-circuit now in the LangGraph manager:

  • If both city and state are already present, we skip Azure Maps.

And hence the address detection heuristic:

  • If the query looks like an address, go straight to geocode_address.

Those two fixes are where the geocoding integration stopped being "a convenience" and became a budgeted service with strong opinions about when it is allowed to run.

Accept that premise and cache-first becomes table stakes rather than an optimization.

The integration boundary: how Azure Maps actually fits the pipeline

Azure Maps is not the source of truth for location in this platform.

The platform is an enrichment system. It pulls fragments out of messy sources, XLSX-imported advisor records, CRM fields, Firecrawl research, and normalizes them into something we can query, sort, and reason about.

So the location pipeline is layered on purpose:

  1. Prefer already-known structured fields (DB/CRM).
  2. Prefer research-derived location (Firecrawl returns city/state/address frequently).
  3. Use Azure Maps only to fill gaps or normalize ambiguous strings.

That matches the logging we left in the langgraph manager module (short-circuit if Firecrawl already completed the fields), and it matches the wider system discipline you can see in the advisor enrichment worker:

  • cost is returned (credits_used, search_duration_seconds)
  • the workflow executor carries total_credits as an invariant across every iteration
  • feature flags exist as kill switches

Geocoding follows the same philosophy. It's a bounded dependency, and we don't get to treat it as a magical oracle.

Cache-first geocoding: the three decisions that matter

When people say "cache geocoding," they usually mean "store the JSON response keyed by the query string." That's not enough.

In practice, three decisions determine whether your cache saves you money or just stores junk:

  1. Key topology: what is the canonical representation of the request?
  2. TTL heuristics: how long is the answer valid for this type of query?
  3. Quota smoothing / backoff: what happens under burst, partial outage, or 429?

I'll take each in turn, then show a working reference implementation that mirrors what we ship: async client, cache wrapper, TTL policy, and backoff.

1) Cache key topology: query strings are lies

A location lookup's "meaning" isn't the raw string. Users (and upstream systems) produce semantically identical queries with wildly different spelling and formatting:

  • "New York, NY" vs "New York NY" vs "new york, new york"
  • "St. Louis" vs "Saint Louis"
  • "Seattle WA" vs "Seattle, Washington"

Key on the raw query and you'll miss most of your hits.

My rule: keys must be stable under harmless variation

For our integration, I treat a geocode request as a tuple of:

  • operation: geocode_address, geocode_place, reverse_geocode, poi_search
  • normalized query: case-folded, whitespace collapsed, punctuation simplified
  • country filter (and any other parameter that materially changes results)
  • resolution: a "zoom class" or precision tier that drives TTL

Tile vs geohash (and why I don't pick only one)

This is the part people argue about.

  • Tile-based keys (Web Mercator tiles like z/x/y) work well for map rendering workloads and reverse geocoding around a viewport. They line up with what the user sees.
  • Geohash keys work well for deduping point-like lookups and clustering nearby requests across different zoom levels.

Our enrichment workload does both kinds of lookup:

  • Address / place queries: string to coordinates + components
  • Reverse lookups: lat/lon to locality + admin regions

So the key topology is mixed:

  • For string-based geocoding, the key comes from the normalized string, plus country filter, plus operation.
  • For reverse geocoding, the key comes from a geohash (or equivalently a rounded lat/lon bucket) so nearby points reuse results.

Why bucket the reverse lookups? Simple. Downstream tasks don't need "the city boundary accurate to 1 meter." They need a stable city/state answer.

2) TTL heuristics: store stable answers longer than volatile ones

One TTL number won't do. TTL is a policy.

Location answers change at different rates:

  • A city/state for a point is stable for months.
  • A POI search query can change quickly (businesses open and close, rankings shift).
  • An address geocode can change slowly (new construction, renumbering) but is usually stable.

So we use TTL buckets:

  • Reverse geocode locality: long TTL
  • Address geocode: medium TTL
  • POI search: shorter TTL

We also condition TTL on "resolution": the coarser the query, the longer we can cache, because the answer is inherently less sensitive.

The idea underneath all of it is that TTL encodes how expensive it is to be wrong.

If a reverse geocode returns the wrong city once a year, nobody cares. If POI search returns a stale ranking for "pizza near me," people notice.

3) Quota smoothing + backoff: the upstream will say no

Even with caching, you can spike:

  • a batch enrichment run
  • a user action that fans out to many lookups
  • a retry storm during transient network failure

Treat Azure Maps as "just another HTTP call" and you'll eventually create your own 429 incident.

So we shape traffic:

  • Client-side concurrency limits (don't fire 500 calls at once)
  • Backoff on 429/503 (respect Retry-After when present)
  • Negative caching for empty results (short TTL, so you don't re-query nonsense every time)

Same mindset as the Firecrawl side:

  • we pass max_credits into the Firecrawl agent
  • we accumulate credits_used

For Azure Maps, the equivalent is "max requests per second" plus cache.

A complete, runnable reference implementation

The actual azure maps module in this project is integrated into our settings, auth, and cache stack. For the blog, I'm providing a minimal but fully runnable version that demonstrates the exact patterns we use:

  • async httpx client
  • cache-first wrapper with an interface you can back by Redis/memory
  • stable key generation
  • TTL heuristics
  • backoff with Retry-After

Paste it into a file and run it. It uses a fake upstream call, so you don't need Azure credentials.

import asyncio
import json
import re
import time
from dataclasses import dataclass
from typing import Any, Optional, Dict, Tuple

import httpx


class AsyncCache:
    """Minimal async cache interface."""

    async def get(self, key: str) -> Optional[str]:
        raise NotImplementedError

    async def set(self, key: str, value: str, ttl_seconds: int) -> None:
        raise NotImplementedError


class InMemoryTTLCache(AsyncCache):
    def __init__(self) -> None:
        self._store: Dict[str, Tuple[float, str]] = {}

    async def get(self, key: str) -> Optional[str]:
        now = time.time()
        item = self._store.get(key)
        if not item:
            return None
        expires_at, value = item
        if expires_at < now:
            self._store.pop(key, None)
            return None
        return value

    async def set(self, key: str, value: str, ttl_seconds: int) -> None:
        self._store[key] = (time.time() + ttl_seconds, value)


def _normalize_query(q: str) -> str:
    q = q.strip().lower()
    # Collapse whitespace
    q = re.sub(r"\s+", " ", q)
    # Normalize common punctuation
    q = q.replace(",", " ")
    q = re.sub(r"\s+", " ", q).strip()
    return q


def _is_likely_address(q: str) -> bool:
    """Cheap heuristic: number + street-ish token."""
    qn = _normalize_query(q)
    return bool(re.search(r"\b\d{1,6}\b", qn)) and bool(re.search(r"\b(st|street|ave|avenue|rd|road|blvd|lane|ln|dr|drive)\b", qn))


def _geohash_bucket(lat: float, lon: float, precision_digits: int = 2) -> str:
    """Not a real geohash; a bucketed coordinate key (good enough for cache keys)."""
    return f"{round(lat, precision_digits)}:{round(lon, precision_digits)}"


@dataclass
class TTLPolicy:
    reverse_geocode_seconds: int = 60 * 60 * 24 * 30       # 30 days
    address_geocode_seconds: int = 60 * 60 * 24 * 7        # 7 days
    poi_search_seconds: int = 60 * 60 * 12                 # 12 hours
    negative_cache_seconds: int = 60 * 10                  # 10 minutes


class AzureMapsClient:
    def __init__(
        self,
        *,
        subscription_key: str,
        cache: AsyncCache,
        ttl: TTLPolicy = TTLPolicy(),
        timeout_seconds: float = 10.0,
        max_retries: int = 3,
    ) -> None:
        self.subscription_key = subscription_key
        self.cache = cache
        self.ttl = ttl
        self.max_retries = max_retries
        self._client = httpx.AsyncClient(timeout=timeout_seconds)

    async def aclose(self) -> None:
        await self._client.aclose()

    def _cache_key_geocode(self, op: str, query: str, country_filter: Optional[str]) -> str:
        qn = _normalize_query(query)
        cf = (country_filter or "").upper()
        return f"azure_maps:{op}:q={qn}:country={cf}"

    def _cache_key_reverse(self, lat: float, lon: float) -> str:
        bucket = _geohash_bucket(lat, lon, precision_digits=2)
        return f"azure_maps:reverse:bucket={bucket}"

    async def _cached_json(self, key: str) -> Optional[dict]:
        raw = await self.cache.get(key)
        return json.loads(raw) if raw else None

    async def _store_json(self, key: str, obj: dict, ttl_seconds: int) -> None:
        await self.cache.set(key, json.dumps(obj, separators=(",", ":")), ttl_seconds)

    async def _request_with_backoff(self, method: str, url: str, params: dict) -> dict:
        # This method is written for Azure Maps style endpoints but uses a fake upstream
        # to keep the snippet runnable.
        for attempt in range(self.max_retries + 1):
            try:
                # Simulated upstream behavior: no network call.
                # Replace with:
                #   resp = await self._client.request(method, url, params=params)
                #   resp.raise_for_status(); return resp.json()
                await asyncio.sleep(0.02)
                return {"ok": True, "url": url, "params": params}
            except httpx.HTTPStatusError as e:
                status = e.response.status_code
                if status in (429, 503) and attempt < self.max_retries:
                    retry_after = e.response.headers.get("Retry-After")
                    delay = float(retry_after) if retry_after else (0.5 * (2 ** attempt))
                    await asyncio.sleep(delay)
                    continue
                raise

        raise RuntimeError("unreachable")

    async def geocode_address(self, query: str, *, country_filter: Optional[str] = None) -> dict:
        key = self._cache_key_geocode("geocode_address", query, country_filter)
        cached = await self._cached_json(key)
        if cached:
            return cached

        payload = await self._request_with_backoff(
            "GET",
            url="https://atlas.microsoft.com/search/address/json",
            params={
                "subscription-key": self.subscription_key,
                "api-version": "1.0",
                "query": query,
                "countrySet": country_filter,
            },
        )

        # Negative caching: if upstream yields no useful content, keep a short TTL.
        ttl = self.ttl.address_geocode_seconds if payload.get("ok") else self.ttl.negative_cache_seconds
        await self._store_json(key, payload, ttl)
        return payload

    async def poi_search(self, query: str, *, country_filter: Optional[str] = None) -> dict:
        key = self._cache_key_geocode("poi_search", query, country_filter)
        cached = await self._cached_json(key)
        if cached:
            return cached

        payload = await self._request_with_backoff(
            "GET",
            url="https://atlas.microsoft.com/search/poi/json",
            params={
                "subscription-key": self.subscription_key,
                "api-version": "1.0",
                "query": query,
                "countrySet": country_filter,
            },
        )
        ttl = self.ttl.poi_search_seconds if payload.get("ok") else self.ttl.negative_cache_seconds
        await self._store_json(key, payload, ttl)
        return payload

    async def reverse_geocode(self, lat: float, lon: float) -> dict:
        key = self._cache_key_reverse(lat, lon)
        cached = await self._cached_json(key)
        if cached:
            return cached

        payload = await self._request_with_backoff(
            "GET",
            url="https://atlas.microsoft.com/search/address/reverse/json",
            params={
                "subscription-key": self.subscription_key,
                "api-version": "1.0",
                "query": f"{lat},{lon}",
            },
        )
        ttl = self.ttl.reverse_geocode_seconds if payload.get("ok") else self.ttl.negative_cache_seconds
        await self._store_json(key, payload, ttl)
        return payload


async def demo() -> None:
    cache = InMemoryTTLCache()
    client = AzureMapsClient(subscription_key="REDACTED", cache=cache)

    q = "123 Main St, Seattle, WA"
    if _is_likely_address(q):
        a = await client.geocode_address(q, country_filter="US")
        b = await client.geocode_address(" 123  Main St Seattle WA ", country_filter="US")
        assert a == b  # cache hit due to normalization

    r1 = await client.reverse_geocode(47.6062, -122.3321)
    r2 = await client.reverse_geocode(47.60621, -122.33209)
    assert r1 == r2  # cache hit due to bucketing

    await client.aclose()
    print("ok")


if __name__ == "__main__":
    asyncio.run(demo())

That demo captures the core behavior we rely on in production:

  • address detection routes to the correct method
  • normalization dedupes string variants
  • reverse geocode buckets nearby points
  • TTL varies by operation
  • retries don't create a thundering herd

The "POI then address" fallthrough (and why it exists)

The LangGraph manager shows a practical fallback sequence:

  • try POI search
  • if it returns nothing, try address geocoding

Which sounds redundant until you look at the inputs we actually get from upstream systems:

  • Sometimes the query is a company name plus a city, and POI search is better.
  • Sometimes it's a literal address, and address geocoding is better.
  • Sometimes it's half an address ("Main St Seattle"), and POI might find a canonical thing when address geocode can't.

The mistake we fixed was letting this fallback happen blindly.

The correct behavior is:

  1. If the string is likely an address, skip POI entirely.
  2. If POI returns no results and the query is ambiguous, then fall back to address.
  3. Cache each step separately so "bad POI query" doesn't cause repeated upstream calls.

That third point is easy to miss. Store only the final "best effort" and you'll keep retrying the losing branch.

Quota smoothing in the real system: shaping bursts rather than only retrying

Backoff only helps after the upstream is already unhappy.

Avoiding the burst in the first place is the better move. In the production integration, I enforce that at two layers:

  • per-process concurrency caps (async semaphore around outbound calls)
  • cache-first with negative caching so repeated bad queries don't hammer the API

Same mental model as the advisor enrichment worker's credit accounting. You don't "audit later." You design the flow so it can't exceed its budget by accident.

How the discipline shows up elsewhere: Firecrawl credits and feature flags

This post is about Azure Maps, but the codebase has a consistent theme: "make cost and control explicit."

You can see it in the advisor enrichment worker.

Credit accounting as an invariant

In the workflow executor module, we iterate advisors, call a Firecrawl agent with a max_credits cap, and accumulate total_credits.

The tell is the comment about attribute access:

  • the result is a Pydantic model, so reading .credits_used is correct
  • the earlier bug was treating it like a dict (.get()), which silently breaks accounting

Here's a minimal, runnable reproduction of that exact class of bug:

from dataclasses import dataclass


@dataclass
class AgentResult:
    credits_used: float
    payload: dict


def broken_accounting(result: AgentResult) -> float:
    # This is the mistake: treating a model like a dict.
    # AttributeError would occur in strict code; in loosely typed code
    # people often wrap it and end up returning 0.
    try:
        return result.get("credits_used", 0.0)  # type: ignore[attr-defined]
    except Exception:
        return 0.0


def correct_accounting(result: AgentResult) -> float:
    return float(result.credits_used)


if __name__ == "__main__":
    r = AgentResult(credits_used=1.25, payload={"ok": True})
    assert broken_accounting(r) == 0.0
    assert correct_accounting(r) == 1.25
    print("ok")

That's why I like having "credits used" flow through the code as a first-class value. You can unit test it. You can put assertions around it. You can build a budget gate.

Feature flags as operational kill switches

the settings routes module defines in-memory feature flags, with a note that production should use Redis/DB. The storage mechanism matters far less than having a kill switch you can hit fast.

Here's a complete, runnable example of the pattern, matching the shape in that file ({name: (enabled, description)} and a Pydantic update model):

from typing import Dict, Tuple
from pydantic import BaseModel


class FeatureFlagUpdate(BaseModel):
    enabled: bool


FEATURE_FLAGS: Dict[str, Tuple[bool, str]] = {
    "linkedin_matching": (True, "Auto-match advisors to LinkedIn profiles"),
    "brokercheck_enrichment": (True, "Enable FINRA BrokerCheck lookups via Firecrawl"),
    "firecrawl_research": (True, "Enable Firecrawl research jobs and SSE streaming"),
    "azure_maps_geocoding": (True, "Enable Azure Maps geocoding for city/state normalization"),
}


def set_flag(name: str, update: FeatureFlagUpdate) -> None:
    if name not in FEATURE_FLAGS:
        raise KeyError(name)
    _, desc = FEATURE_FLAGS[name]
    FEATURE_FLAGS[name] = (bool(update.enabled), desc)


if __name__ == "__main__":
    assert FEATURE_FLAGS["azure_maps_geocoding"][0] is True
    set_flag("azure_maps_geocoding", FeatureFlagUpdate(enabled=False))
    assert FEATURE_FLAGS["azure_maps_geocoding"][0] is False
    print("ok")

When you're running enrichment at scale, feature flags are how you keep a partial outage from becoming a full outage.

One diagram: geocoding in the enrichment graph (as it actually behaves)

This is the dataflow I hold in my head whenever I touch this system. Prefer the structured fields we already have, accept Firecrawl if it already solved the problem, and only then call Azure Maps, through a cache boundary.

Operational edge cases (the stuff that bites you at 2 a.m.)

1) Empty results can be more expensive than good results

When an upstream returns "no matches," your system is tempted to keep trying:

  • alternate spelling
  • removing punctuation
  • widening the query

Fine, once. But if the input is garbage, you'll pay for that garbage forever unless you negative-cache.

So we cache empty-ish results briefly. Not forever, because data changes, but long enough to suppress repeated failures.

2) Timeouts must be budgeted, not defaulted

Geocoding calls often happen inside larger workflows. If your Azure Maps timeout is 30 seconds and your workflow has 10 such calls, you can create multi-minute tail latency.

The fix is simple: keep geocoding timeouts tight, and lean on cache plus retries with backoff for resilience.

3) Caching must include parameters, or you will lie to yourself

If country_filter changes the answer, it belongs in the key.

Same for anything else that changes the result set (language, typeahead bias, and so on). You don't want a cache hit that returns the right answer for the wrong request.

Closing

Once "Fix 2" and "Fix 7" landed in the graph, routing address queries correctly and short-circuiting Azure Maps when Firecrawl had already delivered city/state, geocoding stopped being a background detail and became a budgeted, testable subsystem. Cache-first keys, a TTL policy, and quota smoothing are how you keep one fuzzy location string from turning into a thousand identical upstream calls. Calling them performance tricks undersells them.