Mehdi Akiki
Published on

Don't turn every provider 429 into a customer 500

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Article · Through the layers

“Something went wrong” is a poor explanation when the application knows the provider asked it to slow down.

Blindly returning the provider's 429 is not always better. The provider may be limiting your shared service account, while the customer has used almost none of their own allowance.

The adapter needs to translate the failure into the contract your API offers. That requires knowing whose limit was reached and whether any work was accepted.

Separate the situations before choosing a status

A customer-specific allowance can justify a customer-facing rate-limit response. A shared provider quota may be temporary service unavailability. A durable job already accepted by your system may remain pending instead of turning into a failed submission.

This Rust model keeps those decisions separate:

enum LimitScope {
    Customer,
    Shared,
    Unknown,
}

It is an application model, not a claim that every provider supplies these categories. If the scope is unknown, preserve that uncertainty internally and use a conservative documented public outcome.

The HTTP specification for 429 allows a Retry-After field. It does not tell your adapter that the provider's quota belongs to your customer.

Likewise, a shared-limit response should not blame the user or tell them to upgrade a plan unless that is actually the cause.

Translate retry advice carefully

Parse the provider's retry instruction using its documented format. Retry-After can represent a delay or an HTTP date. Account for the operation's remaining deadline and for missing or unreasonable values.

Do not copy raw upstream headers and bodies into your API response. They may reveal account details or describe a retry policy that does not match the product endpoint.

If the operation is a write, determine whether the provider guarantees that a rate-limited attempt was not accepted. Do not infer that from a status label alone when the provider has a more complicated asynchronous contract.

The provider contract-testing article explains why transport shape and business meaning need separate checks. Include these error responses in the contract fixtures, not only successful JSON.

Keep a retry storm out of the handler

The customer response and internal recovery policy are related but different. An internal queue can wait for shared capacity, while the public API shows pending work. A synchronous endpoint may need to refuse work within its deadline.

The adaptive-concurrency guide covers protecting the upstream path. Retrying immediately in every handler can make a shared quota problem worse.

Test a known customer quota, a shared limit, an unknown scope, and a malformed retry header. Assert the public error category, safe body, and whether the operation remains pending or was never accepted.

There will be cases where a generic server failure is the honest result because the adapter cannot classify the problem safely. Keep that fallback. The useful improvement is to stop treating every upstream response as either an unexplained exception or a public response ready to forward unchanged.

Two identical status numbers can need different advice

The fixture takes a trusted limit classification from the connector configuration. Customer quota produces HTTP 429 with a customer-quota code. Shared capacity produces 503 with a provider-capacity code. An unknown scope also produces 503, but without claiming that the caller can fix a personal quota.

This policy is explicit. A different service may choose another documented public contract. The important part is not to infer customer responsibility merely from a provider's 429 response. Account configuration and provider documentation determine which quota was actually involved.

Retry guidance is checked separately. A decimal value of five becomes five seconds. An HTTP date seven seconds in the future becomes seven seconds. The parser bounds the header to 128 bytes and accepts at most sixty seconds for this interface. Missing, malformed, overflowing, and longer guidance becomes absent rather than an unbounded sleep instruction.

Omitting a large delay is different from telling the caller to retry immediately. The public response still carries the failure code. A real handler should explain its retry policy, and an internal scheduler can retain a longer trusted delay if its operation budget allows it. Do not truncate a large provider wait down to a shorter promise without considering that meaning.

The test also supplies a private provider message and checks that it does not appear in the serialized public results. The code selects a small set of safe fields instead of copying the original body and trying to remove secrets afterward.

The Rust cases use controlled inputs and a fixed clock. They test translation, not a specific vendor's rate-limit semantics. Before using the mapping with a real connector, verify that vendor's documented quota scope and retry rules, then keep representative responses as fixtures.