# 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.

Source: https://voxgig.com/howto/translate-upstream-errors-behind-your-api

- Audience: platform-team
- Level: advanced
- Verified: 2026-09-24
- Published: 2026-09-24

## 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](https://www.rfc-editor.org/rfc/rfc9110.html#name-502-bad-gateway) for an invalid response
from an inbound server, [503](https://www.rfc-editor.org/rfc/rfc9110.html#name-503-service-unavailable)
for a temporary overload, and [504](https://www.rfc-editor.org/rfc/rfc9110.html#name-504-gateway-timeout)
for a timely response that never arrived.[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [502 wrapper with a cause extension](https://www.rfc-editor.org/rfc/rfc9457.html#name-extension-members) | Internal callers who debug with the upstream body in front of them | One status for every upstream failure, so a client cannot tell an outage from a bad request, and `cause` can carry secrets | Callers outside your organization |
| [Mapping into your own catalog](https://www.rfc-editor.org/rfc/rfc9457.html#name-type) | Callers who need a contract that survives a change of provider | A mapping table per upstream to maintain, and a log to hold the detail you no longer return | A prototype with one internal caller and no catalog yet |
| [Pass-through](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_intercept_errors) | A proxy whose whole job is to forward, with no contract of its own | The provider's contract becomes yours, its 401 included, and it changes whenever theirs does | Your 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.

```ts title="gateway.mjs"
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`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static),
which rejects the fetch with a `TimeoutError`, and Node ships the same static method as a
[global](https://nodejs.org/api/globals.html). 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.

```ts title="translate.mjs"
  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`](https://www.rfc-editor.org/rfc/rfc9110.html#name-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`.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test translate.test.mjs
```

```text output
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.

## Related how-tos

- [Evolve an error contract without breaking clients](/howto/evolve-an-error-contract-without-breaking-clients)

- [Add a circuit breaker to an outbound HTTP client](/howto/add-a-circuit-breaker-to-an-http-client)

- [Time out fetch calls with AbortSignal in Node.js](/howto/time-out-fetch-with-abortsignal-in-node)

## 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.

[^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](https://www.rfc-editor.org/rfc/rfc9110.html#name-502-bad-gateway) describes a
narrower thing than the status it defines is used for.

[^2]: A `Retry-After` value is either a number of seconds or an HTTP-date, and
[RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#name-retry-after) 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.

[^3]: Amazon API Gateway keeps a
[list of gateway response types](https://docs.aws.amazon.com/apigateway/latest/developerguide/supported-gateway-response-types.html),
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.