Building a Country-to-Provider Routing Table in Python

TL;DR: Keep the mapping from country to provider chain as versioned data outside the deployment, validate it on load, require a wildcard row, and swap it in atomically so a bad edit keeps the previous version rather than taking routing down. The measurements that populate it come from dynamic provider selection based on region.

The Table Is Data, Not Code

The mapping changes far more often than the dispatch logic around it — a coverage measurement, a contract change, a provider incident — and every one of those should be an edit to a document rather than a release.

{
  "version": 14,
  "updated_at": "2026-08-06T09:12:00Z",
  "default_chain": ["here", "osm"],
  "routes": {
    "US": ["google", "here", "osm"],
    "GB": ["google", "here"],
    "DE": ["here", "google", "osm"],
    "JP": ["local_jp", "here"],
    "BR": ["here", "osm"]
  }
}

The default_chain is the field that prevents an entire class of incident. Address data reliably contains countries nobody planned for — a single order from a new market — and a lookup that raises on a missing key turns that one record into a failed batch.

Loading, Validating, Swapping

from __future__ import annotations

import json
import threading
from dataclasses import dataclass

KNOWN_PROVIDERS = frozenset({"google", "here", "mapbox", "osm", "local_jp"})


class RoutingTableError(ValueError):
    """Raised when a candidate table fails validation."""


@dataclass(frozen=True)
class RoutingTable:
    version: int
    default_chain: tuple[str, ...]
    routes: dict[str, tuple[str, ...]]

    def chain_for(self, country: str | None) -> tuple[str, ...]:
        if not country:
            return self.default_chain
        return self.routes.get(country.upper(), self.default_chain)


def parse(raw: str) -> RoutingTable:
    """Parse and validate. Raises rather than returning a half-valid table."""
    doc = json.loads(raw)
    default = tuple(doc.get("default_chain") or ())
    if not default:
        raise RoutingTableError("default_chain is required and must be non-empty")

    routes: dict[str, tuple[str, ...]] = {}
    for country, chain in (doc.get("routes") or {}).items():
        if len(country) != 2 or not country.isalpha():
            raise RoutingTableError(f"not an ISO alpha-2 code: {country!r}")
        if not chain:
            raise RoutingTableError(f"empty chain for {country}")
        unknown = set(chain) - KNOWN_PROVIDERS
        if unknown:
            raise RoutingTableError(f"unknown providers for {country}: {sorted(unknown)}")
        routes[country.upper()] = tuple(chain)

    unknown_default = set(default) - KNOWN_PROVIDERS
    if unknown_default:
        raise RoutingTableError(f"unknown providers in default: {sorted(unknown_default)}")
    return RoutingTable(int(doc["version"]), default, routes)


class RoutingRegistry:
    """Holds the current table and swaps it atomically on refresh."""

    def __init__(self, initial: RoutingTable) -> None:
        self._table = initial
        self._lock = threading.Lock()

    @property
    def table(self) -> RoutingTable:
        return self._table                    # attribute read is atomic

    def refresh(self, raw: str) -> bool:
        """Swap in a new table, or keep the old one and report failure."""
        try:
            candidate = parse(raw)
        except (RoutingTableError, json.JSONDecodeError, KeyError):
            return False                      # caller alerts; routing is unaffected
        with self._lock:
            self._table = candidate
        return True

The failure path is the design. A typo in a hand-edited document returns False, the previous table stays in effect, and an alert fires — rather than the alternative, where a malformed file empties the routing table and every record falls through to an exception.

Where the Rows Come From

From measurement to a routed record A four-stage flow. Per-market benchmark results produce a proposed ordering. The edit is validated. The registry swaps the table in atomically. Every routed record carries the table version so a later accuracy change can be traced to the routing decision that produced it. benchmark rooftop share per market edit + version bump one row, reviewed validate + swap or keep the previous version stamped result table_version=14 The stamp closes the loop: when accuracy moves, the first question is which table version was in effect, and a column answers it in one query rather than by reconstructing a deployment timeline.

Reviewing the edit like code — a diff, an approval, a version bump — is what keeps the table trustworthy while still allowing it to change without a release. The document lives in version control even though it is loaded at runtime, which gives you the history for free.

Validation Rejects, It Does Not Repair

A failed refresh changes nothing A candidate table containing an unknown provider is parsed. Validation rejects it, the registry keeps version fourteen in effect, and an alert is raised. The alternative, a permissive parser that drops the bad row and accepts the rest, is shown as silently changing routing for one market. candidate v15 contains a typo validate all or nothing reject → v14 stays live, alert raised routing is completely unaffected a permissive parser would drop the row and silently reroute one market Partial acceptance is the dangerous option: routing changes and nothing tells you it did.

Testing Without a Network

Every interesting case in this component is a pure function over the table, which makes the tests fast and comprehensive.

Case Expectation
unmapped country returns the default chain, does not raise
lower-case country code matched case-insensitively
None country returns the default chain
empty chain for a country rejected at parse time
unknown provider name rejected at parse time
missing default_chain rejected at parse time
malformed JSON refresh returns False, table unchanged

The last row is the one that most often lacks a test and most often matters. Asserting that a failed refresh leaves the previous table intact is the difference between a validated design and a hopeful one.

Operational Notes

Refresh on a timer rather than on a signal. A polling refresh of a small document every minute is negligible, it needs no coordination, and it means a change propagates to every worker within a bounded window without any of them having to be told.

Log the version on every swap, including when the version number is unchanged. A table that silently stops refreshing — a permissions change, a moved file — looks identical to a table that has simply not been edited, and the log line is what distinguishes them.

Finally, expose the current version as a metric. When accuracy differs between two workers, the first hypothesis is that they are running different tables, and a gauge answers that in seconds rather than by inspecting processes.

One Table, Many Workers

Propagation across workers A shared routing document is polled by four workers on a one-minute timer. Immediately after an edit, three workers hold version fifteen and one still holds fourteen. Within one refresh interval all four have converged, and each exposes its version as a metric so the difference is visible while it lasts. routing document · v15 worker 1 v15 worker 2 v15 worker 3 v14 — refreshes shortly worker 4 v15 Exposing the version as a gauge makes a straggler obvious; without it, the difference presents as unexplained routing.

Edge Cases and Failure Modes

Country codes that are not countries. UK is not the ISO code for the United Kingdom (GB is), and EU is not a country at all. Validate against a real code list rather than a shape check if your inputs are user-supplied.

A provider removed while still referenced. Deleting a provider from KNOWN_PROVIDERS without editing the table makes every refresh fail validation. Remove it from the routes first, then from the code.

Chains of length one. Legal, and worth flagging: a country with no fallback will produce unresolved records the moment its single provider has an incident.

Stale tables after a network partition. A worker that cannot reach the document keeps its last good copy indefinitely, which is correct, but the age of that copy should be visible — otherwise a partition presents as unexplained routing differences.

Changing a Route Safely

Editing a row is easy; knowing whether the edit helped is the harder half, and it is worth a short process rather than a judgement call.

Change one market at a time. A version that reorders three countries at once produces an accuracy change that cannot be attributed to any of them, and if the aggregate worsens the only remedy is to revert everything. One row per version is slower and always interpretable.

Give the change a full cycle before judging it. Address mixes vary by day of week, and a routing change evaluated over a weekend can look dramatically better or worse than it is. A week of data at the same volume as the week before is the minimum comparison worth acting on.

Watch the fallback share alongside the rooftop share. A new primary that improves rooftop precision while doubling the share of records answered at chain position one is not the win it appears to be — it is spending more calls to get there, and the cost per resolved address is the metric that reveals it.

Keep the previous version reachable. Reverting is a one-line edit and a refresh, which is the entire operational argument for holding the table as data; a routing change that requires a deployment to undo will not be reverted quickly enough during an incident.

Integration Note

The table feeds the chain walk described in routing geocoding requests by country code in Python, which consumes an ordered chain and consults each provider’s breaker before calling it. The orderings themselves should come from measured per-market accuracy rather than from vendor claims, and re-running that benchmark quarterly is what keeps the table honest as providers improve in different places.