How-to › Make calls that survive failure

How to translate upstream API errors behind your own API#

Decide what your API returns when a provider it depends on fails, without leaking the provider's status codes, messages, or credential problems to callers.

Audience
Platform team
Level
advanced
Topic
Handle and design API errors
Verified

The carrier your shipping API calls rejects your key, and your caller sees a 401 saying the API key is invalid. They rotate their own credential, which was fine, and open a ticket. The carrier’s error text is in their logs and their support thread, and your API has told them something untrue about their own access.

What you get

You will end up with a translation layer that turns each class of upstream failure into a status and code of your own, tested against a stub that produces every class. This is for you if your API calls a third-party API on the request path and your callers can see the difference.

Short answer

Map every upstream failure into your own error catalog and log the provider’s detail rather than returning it. An upstream 401 or 403 is your 502, because the broken credential is yours. A timeout is a 504, a 429 or 503 is your 503 with Retry-After copied, and a body that is not JSON is a 502. Only a rejection you recognize becomes the caller’s 4xx.

You will need

Node 22 or later, an API that calls a third-party API on the request path, and an error catalog of your own to map into. The three gateway statuses are defined in RFC 9110: 502 for an invalid response from an inbound server, 503 for a temporary overload, and 504 for a timely response that never arrived.1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
502 wrapper with a cause extensionInternal callers who debug with the upstream body in front of themOne status for every upstream failure, so a client cannot tell an outage from a bad request, and cause can carry secretsCallers outside your organization
Mapping into your own catalogCallers who need a contract that survives a change of providerA mapping table per upstream to maintain, and a log to hold the detail you no longer returnA prototype with one internal caller and no catalog yet
Pass-throughA proxy whose whole job is to forward, with no contract of its ownThe provider’s contract becomes yours, its 401 included, and it changes whenever theirs doesYour API promises its callers anything at all

Pass-through is the most transparent for debugging and the least stable, because every provider change is a change to your contract. It is also what a reverse proxy does unless told otherwise: nginx documents proxy_intercept_errors off as the default, and every upstream status from 300 up goes to the client. The wrapper keeps the transparency and costs the caller the ability to act, since a 502 that wraps a 400 and a 502 that wraps a 503 look alike from outside. The catalog is the only one of the three where the caller’s view is yours to keep stable. It is also the one that makes you write down what each upstream failure means.

Describe the upstream failure in one shape

Everything the translators need to know about the upstream call fits in one object, which is the only part of the upstream that reaches them.

export async function callUpstream(url, { timeoutMs = 300 } = {}) {
  let res
  let text
  try {
    res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
    text = await res.text()
  } catch (err) {
    // The signal covers the body as well as the headers, so a body that stalls
    // is a timeout too. Any other rejection, a refused connection, a failed
    // lookup, a dropped socket, means the upstream could not be reached.
    const timedOut = err.name === 'TimeoutError'
    return { timedOut, unreachable: !timedOut, status: null, headers: {}, json: null, text: '', error: err.name }
  }
  const contentType = res.headers.get('content-type') ?? ''
  let json = null
  if (contentType.includes('json')) {
    try { json = JSON.parse(text) } catch { json = null }
  }
  return {
    timedOut: false,
    unreachable: false,
    status: res.status,
    headers: { 'retry-after': res.headers.get('retry-after') ?? undefined },
    json,
    text,
  }
}

The body is parsed only when the media type says it is JSON, and a parse failure leaves json null rather than throwing. A provider’s CDN answering with an HTML error page is one of the failures this page is about. The timeout comes from AbortSignal.timeout, which rejects the fetch with a TimeoutError, and Node ships the same static method as a global. The body is read inside the same try, because the signal covers it too: a body that stalls after the headers is a timeout, not a throw. Any other rejection, a refused connection or a failed lookup, comes back as unreachable, so the function never throws. Only Retry-After is kept from the upstream headers. The rest, including any Set-Cookie or WWW-Authenticate, stays behind.

Map into your catalog and log the rest

The mapping is a short function with one rule per class of failure, and the order of the rules is part of the design.

  const detail = up.json ?? up.text.slice(0, 120)
  log.push({ upstream_status: up.status, upstream_body: detail })

  if (up.timedOut) return { status: 504, body: problem('upstream_timeout', 504, 'The carrier did not answer in time', { retryable: true }) }
  if (up.unreachable) return { status: 502, body: problem('upstream_unreachable', 502, 'The carrier could not be reached', { retryable: true }) }
  // A 2xx passes through only when its body parsed as JSON. One that did not falls to the rules below.
  if (up.status < 400 && up.json !== null) return { status: 200, body: up.json }

  // The provider rejected our credential. That is our outage, never the caller's 401.
  if (up.status === 401 || up.status === 403) {
    return { status: 502, body: problem('upstream_auth', 502, 'The carrier rejected this service, and the operators have been told') }
  }
  if (up.status === 404) {
    return { status: 404, body: problem('shipment_not_found', 404, 'No shipment has that id') }
  }
  if (up.status === 429 || up.status === 503) {
    return {
      status: 503,
      headers: { 'retry-after': up.headers['retry-after'] ?? '5' },
      body: problem('upstream_busy', 503, 'The carrier is not accepting requests right now', { retryable: true }),
    }
  }

The log line comes first, before any branch, so every upstream failure is recorded with its body whatever the caller ends up seeing. A timeout is a 504 and an upstream that could not be reached is a 502, both marked retryable, and a 2xx passes through only when its body parsed as JSON. A 401 or 403 from the provider means your credential or your plan is the problem. The caller gets a 502 with a code that says the operators know, instead of anything about keys. A 404 is the one upstream status that can be the caller’s own: they asked for a shipment by id and the carrier has none. A 429 or 503 becomes your 503 with the provider’s Retry-After copied through, because the delay the carrier asked for is the delay your caller has to respect too.2

The rules after those handle the rest. A body that is not JSON is a 502 of its own, whatever the status in front of it. A 4xx whose provider code is in your known-rejections table becomes the caller’s 422 under your name for it. The table is read with Object.hasOwn, so a provider code that happens to name a method on Object.prototype is not a match. Any other 4xx or 5xx is a 502 with retryable set from the class.

Check it worked

Every failure class through the three strategies, against a stub upstream that answers each path the way a real provider would, and never answers /slow.

node demo.mjs
path           upstream  passthrough                            wrap502                                catalog
/ok            200       200 -                                  200 -                                  200 -
/unauthorized  401       401 invalid_api_key                    502 upstream_error                     502 upstream_auth
/forbidden     403       403 plan_limit                         502 upstream_error                     502 upstream_auth
/missing       404       404 not_found                          502 upstream_error                     404 shipment_not_found
/rejected      400       400 invalid_postcode                   502 upstream_error                     422 address_invalid
/throttled     429       429 rate_limited wait=7s               502 upstream_error wait=7s             503 upstream_busy wait=7s
/broken        500       500 internal                           502 upstream_error                     502 upstream_error
/down          503       503 maintenance wait=30s               502 upstream_error wait=30s            503 upstream_busy wait=30s
/html          502       502 non-json                           502 upstream_error                     502 upstream_invalid_response
/slow          timeout   504 upstream_timeout                   504 upstream_timeout                   504 upstream_timeout

the upstream 401 under catalog: what is logged, and what the caller gets
logged   {"upstream_status":401,"upstream_body":{"error":"invalid_api_key","message":"Invalid API key provided: cr_live_4f...9a"}}
returned {"type":"https://api.example.com/problems/upstream_auth","title":"The carrier rejected this service, and the operators have been told","status":502,"code":"upstream_auth"}

Read the columns as three contracts. The pass-through column is the carrier’s contract with the carrier’s names on it, and the 401 invalid_api_key row is the failure from the top of the page. The wrapper column is one code for everything, which is why the /rejected and /down rows look the same to a client. The catalog column has your names, keeps the 404, promotes one 400 you recognize to a 422, and carries the wait on the two rows where the carrier asked for one. The last two lines show the split that matters: the key fragment is in the log and not in the response.

node --test translate.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 424.010571

The first test asserts the rule the page is named for, that no upstream 401 or 403 becomes yours. The second asserts that pass-through does forward it, so the suite fails if the stub stops producing the failure. The last two cover the failures the stub’s table cannot show. A body that stalls after the headers is a 504, and an upstream that refuses the connection is a 502 rather than a 200 with no body.

When it goes wrong

Your caller reports that their credential stopped working, and it did not. An upstream 401 was forwarded, or was mapped to your own 401 by someone who matched on the number. Map 401 and 403 from any provider to a 502 with a code of your own, and page yourself.3

The provider changes a status and your clients break. They were reading the carrier’s codes through your pass-through, and the carrier retired one. Under a catalog the change lands in your mapping table and in the log, and the contract your clients read stays where it was.

Your caller’s JSON parser throws on your response. The provider’s edge answered with an HTML page, and the body went through. Parse only when the media type says JSON, and answer a 502 of your own when it does not.

Your handler answers 500 with a stack trace of its own. The upstream body was parsed unconditionally and the HTML was not JSON. The gateway function never throws on a body, so the translation runs on every failure class, including the ones the provider never documented.

When not to do this

Do not forward an upstream 401 or 403, and do not map them to your own. Both statuses tell your caller that their credential or their permission is the problem, and in this case neither is. They are a 502 under a code that names the upstream.

Do not return the provider’s body to a caller outside your organization, and not inside a cause member either. Provider messages carry key fragments, internal host names, and stack frames, as the /unauthorized and /broken rows show, and a cause extension is a body with a different label.

Do not map every upstream 4xx to a 4xx for the caller. A 400 from the provider means your request was rejected, and whether that is the caller’s fault depends on what you forwarded. Promote only the rejections you have looked at, under your own code, and treat the rest as your 502.

Do not drop the retry signal. A caller that gets a 502 with no Retry-After and no retryable member has to guess, and a guess at a 503 that was really a rejected request is a retry loop.

Last verified

Verified 2026-09-24 against Node 22.22.2. Both output blocks are what the preceding command printed. The upstream is a stub on the loopback interface with one route per failure class, standing in for a real provider.

Footnotes

  1. RFC 9110 defines 502 as the status for a gateway that received an invalid response from an inbound server, and the word is invalid, not unsuccessful. A well-formed 500 from the provider is a valid HTTP response. The specification does not say what a gateway should return in that case, and practice settled on 502 anyway. So section 15.6.3 describes a narrower thing than the status it defines is used for. ↩︎ Back to text

  2. A Retry-After value is either a number of seconds or an HTTP-date, and RFC 9110 permits both. Copying the seconds through is safe. Copying a date through is safe only if your gateway adds no time of its own. A date fixed by the provider does not move while your service is slow to forward it. A caller reading it after the moment has passed is told to retry at once. ↩︎ Back to text

  3. Amazon API Gateway keeps a list of gateway response types, and two of them are the failure classes on this page under other names: INTEGRATION_FAILURE and INTEGRATION_TIMEOUT. Both default to 504, so out of the box a backend that answered wrongly and a backend that never answered get the same number. The list exists so that you can change that, which is this page reduced to a table of constants. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.