TL;DR
- Integration layers get built carefully on the way in and carelessly on the way out. The vendor's error payload and status code go straight to your consumer, and your boundary dissolves exactly when it matters most.
- It's worse than having no boundary: consumers build against error shapes you don't own, can't version, and will break by accident when the vendor changes them.
- Classify before you translate. Four categories — caller error, contract error, dependency failure, transient failure — and the category picks the status code.
- Most upstream 4xx should become 502, not passthrough. If a vendor rejects your request as malformed, that's a defect in your mapping, not in your caller's input.
- Keep 502 and 504 distinct. Same customer impact, completely different first move at 3am.
- Diagnostic detail goes to logs with a correlation ID, not to the caller. Those are different requirements serving different audiences.
- Watch the AWS-specific traps: the 29-second REST API integration timeout bypasses your error handling entirely, and gateway-level errors never reach your code unless you override
GatewayResponses.- The test: open your API definition. If any endpoint's failure modes can't be enumerated, you don't have an error contract.
Most integration layers I've worked on were built carefully on the way in and carelessly on the way out.
Somebody does the work properly. There are mapper functions translating the vendor's field names into the team's own vocabulary. Nobody downstream ever sees PARTY_ID_EXT or a date formatted as 20260317. The domain model is clean, the translation is tested, and the boundary does exactly what a boundary is for.
Then the vendor returns a 400, and their error payload goes straight to your consumer with their status code on it.
I've now seen this in enough systems to stop treating it as a local mistake. It isn't carelessness — it's the natural consequence of two entirely reasonable decisions meeting, and it takes deliberate effort to prevent.
How it happens
The first decision is a consistent handler shape. Every function funnels its failures into one place:
export const handler = async (event) => {
try {
// ...
} catch (err) {
logger.error(err);
return toHttpResponse(err);
}
};Good practice. Terse, uniform, hard to get wrong.
The second decision is the helper, written early, when the only errors anyone had were HTTP client errors:
export function toHttpResponse(err) {
if (err.response) { // an HTTP client error
return {
statusCode: err.response.status, // the upstream's status
body: JSON.stringify(err.response.data) // the upstream's body
};
}
return { statusCode: err.status ?? 500, body: JSON.stringify({ message: err.message }) };
}That err.response branch is the leak. It's the shape most HTTP clients attach to a failed request — the upstream's actual response — and this helper takes the upstream's status code and its raw body and hands both to your caller.
Two files, both sensible in isolation, and your vendor's error vocabulary is now part of your public API.
What's really happened is that nobody claimed the error contract, so the HTTP client's error object became it by default. The success path had an owner and got designed. The failure path was inherited from a library.
flowchart LR
subgraph S[Success path]
A[Upstream 200] --> B[Mapper<br/>vendor shape → your shape]
B --> C[Consumer sees<br/>your model]
end
subgraph F[Failure path]
D[Upstream 4xx/5xx] --> E[Client throws<br/>upstream response attached]
E --> G[Shared helper]
G --> H[Consumer sees<br/>vendor's model]
endWhy this is worse than having no boundary at all
The easy objection is that errors are exceptional and the happy path is what matters. I'd argue the opposite, for three reasons.
Consumers will build on it. Once an error shape reaches a client, somebody parses it. A mobile developer writes if (err.code === 'PTY_NOT_FND') because that's what they observed in staging. That string came from a vendor. You've never documented it, don't own it, and can't change it — and when the vendor renames it in a minor release, you ship a breaking change you didn't write. You have published an API surface you don't know exists.
It defeats the reason the layer exists. The entire point is that an upstream can change without the rest of the world caring. A leaking failure path means a vendor's error-format change becomes client-visible. You built the thing specifically to prevent that, and it doesn't — on the path where things are already going wrong and your blast radius is widest.
Your status codes stop carrying information. This is the one that costs real operational time. A 401 from the upstream — caused by your service credentials expiring — arrives at the consumer as though their authentication failed. They re-authenticate, fail again, and open a ticket. Meanwhile the actual fault is a secret you need to rotate, and nothing in the response says so.
There's a security dimension too. Upstream error bodies routinely carry internal hostnames, stack traces, SQL fragments and connection strings. Whether that reaches an external caller should be a decision. When you pass errors through, it's a default.
Classify before you translate
The fix isn't a bigger switch statement. It's deciding what categories of failure your API actually has, and there are only four that matter:
| Category | Whose fault | What the caller should do | |---|---|---| | Caller error | Theirs | Fix the request | | Contract error | Yours | Nothing — you have a bug | | Dependency failure | Neither | Retry later, or escalate | | Transient failure | Neither | Retry now, with backoff |
Almost every error in an integration layer lands in one of those, and the category determines the status code. Once you have the taxonomy, translation is mechanical:
export function translate(err, dependency, { exposesResourcesDirectly = false } = {}) {
// No response at all: connection refused, DNS, socket timeout.
if (!err.response) {
return new DependencyError(`${dependency} is unreachable`, { status: 504, retryable: true });
}
const upstream = err.response.status;
// Their auth rejected OUR credentials. Never the caller's problem.
if (upstream === 401 || upstream === 403) {
return new DependencyError(`${dependency} rejected this service's credentials`, { status: 502 });
}
// Genuine "not found" — the only case where passthrough is semantically honest.
if (upstream === 404 && exposesResourcesDirectly) {
return new NotFoundError(`Not found in ${dependency}`, { status: 404 });
}
// Before the general 4xx branch, or it never runs — 429 is a 4xx.
if (upstream === 429) {
return new DependencyError(`${dependency} is rate limiting`, {
status: 503, retryable: true, retryAfter: err.response.headers['retry-after']
});
}
// They rejected what we sent. That's a defect in our mapping, not their input.
if (upstream >= 400 && upstream < 500) {
return new ContractError(`${dependency} rejected the request`, {
status: 502,
detail: summarise(err.response.data) // logged, not returned
});
}
return new DependencyError(`${dependency} failed`, { status: 502, retryable: true });
}Two things about that function are worth more than they look. exposesResourcesDirectly is an option, not a property of dependency — dependency is a name, used for messages and metric dimensions, and quietly making it an object in one branch is how you get an undefined that silently disables a case. And ordering is load-bearing: 429 is a 4xx, so it has to be tested before the general 4xx branch or it never executes. Both are the kind of thing that reads fine and behaves wrong.
Three of those decisions are worth defending, because they're the ones people argue with.
Upstream 4xx becomes 502, not passthrough. If a vendor says your request is malformed, that's a defect in your mapping. Returning 400 tells the caller to fix something they didn't get wrong, and they'll spend an afternoon on it. 502 Bad Gateway means "I could not get a usable answer from something I depend on," which is precisely true.
504 for no-response, 502 for bad-response. The distinction matters to anyone reading a dashboard. A rising 504 rate points at network, DNS, or a dependency that's stopped answering. A rising 502 rate points at a contract mismatch — usually something you deployed. Same customer impact, completely different first response at 3am. Collapsing both into 500 throws away your most useful triage signal.
429 becomes 503 with Retry-After. Passing 429 straight through tells your caller they are being rate limited. They aren't — you are. 503 with a Retry-After header is honest and gives well-behaved clients something to act on.
And the detail doesn't vanish. It goes into the log, with a correlation ID, where operators need it. "Preserve diagnostic detail" and "return diagnostic detail to the caller" are different requirements serving different audiences, and conflating them is the root of most of this.
Which leaves the helper this article opened with. Here it is repaired:
export function toHttpResponse(err, correlationId) {
const status = err.status ?? 500;
return {
statusCode: status,
headers: err.retryAfter ? { 'Retry-After': err.retryAfter } : {},
body: JSON.stringify({
error: {
code: err.code ?? 'INTERNAL_ERROR',
message: err.publicMessage ?? 'The request could not be completed',
correlationId
}
})
};
}The fix is a deletion. The err.response branch — the one that read the upstream's status and body and handed both to your caller — is gone, and nothing replaces it. translate runs at the point where a dependency call fails, which is the only place that knows which dependency failed; by the time an error reaches this helper it has already been classified, so there is nothing left for the helper to decide.
That's the shape of the whole fix. The original helper was doing classification badly because it was the only thing in the path willing to do it at all. Move classification to the boundary that has the context, and the response layer stops needing an opinion.
Note also that retryAfter, captured in translate, now actually reaches the caller. In the original it was recorded and dropped — the 503 arrived with no indication of when to try again, which is most of what makes a 503 useful.
Making it debuggable
Translation without correlation just moves the problem. If a caller gets 502 Bad Gateway and nothing else, your support conversation starts with a timestamp and ends in a log search.
Put an identifier in both places:
{
"error": {
"code": "DEPENDENCY_FAILED",
"message": "The policy service is currently unavailable",
"correlationId": "01JC8Z3K4M9QW2X7"
}
}That same value goes on every log line for the invocation and on every outbound request header. On AWS you get a chunk of this for free — the X-Amzn-Trace-Id header propagates through API Gateway into Lambda, and if X-Ray tracing is enabled, subsegments for downstream calls come with the SDK. But X-Ray gives you your trace ID, not the caller's, and it won't be in the response body unless you put it there. The one-line version — generate an ID at the edge, log it, propagate it, return it — is worth more than most observability tooling because it turns "it broke this morning" into a single query.
Three AWS-specific details that catch people out here:
REST APIs have a hard 29-second integration timeout. If your Lambda timeout is 30 seconds, the gateway gives up first and the caller gets a gateway-generated 504 that never reaches your error handling. Your careful taxonomy is bypassed entirely. Set the function timeout below the integration timeout — and if the work genuinely takes longer, that's a signal it should be asynchronous, not that you need a longer timeout.
Gateway-level errors bypass your Lambda completely. Authorizer denials, request validation failures, throttling. Those produce a default JSON shape that looks nothing like the one you designed. GatewayResponses in your template lets you override them so the error contract holds at the edge as well as in your code. Almost nobody does this, and it's why so many APIs have two visibly different error formats.
Your retryable flag has consequences downstream. If the caller is an SDK, a 500-series response may be retried automatically with backoff — which is what you want for a genuine transient failure and emphatically not what you want if the operation wasn't idempotent. Classifying an error as retryable is a promise about the safety of repeating the request, and it should be made deliberately.
When passthrough is the right answer
I don't want to leave this as an absolute, because there's a legitimate case.
If an endpoint is an explicit, documented proxy — you're exposing a vendor's API through your gateway for authentication, routing, and network access, and consumers know they're talking to that vendor's contract — then transparent errors are correct, and translating them would be actively unhelpful. The boundary you've drawn is a network boundary, not a semantic one.
The trouble starts when both kinds of endpoint share one error helper. One implementation, two intents, and no way to tell from a call site which one you're getting. If you have both — and most integration layers do — the difference should be visible in the code, not left to whoever last edited the shared function.
So the question to ask of every endpoint is: is this a proxy or a product?
A proxy forwards someone else's contract, and says so in its documentation. A product exposes yours, and every failure a caller can encounter is one you named, with a status code you chose, in a shape you'd be willing to put in an OpenAPI spec.
Which is the test I'd actually apply. Open your API definition and look at the documented responses. If any endpoint's failure modes can't be enumerated because the honest answer is "whatever the upstream returns", you don't have an error contract — you have an error passthrough with a translation layer bolted onto the happy path.
Anyone can map a success response. The boundary is only real if it holds when something breaks, and in an integration layer, something upstream is always breaking somewhere.