Provider Router

A small library for calling one capability through several interchangeable providers: try them in a preferred order, fail over on rate limits and transient errors, and normalize every provider’s output to one contract.

Where it came from

Vacation Price Tracker gets flight prices from three sources: the Skiplagged MCP, the Kiwi.com MCP, and a Google Flights scraper. In July 2026 Skiplagged’s flight backend started returning sustained 429s. The fix at the time was a database setting and a human: notice the refreshes failing, flip flight_provider to kiwi, move on.

That worked, and it was obviously the wrong shape. The knowledge that “if this one is throttled, ask that one” was in my head and in a runbook, not in the code.

The first instinct was to write the fallback inline. The second, more useful instinct was noticing I had already written it somewhere else. Showbook geocodes venues with Google Places, and falls back to OpenStreetMap’s Nominatim when Google can’t answer. Same shape. Different domain, different language, hand-rolled twice.

The part that isn’t a list

Ordered failover sounds trivial, and the ordering part is: it’s an array. The part that isn’t trivial is that every provider says “I’m rate limited” in a different language:

ProviderHow it says rate limited
One APIHTTP 429 with a Retry-After header
AnotherHTTP 200, and the payload says "rate limit exceeded"
A scraperan unparseable block page
A geocoderHTTP 200, and the field you needed is just missing

A router can’t parse those; it doesn’t know the domain. Your adapter can. Translating each provider’s dialect into one shared vocabulary turned out to be the actual content of the library. Everything else follows from having it.

So the library ships no adapters, on purpose. An adapter encodes your providers and your failure modes; a catalog of them would mean a dependency per provider and a package that ages at the speed of other people’s APIs. What ships is the contract adapters implement, the router that drives them, and a conformance test that holds them honest.

Two ideas worth stealing even if you never use the library

“The call returned 200” is not “the provider answered well.” A geocoder can return a perfectly valid 200 with no latitude or longitude. A search can union several upstream queries, lose one, and return a well-formed result that silently omits an entire category. Both are successes by every transport measure and useless to the caller. So results are graded OK or DEGRADED, and a degraded answer is kept but not settled for. The router holds it while it keeps looking for a good one, returns it only if nothing better appears, and always labels it.

Declining is not failing. If a provider can’t honour a constraint in the request, the useful response is to decline rather than answer a different question. A flight search that quietly drops your cabin-class filter hasn’t failed. It has changed what the price is a price for, and nothing downstream can tell. That distinction has its own outcome type.

There’s a third, learned the expensive way: a spend ceiling aborts the whole route rather than failing over. Trying the next provider after a daily budget trips spends more against the ceiling that just tripped. Failure that amplifies itself is the one kind worth special-casing.

Building it twice, on purpose

I needed it in Python (the flight tracker) and TypeScript (Showbook), so there are two implementations. Two implementations of one contract drift. Not maliciously, just because someone fixes an edge case in whichever language they’re in that day.

The thing that stops it is a file of shared test vectors: twenty routing scenarios as data, provider behaviour in and expected decisions out, read by both test suites. A behavioural change lands in both ports or CI goes red in one.

It earned its place on the first run. The TypeScript router guarded against an adapter whose error-classifier returns nothing; the Python one raised an AttributeError from inside the router, the kind of bug that reads as a router fault and sends you to the wrong file entirely. I’d written both within an hour of each other and had no idea they disagreed.

How it’s used

Vacation Price Tracker routes flight searches through it. The flight_provider setting stopped being “the provider” and became “the provider we prefer”: a Skiplagged 429 now moves to Kiwi automatically. Only the overnight tracking sweep opts in, though. That runs unattended, so a failed refresh is a missing point in a history nobody is watching. The interactive chat search doesn’t, because a user is right there and can simply ask again. Different failure economics, different default.

That change needed a prerequisite I didn’t expect. Snapshots record which provider priced them, but the notification logic compared each new price to the previous one without looking at the provider. Three sources see different inventory, so switching between them changes the number for reasons that have nothing to do with the market. An “alert me on any drop” rule would have emailed about a fare that never moved, and automatic failover would have made that happen without anyone deciding to. The comparison is now suppressed across a provider change.

Showbook routes venue geocoding through it. What’s left of that file is the part that’s actually about geocoding; the ordering, the failover, and the one-call-per-second politeness Nominatim’s usage policy requires all come from the library. Two behaviours that had been implicit got names: Nominatim results are DEGRADED (coordinates, but no Place ID and no photo, both of which callers want), and the rate limit is per-provider pacing rather than a module-level timestamp every caller shared whether or not it was about to hit Nominatim.

Two behaviours are new rather than relocated. A repeatedly failing Google now stops being called at all instead of being asked again every time. And a 429 carrying Retry-After opens the circuit for exactly that long on the first refusal. It’s a public endpoint with a usage policy, and making it say no three times before listening was never defensible.

What I’d tell myself at the start

The interesting part of this library is not the failover. It’s the vocabulary: OK, DEGRADED, RATE_LIMITED, TRANSIENT, TERMINAL, UNSUPPORTED, BUDGET. Once providers are forced to describe themselves in it, a lot of previously invisible failure becomes something you can act on.

I found that out backwards: I set out to write a fallback list and discovered the list was the easy half.

Related: MCP Budget Governor came out of the same repos and the same instinct — notice the thing you’ve written twice, and give it a name.