Public API error middleware correctly returns HTTP status codes
n8n's public API now returns the correct HTTP status codes for domain errors — NotFoundError returns 404, ForbiddenError returns 403 — instead of defaulting to 400. A new error classification system handles the mapping automatically.
The public API's error handling was returning 400 for nearly everything, even when the error type clearly indicated a different problem. A NotFoundError meant "resource not found" but came back with the same status as a malformed request.
Handlers that throw n8n domain errors like NotFoundError or ForbiddenError now get their actual HTTP status codes back — 404, 403, and so on — instead of the generic 400 that was previously returned. The middleware classifies errors by type (ResponseError, UserError, UnexpectedError, HttpError) and serializes them appropriately for public API consumers.
For unexpected errors and plain exceptions, the system now returns 500 with a generic message ("Internal server error") rather than leaking internal details. This is a security and API correctness improvement. The execution retry logic also updated to throw ConflictError instead of UnexpectedError for finished executions, ensuring that scenario returns 409 instead of 500.
The change is scoped to unhandled errors — handlers that catch exceptions and call res.status().json() themselves are unaffected. In the CLI package, this affects the public API router and the shared error response helper.
View Original GitHub Description
Summary
Replaces the inline error handler in the public API router with a structured middleware that handles:
- n8n domain errors (
ResponseErrorsubclasses likeNotFoundError,ForbiddenError,BadRequestError) — responds with the error's own HTTP status code and message - OpenAPI validation errors (from
express-openapi-validator) — responds with the validator's status code - Unknown errors — responds with 500 without leaking internals
This means public API handlers can simply throw n8n errors and they'll be automatically mapped to the correct HTTP response, instead of needing per-handler try/catch with manual res.status().json() calls.
What has changed?
The middleware runs after the handlers. Handlers that catch errors and call res.status(...).json(...) themselves do not go through this middleware, so those endpoints are not affected by this change (majority of our operations).
This only affects "unhandled" errors — ResponseError subclasses now return the actual status code, and UnexpectedError/OperationalError become 500 instead of 400.
| Thrown value | Before (master) | After (this branch) |
|---|---|---|
ResponseError (e.g. NotFoundError) | 400 | Uses httpStatusCode (e.g. 404) |
ResponseError with httpStatusCode === 400 (e.g. BadRequestError) | 400 | 400 (unchanged) |
UserError | 400 | 400 (unchanged) |
UnexpectedError | 400 | 500 + generic "Internal server error" |
OperationalError | 400 | 500 + generic "Internal server error" |
HttpError (express-openapi-validator) | error.status or 400 | error.status or 400 (unchanged) |
Plain Error / unknown | 400 | 500 + generic "Internal server error" |
How to test
Any public API call that triggers a domain error (e.g. GET /api/v1/workflows/nonexistent-id) should return the correct status code and { message } body.
Related Linear tickets, Github issues, and Community forum posts
https://linear.app/n8n/issue/LIGO-384
Review / Merge checklist
- PR title and summary are descriptive. (conventions)
- Docs updated or follow-up ticket created.
- Tests included.
- PR Labeled with
release/backport(if the PR is an urgent fix that needs to be backported)