Node.js Serverless on AWS Lambda · HTTP APIs on Lambda

Errors, Status Codes and Logs

Why unhandled errors turn into vague 502s for clients, and how to return useful errors while keeping details in logs.

Scenario

Example app: a mobile app calls a Lambda-backed API to update a profile. Validation errors, missing records and database timeouts all happen sometimes.

What you see in production

The mobile team reports that every failure looks the same: HTTP 500 or 502 with {"message":"Internal server error"}. Users cannot tell whether to fix their input or try again. Meanwhile some error responses accidentally include stack traces with internal hostnames.

The naive code

export const handler = async (event) => {
  const input = JSON.parse(event.body);                 // throws on invalid JSON
  if (!input.email) throw new Error("email required");  // becomes a generic 5xx
  try {
    return { statusCode: 200, body: JSON.stringify(await updateProfile(input)) };
  } catch (err) {
    return { statusCode: 500, body: JSON.stringify({ error: err.stack }) };  // leaks internals
  }
};

Reproduce it

Mapping failures to status codesTry it yourself
class HttpError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }

const scenarios = {
  "bad JSON": () => JSON.parse("{oops"),
  "missing email": () => { throw new HttpError(400, "VALIDATION", "email is required"); },
  "not found": () => { throw new HttpError(404, "NOT_FOUND", "profile not found"); },
  "db timeout": () => { throw new Error("connect ETIMEDOUT 10.0.3.17:5432"); },
};

function naive(fn) {
  try { fn(); return { status: 200 }; }
  catch (e) { return { status: 500, body: e instanceof SyntaxError ? "SyntaxError: <parser message>" : `${e.name}: ${e.message}` }; }
}
function mapped(fn, requestId) {
  try { fn(); return { status: 200 }; }
  catch (e) {
    if (e instanceof SyntaxError) return { status: 400, body: { code: "BAD_JSON", requestId } };
    if (e instanceof HttpError) return { status: e.status, body: { code: e.code, message: e.message, requestId } };
    return { status: 503, body: { code: "TEMPORARY", message: "please retry", requestId } };   // details only in logs
  }
}
for (const [name, fn] of Object.entries(scenarios)) {
  console.log(name.padEnd(13), "naive:", JSON.stringify(naive(fn)));
  console.log("".padEnd(13), "mapped:", JSON.stringify(mapped(fn, "req-123")));
}

Output

bad JSON      naive: {"status":500,"body":"SyntaxError: <parser message>"}
              mapped: {"status":400,"body":{"code":"BAD_JSON","requestId":"req-123"}}
missing email naive: {"status":500,"body":"Error: email is required"}
              mapped: {"status":400,"body":{"code":"VALIDATION","message":"email is required","requestId":"req-123"}}
not found     naive: {"status":500,"body":"Error: profile not found"}
              mapped: {"status":404,"body":{"code":"NOT_FOUND","message":"profile not found","requestId":"req-123"}}
db timeout    naive: {"status":500,"body":"Error: connect ETIMEDOUT 10.0.3.17:5432"}
              mapped: {"status":503,"body":{"code":"TEMPORARY","message":"please retry","requestId":"req-123"}}

Root cause

An exception that escapes the handler is treated by API Gateway as a failed integration, which clients see as a generic 5xx. Catch-all handlers that serialise the error object leak internal details. Without a mapping between failure types and HTTP semantics, clients cannot distinguish "your input is wrong" (4xx, do not retry) from "we had a problem" (5xx, retry may help).

The fix

Validate input explicitly, model expected failures as typed errors, map them to status codes in one place, and log unexpected errors with context and a request ID that the client also receives:

import { Logger } from "@aws-lambda-powertools/logger";
const logger = new Logger({ serviceName: "profiles" });

export const handler = async (event, context) => {
  logger.addContext(context);
  try {
    const input = parseProfile(event.body);            // throws HttpError(400) with field details
    return json(200, await updateProfile(input));
  } catch (err) {
    if (err instanceof HttpError) return json(err.status, { code: err.code, message: err.message, requestId: context.awsRequestId });
    logger.error("profile update failed", { error: err });
    return json(503, { code: "TEMPORARY", message: "Please retry", requestId: context.awsRequestId });
  }
};

Use a consistent error format (for example RFC 9457 problem details), return Retry-After for retryable errors where useful, and keep stack traces in logs only.

Trade-offs

  • Catching everything in the handler hides failures from Lambda's own error metrics; emit a custom metric or log at error level so alarms still fire.
  • 503 versus 500: 503 signals a temporary condition (clients may retry); use 500 for bugs to avoid encouraging retries that cannot succeed.
  • For asynchronous event sources, throwing is correct: it triggers the retry policy. The HTTP mapping is for synchronous APIs.

Detect it in production

  • Dashboards of status codes per route and error code; a sudden rise in 5xx after deploys.
  • Log searches by request ID from client error reports.
  • Security scans and tests that assert error bodies never contain stack traces.

Key points

  • Escaped exceptions become vague 5xx responses; map failures explicitly.
  • 4xx for client errors (no retry), 5xx for server errors (maybe retry).
  • Return a request ID; keep stack traces in logs.
  • Keep error metrics visible when you catch errors.

Further reading: Lambda function errors in Node.js · RFC 9457: Problem Details for HTTP APIs

Practice questions

What does the client see if a Lambda handler behind API Gateway throws an unhandled exception?

Answer

A generic 5xx error such as 502 or 500 with a message like 'Internal server error', without any indication of the cause. Map expected failures to explicit status codes inside the handler.

Why should validation errors return 4xx and transient failures 5xx?

Answer

Clients decide whether to retry based on the status: 4xx means the request itself is wrong and retrying will not help; 5xx (especially 503) signals a server-side or temporary problem where a retry may succeed.

If the handler catches all errors and returns a response, what monitoring problem appears?

Answer

Lambda records the invocation as successful, so its Errors metric and alarms no longer reflect failures. Log at error level and emit custom metrics (for example with Powertools Metrics) so alarms still work.

Open in the app to mark lessons complete, take notes and track your progress.