
The Hidden Cost of Multi-Provider AI: Why HTTP Status Codes Are Lying to You
When your AI gateway routes between OpenAI, Anthropic, and Gemini, the hard part isn't calling the APIs—it's making sense of their completely different failure modes. A lesson from the trenches on building an error model that saves your sanity.
I came across a dev.to post this week that perfectly captured a frustration I've felt but never articulated this cleanly. The author, building an API gateway that routes between OpenAI, Anthropic, and Gemini, discovered that the real complexity wasn't in integrating the APIs themselves. Calling each provider was, in their words, "maybe an afternoon of work each."
The hard part showed up later, the first time something went wrong.
And that's the thing about building on top of external APIs—especially when you're orchestrating across multiple providers that are supposed to be doing roughly the same job—the failure modes are where the abstraction leaks hardest.
The trap of the naive error handler
The initial error handling looked like what most of us would write on a first pass. You wrap your API call in a try-catch, grab whatever the provider throws, forward the status code and message, and move on:
} catch (error) {
res.status(error.status || 500).json({ error: error.message })
}Clean. Obvious. And completely wrong once you look at what each provider actually sends back when something breaks.
OpenAI wraps its errors in an object with a type field and sometimes a code. Anthropic uses its own type field that means something entirely different. A 429 from one provider might mean "you're sending too fast, back off"—a classic rate limit. The same 429 from another context might mean something closer to "we're out of capacity right now, this isn't really about your rate at all."
If you're just passing through error.status and error.message, none of that nuance survives. Worse, your own error handling becomes provider-specific whether you meant it to be or not, because the shape of the failure is different depending on who you called. You start writing code that says "if the provider was OpenAI, parse the error like this; if it was Anthropic, parse it like that." And that's how you end up with spaghetti—conditional logic that has nothing to do with your business rules and everything to do with someone else's inconsistent API design.
What the builder did instead
Instead of trusting each provider's raw error shape, they normalized every failure into a single internal error model before it hits the response:
} catch (error) {
const classified = classifyProviderError(error)
res.status(classified.httpStatus).json({
error: 'AI provider error. Please try again.',
error_class: classified.error_class,
provider: classified.provider
})
}The key is error_class, a small fixed set of categories: rate_limited, overloaded, quota_exceeded, invalid_request, authentication_error, server_error. These are true regardless of which provider actually failed. The raw provider error still gets logged for debugging, but what the caller sees is the category of failure, not the provider's specific wire format.
This doesn't automatically retry anything. That's important. Retry logic itself is outside this model. What this does is make "should I retry, and how" a decision you can make once, based on error_class, instead of once per provider you happen to be routing through that day.
The real insight isn't about error handling
The part that surprised the author—and what resonated with me—wasn't that providers disagree on 429 semantics. That's almost expected. It's that we spend so much time evaluating AI providers on capability: reasoning benchmarks, context windows, model strengths. But once you're in production, what eats your time is the dumber stuff. Two APIs disagreeing about what a 429 even means. Rate limiting that behaves differently under the same HTTP status code.
This maps to a broader pattern I've seen across API integrations in general, not just AI. When you consume external services, the happy path is always the easy part. Any well-documented REST API can be called in an afternoon. The hard part is everything that happens when the happy path falls apart.
Authentication tokens expire mid-session. A provider's load balancer returns a 503 but the underlying service is fine, so retrying immediately works—except sometimes it doesn't, and you hammer a degraded service with retries. Quota limits hit at 11:47 PM on a Saturday and your alerting only fires on HTTP status codes, not on semantic error classes, so it takes you an hour to realize it wasn't a server error at all.
The AI-gateway post articulates a specific version of this:
"Are you branching your error handling on the provider, or on the actual failure type? Those aren't the same question, and the difference only shows up once something breaks in production."
That distinction is everything. Provider-based branching is a sign that you've outsourced your error semantics to someone else's API design. Failure-type branching means you've internalized the semantics and mapped the provider's idiosyncrasies onto your own model. That's the difference between a brittle integration and a resilient one.
What this means for anyone building on top of LLM APIs
If you're building anything that calls more than one LLM provider—or even just one, but you're planning for the possibility of adding another—this lesson scales. The error model isn't an optional abstraction layer you add later when things get messy. It's the thing you build before you write your first real business logic, because once you have provider-specific error handling scattered through your codebase, refactoring it out becomes a game of whack-a-mole.
And the error model doesn't need to be fancy. A handful of well-defined error classes and a mapping function per provider is all it takes. The value comes from having a single place where you decide what a failure means, instead of discovering it each time a new provider throws a status code you haven't seen before.
There's a deeper principle here that goes beyond AI. Whenever you're consuming an external service, the most important abstraction you can build is the one that protects your own code from someone else's inconsistencies. Not their data format—that's easy. Their failure modes.
Because when a provider's error response changes—and it will, silently, in a minor version bump that you didn't catch in the changelog—you want that change to ripple to exactly one file in your codebase, not to every handler that touches that provider.
The gateway pattern isn't about adding complexity
The post mentions that this error handling eventually became part of a larger project called Apiarium, an AI gateway. It's easy to roll your eyes at yet another abstraction layer when all you wanted was to swap between a few models. But what I find valuable here is that the gateway wasn't born from architectural ambition—it was born from pain.
"Not because I wanted another abstraction layer. Because I got tired of writing provider-specific error handling."
That's the right reason to abstract. Not because a book told you to, not because it looks clean on a diagram, but because you found yourself doing the same provider-specific work over and over and realized that the work itself was unnecessary. The abstraction removes something, rather than adding a new layer to manage.
When I look at the state of AI tooling today, I see a lot of projects that abstract too early. They add an orchestration layer before they've even hit a production incident with a single provider. They don't know what the real failure modes are, so they build abstractions that don't map to anything painful. Those abstractions become maintenance burdens—more code to update each time a provider changes their API, more surface area for bugs, without actually delivering value.
The Apiarium approach is the opposite. It's an extraction, not an invention. The error model came from real breakages, real confusion over what a 429 meant, real frustration at having to write three different error handlers for three providers that are all, at the end of the day, returning text completions. That's how you know the abstraction is honest.
A framework for evaluating any multi-provider integration
This post left me thinking about a simple litmus test I've been applying to my own integrations since reading it. When something goes wrong in production, ask yourself two questions:
- Can I tell, just by looking at my own system's logs, what kind of failure happened—independent of which provider caused it?
- If I swap one provider for another tomorrow, how many places does my error-handling logic need to change?
If the answer to the first question is "no" and the second is "more than one," you've got a leaky abstraction. Not in the fancy Grady Booch sense, but in a practical way: your system's assumptions about failure are coupled to a specific external party's implementation details. That coupling will cost you time, stress, and probably a few weekend incidents when a provider silently changes their error format.
The fix isn't complicated. It's a mapping function and a type definition. But it has to come before you need it, because once you've got provider-specific error handling scattered through fifty request handlers, untangling it without breaking anything is a weekend project in itself.
Where this goes next
The AI provider landscape is only getting more fragmented. More models, more API flavors, more edge-case failure modes that nobody documents. The providers themselves aren't going to standardize on error semantics anytime soon—they're competing on developer experience, but error handling is the last thing anyone brags about in a launch post.
That means the normalization work has to happen on our side. Whether it's a dedicated gateway like Apiarium or just a thin wrapper in your application code, the pattern is the same: define what failures mean on your terms, map the provider's output into that model as early as possible, and let the rest of your system reason about error_class, not about OpenAI's type field or Gemini's status string.
The next time you integrate a new AI provider, don't start with model capabilities or context windows. Start with how it fails. Run it past its rate limits intentionally. Exceed its quota and see what comes back. Because the thing that will actually wake you up at 3 AM isn't whether the model performs well on a benchmark—it's whether you can tell the difference between "back off for 30 seconds" and "you're done until next month" when your production traffic is hitting it.
That's where the real engineering lives. Not in calling the APIs. In surviving them.