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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| 502 wrapper with a cause extension | 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 | 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 | 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.
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.
Related how-tos
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
-
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
-
A
Retry-Aftervalue 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 -
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_FAILUREandINTEGRATION_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