Error Handling
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Vapor 4.122.2 on Swift 6.4 (Swift 6 language mode, package tools version 6.3 as in the
official template; Vapor itself needs 6.0), with Fluent 4.13 for the one database-error example, checked on 2026-10-17.
Every Swift listing on this page was compiled in the Swift 6 language mode, and the response bodies quoted below were
captured from those listings running under the VaporTesting harness, in the debug configuration. Vapor 5 is at
5.0.0-beta.4 and is not released; its error types are noted where they differ. Swift’s own error model (throw,
do/catch, typed throws) is covered by Swift Error Handling
and the async side by Async/Await and Tasks; this page is
about how Vapor turns a thrown Error into an HTTP response.
Where it runs in the request
Errors are the other half of the request lifecycle. A request passes through the middleware chain, the router and the
handler; if anything on that path throws (a handler, a Content decoding, a validation, a database call, a middleware
further down the chain, or the router itself when no route matches), the error travels back up the chain until a
middleware catches it and returns a Response. That catcher is, by default, ErrorMiddleware, which every
Application registers at the head of its middleware list, right after RouteLoggingMiddleware. A middleware that sits
outside the error middleware is not protected by it: an error thrown there is not converted into a JSON response, and
the client receives the server’s bare failure response instead. That is why the error middleware must be first.
Throwing from a handler: Abort
Abort is the ready-made error. It is a struct that conforms to both AbortError and DebuggableError:
throw Abort(.notFound)
throw Abort(.badRequest, reason: "The book id must be an integer.")
throw Abort(.unauthorized, headers: ["WWW-Authenticate": "Bearer realm=\"bookshelf\""],
reason: "A bearer token is required.")
Source: Vapor docs: Errors
The initializer in Vapor 4.122.2 is
Abort(_ status:, headers: = [:], reason: = nil, identifier: = nil, suggestedFixes: = [], file:, function:, line:, column:, range:).
The argument order is fixed by the signature: headers comes before reason. The properties are:
| Property | Meaning |
|---|---|
|
the |
|
the human-readable message; defaults to the status’s standard reason phrase ( |
|
extra response headers ( |
|
a machine-friendly name; defaults to the status code as text ( |
|
an |
The file:, function:, line:, column: and range: parameters exist so that source points at the throw
statement; do not pass them. The stackTrace property and the matching initializer are deprecated ("Captured stack
traces are no longer supported by Vapor"). The suggestedFixes: initializer parameter is accepted, but in 4.122.2 the
value is not stored: Abort has no property to keep it, so use your own DebuggableError type to ship suggestions.
For redirects, Abort has a factory that fills Location and picks the status:
Abort.redirect(to: "/login", redirectType: .temporary). The older Abort.redirect(to:type:) is deprecated in
favor of redirect(to:redirectType:) (RedirectType values: .permanent is 301, .normal is 303 and the default, .temporary is 307).
Most code uses req.redirect(to:), covered with the other response helpers.
The status constants come from SwiftNIO’s HTTPResponseStatus: .badRequest, .unauthorized, .forbidden,
.notFound, .methodNotAllowed, .conflict, .unprocessableEntity, .tooManyRequests, .internalServerError, .serviceUnavailable, and so on, or
HTTPResponseStatus(statusCode: 418) for any number.
AbortError: your own errors with a status
Abort is fine for a quick throw. For errors that belong to your domain, conform your own type to AbortError,
which has three members:
public protocol AbortError: Error {
var reason: String { get } // default: status.reasonPhrase
var status: HTTPResponseStatus { get }
var headers: HTTPHeaders { get } // default: [:]
}
Only status is mandatory. The documentation comment states the contract: an AbortError is "always displayed to the
end-user", even in production, where every other error is replaced by a generic message (see below). Anything you put
in reason is therefore public: no SQL, no file paths, no internal identifiers.
Vapor ships conformances you rely on every day: DecodingError is an AbortError with status 400 and a reason that
includes the coding path (see the truncated-JSON capture below), ValidationsError (thrown by validate(content:), see Validation) is 400 with the failure
descriptions as reason, and RouteNotFound is 404.
DebuggableError: hints for the developer
DebuggableError adds diagnostic information. It refines LocalizedError, CustomStringConvertible and
CustomDebugStringConvertible:
| Member | Meaning |
|---|---|
|
names this specific error; the full identifier is |
|
one sentence, ending with a period |
|
optional |
|
arrays of strings, default empty |
|
arrays of URLs as strings, default empty |
|
the |
|
static, default to the type name |
description prints the identifier and reason plus the causes and fixes on one line; debugDescription prints the long
multi-line form with bulleted lists, which report(error:) uses when the log level is trace. When a type conforms to
both protocols, AbortError supplies identifier from the status code, so a type that implements status and
reason only needs identifier when it wants a better name. A complete example, used by the Bookshelf routes of
this page:
import Vapor
/// A domain error: it knows nothing about HTTP.
enum BookError: Error {
case isbnTaken(String)
case outOfStock(title: String)
}
/// An error that carries its own HTTP status and debugging hints.
struct BookNotFoundError: AbortError, DebuggableError {
let id: Int
// AbortError
var status: HTTPResponseStatus { .notFound }
var reason: String { "No book with id \(id)." }
// DebuggableError
var identifier: String { "bookNotFound" }
var possibleCauses: [String] { ["The book was deleted.", "The id was mistyped."] }
var suggestedFixes: [String] { ["List the books with GET /books and use one of the ids."] }
var logLevel: Logger.Level { .info }
}
BookError is deliberately a plain Swift enum with no HTTP vocabulary; the middleware below maps it. BookNotFoundError
is the opposite choice, an error that already knows it is a 404. Both styles are valid: pick the first for errors
raised deep in services that must stay transport-agnostic, and the second for errors raised in controllers.
Because Swift 6 requires thrown errors to be Sendable, an error type with only value-type properties conforms
implicitly; a class-based error needs final and immutable Sendable properties.
The Bookshelf handlers
The routes below throw all three kinds of error. They use an actor store so the closures are Sendable without
locks (see Swift Concurrency in Vapor).
import Vapor
struct Book: Content, Equatable {
var id: Int
var title: String
var isbn: String
}
struct NewBook: Content, Validatable {
var title: String
var isbn: String
static func validations(_ validations: inout Validations) {
validations.add("title", as: String.self, is: !.empty && .count(...200))
validations.add("isbn", as: String.self, is: .count(10...13) && .alphanumeric)
}
}
actor BookStore {
private var books: [Int: Book] = [1: Book(id: 1, title: "Dune", isbn: "9780441013593")]
private var nextID = 2
func find(_ id: Int) -> Book? { books[id] }
func add(_ new: NewBook) throws -> Book {
if books.values.contains(where: { $0.isbn == new.isbn }) {
throw BookError.isbnTaken(new.isbn)
}
let book = Book(id: nextID, title: new.title, isbn: new.isbn)
books[nextID] = book
nextID += 1
return book
}
func remove(_ id: Int) -> Bool { books.removeValue(forKey: id) != nil }
}
func routes(_ app: Application) throws {
let store = BookStore()
let books = app.grouped("books")
books.get(":id") { req async throws -> Book in
guard let id = req.parameters.get("id", as: Int.self) else {
throw Abort(.badRequest, reason: "The book id must be an integer.")
}
guard let book = await store.find(id) else {
throw BookNotFoundError(id: id)
}
return book
}
books.post { req async throws -> Response in
try NewBook.validate(content: req)
let new = try req.content.decode(NewBook.self)
let book = try await store.add(new)
let response = Response(status: .created, headers: ["Location": "/books/\(book.id)"])
try response.content.encode(book)
return response
}
books.delete(":id") { req async throws -> HTTPStatus in
let id = try req.parameters.require("id", as: Int.self)
guard await store.remove(id) else { throw BookNotFoundError(id: id) }
return .noContent
}
app.get("admin") { _ async throws -> String in
throw Abort(.unauthorized, headers: ["WWW-Authenticate": "Bearer realm=\"bookshelf\""],
reason: "A bearer token is required.")
}
app.get("boom") { _ async throws -> String in
struct Unexpected: Error { let secret = "db-password-placeholder" }
throw Unexpected()
}
}
Source: Vapor docs: Routing and Vapor docs: Validation
Three details matter here. req.parameters.get("id", as: Int.self) returns nil for a non-numeric segment, which is
a client mistake, so it becomes a 400; req.parameters.require(_:as:) does the same in one line, as the DELETE handler shows, but its failure
modes are fixed by Vapor: an unparsable value throws Abort(.unprocessableEntity) (422) and a parameter name that the
route does not declare throws Abort(.internalServerError), so use get(_:as:) when you want the 400. NewBook.validate(content:) throws ValidationsError (400) before decoding. The /boom
route throws an error that Vapor knows nothing about, to show the 500 path. Its secret property stands for any
sensitive data an error can carry.
The default ErrorMiddleware
Application.middleware starts with two middlewares: RouteLoggingMiddleware(logLevel: .info), which logs every
request line, and ErrorMiddleware.default(environment:). The default error middleware decides in this order:
-
an error that is both
DebuggableErrorandAbortError, likeAbort: itsreason,status,headersandsource -
an
AbortError: itsreason,statusandheaders -
a
DebuggableErroronly: itsreason, status 500 and no headers -
anything else: status 500; the reason is
String(describing: error)in a debug build and the fixed textSomething went wrong.in a release build
Then it reports the error to req.logger (method, URL and user agent as metadata) and serializes a body with the
global JSON encoder:
{"error": true, "reason": "No book with id 99."}
The body has exactly two fields, error (always true) and reason. The ErrorResponse type behind it is
internal, so you cannot decode it by name in a client of the same package. If encoding fails, the body falls back to
plain text.
The development versus production difference is the fourth rule. Environment.isRelease is true when Swift assertions
are disabled, that is, when the binary is built with swift build -c release, and not when --env production is
passed: the document of the property says it is decided by the build configuration, "intentionally" independent of the
Environment name. A debug build run with --env production still leaks String(describing: error). Consequences:
-
build with
-c releasefor anything exposed to the Internet (the official Dockerfile does); -
make any message you want the client to see an
AbortError, and any message you do not want shown anything else; -
never put secrets in the
reasonof anAbortError, because it is shown in every configuration.
The ErrorMiddleware initializer takes a closure, ErrorMiddleware { req, error in Response }, so you can reuse its
position in the chain with your own conversion without writing a middleware type. The closure is @Sendable,
non-throwing and synchronous, which is its limit: it cannot await, so use a real middleware type when the
conversion needs async work.
Replacing it: a custom error middleware and Problem Details
An API that must be consumed by many clients benefits from a standard error shape.
RFC 9457 "Problem Details for HTTP APIs" (which obsoletes RFC 7807)
defines a JSON object with the members type (a URI identifying the problem type, default about:blank), title,
status, detail and instance, plus any extension members, served as application/problem+json. The middleware
below produces it, and also performs the mapping of domain, validation, decoding and database errors to status codes.
import Vapor
import Fluent
/// RFC 9457 "Problem Details for HTTP APIs" body.
struct ProblemDetails: Content {
struct FieldProblem: Content {
var field: String
var message: String
}
var type: String
var title: String
var status: Int
var detail: String?
var instance: String?
var errors: [FieldProblem]?
}
extension HTTPMediaType {
static let problemJSON = HTTPMediaType(type: "application", subType: "problem+json")
}
/// Replaces the default ErrorMiddleware: every failure becomes an application/problem+json response.
struct ProblemDetailsMiddleware: AsyncMiddleware {
let environment: Environment
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
do {
return try await next.respond(to: request)
} catch {
return makeResponse(for: error, on: request)
}
}
private func makeResponse(for error: any Error, on request: Request) -> Response {
var problem = ProblemDetails(
type: "about:blank", title: "Internal Server Error", status: 500,
detail: environment.isRelease ? "Something went wrong." : String(describing: error),
instance: request.url.path)
var headers = HTTPHeaders()
switch error {
case let error as BookError:
switch error {
case .isbnTaken(let isbn):
problem.status = 409
problem.title = "Conflict"
problem.detail = "A book with ISBN \(isbn) already exists."
case .outOfStock(let title):
problem.status = 422
problem.title = "Unprocessable Content"
problem.detail = "\(title) is out of stock."
}
case let error as ValidationsError:
problem.status = 400
problem.title = "Validation failed"
problem.detail = "One or more fields are invalid."
problem.errors = error.failures.map {
.init(field: "\($0.key)", message: $0.failureDescription ?? "is invalid")
}
case let error as any DatabaseError where error.isConstraintFailure:
problem.status = 409
problem.title = "Conflict"
problem.detail = "The change violates a database constraint."
case let error as any DatabaseError where error.isConnectionClosed:
problem.status = 503
problem.title = "Service Unavailable"
problem.detail = "The database is temporarily unavailable."
headers.replaceOrAdd(name: .retryAfter, value: "5")
case let error as any AbortError:
problem.status = Int(error.status.code)
problem.title = error.status.reasonPhrase
problem.detail = error.reason
headers = error.headers
default:
break
}
// 4xx are the client's problem: log them quietly; 5xx are ours.
if problem.status >= 500 {
request.logger.report(error: error, metadata: ["path": "\(request.url.path)"])
} else {
request.logger.info("\(problem.status) \(problem.title)", metadata: ["path": "\(request.url.path)"])
}
let response = Response(status: HTTPResponseStatus(statusCode: problem.status), headers: headers)
do {
try response.content.encode(problem, as: .json)
} catch {
response.body = .init(string: "{\"title\":\"Internal Server Error\",\"status\":500}")
}
response.headers.contentType = .problemJSON
return response
}
}
Source: Vapor docs: Errors, Vapor docs: Middleware and RFC 9457
The case order is significant: the first match wins. Domain and validation errors are tested before the generic
AbortError, because ValidationsError is itself an AbortError and would otherwise lose its field list. The
DatabaseError cases come before AbortError for the same reason, and DatabaseError is a Fluent protocol
(isSyntaxError, isConstraintFailure, isConnectionClosed) that the Fluent drivers' errors conform to, so the
middleware reacts to a unique-index violation without importing a driver. A syntax error (isSyntaxError) is a programming bug,
so it is left to the 500 branch. The cast is written as any DatabaseError, which the ExistentialAny feature enabled by the template requires.
Installing it is one change in configure. The new middleware must be the first, so reset the list instead of adding
at the end:
import Vapor
func configure(_ app: Application) async throws {
// Replace the default stack (RouteLoggingMiddleware + ErrorMiddleware) with our own error handler first.
app.middleware = Middlewares()
app.middleware.use(ProblemDetailsMiddleware(environment: app.environment))
try routes(app)
}
Source: Vapor docs: Middleware
Alternatively keep the stock stack and add the new one in front: app.middleware.use(ProblemDetailsMiddleware(...), at: .beginning).
Then the stock ErrorMiddleware remains inside it and answers first; your middleware would see only errors that
the inner one itself throws, which is almost never what you want. Replace it, as above, unless you keep the default body.
What the client sees
Captured from the listings above (debug build, so the /boom detail is visible; a release build prints the fixed
text). The JSON encoder does not guarantee key order, so the members appear in the order they happened to be printed,
and the encoder escapes / as \/:
# GET /books/99
{"title":"Not Found","type":"about:blank","status":404,"detail":"No book with id 99.","instance":"\/books\/99"}
# GET /books/abc
{"type":"about:blank","detail":"The book id must be an integer.","instance":"\/books\/abc","title":"Bad Request","status":400}
# GET /nope (no route matches)
{"title":"Not Found","status":404,"instance":"\/nope","type":"about:blank","detail":"Not Found"}
# POST /books with an already used ISBN
{"type":"about:blank","detail":"A book with ISBN 9780141439587 already exists.","instance":"\/books","title":"Conflict","status":409}
# POST /books with an empty title and a bad ISBN
{"type":"about:blank","detail":"One or more fields are invalid.","instance":"\/books","errors":[{"message":"title is empty","field":"title"},{"message":"isbn is less than minimum of 10 character(s) and contains '!' (allowed: A-Z, a-z, 0-9)","field":"isbn"}],"title":"Validation failed","status":400}
# POST /books with truncated JSON
{"type":"about:blank","detail":"Data corrupted at path ''. The given data was not valid JSON. Underlying error: Error Domain=NSCocoaErrorDomain Code=3840 \"Unexpected end of file\" UserInfo={NSDebugDescription=Unexpected end of file}.","instance":"\/books","title":"Bad Request","status":400}
# GET /admin (note the WWW-Authenticate header, status 401)
{"type":"about:blank","detail":"A bearer token is required.","instance":"\/admin","title":"Unauthorized","status":401}
# GET /boom
{"type":"about:blank","detail":"Unexpected(secret: \"db-password-placeholder\")","instance":"\/boom","title":"Internal Server Error","status":500}
The ValidationsError reason is a single sentence for the default middleware; this middleware splits it per field, so a
form or a client can mark the right input. Compare the 400 for truncated JSON: the reason comes from the
DecodingError conformance, with the coding path, which is helpful for a developer and tells an attacker about your
model names, so a public API may prefer a fixed detail for decoding errors.
RouteNotFound and 404 for unknown paths
When no route matches, the router’s fallback responder fails the request with RouteNotFound, an AbortError
with status 404 and a DebuggableError whose logLevel is .debug, so a crawler probing for /wp-admin does not fill
the warnings. The type has been public since Vapor 4.121.0 (it was internal in 4.120.0), which allows middleware to
recognize it:
if error is RouteNotFound {
// a missing path, as opposed to a handler's own 404 for a missing record
}
Distinguishing the two lets an API return a different type for "no such endpoint" than for "no such book".
A wrong HTTP method on a known path is also RouteNotFound in Vapor 4.122: there is no automatic 405 with an Allow
header, so add one yourself when the distinction matters. Static files are served by FileMiddleware, which
passes to the next responder on a miss, so a missing file ends in the same 404.
Reporting errors: report(error:)
Logger.report(error:metadata:file:function:line:) is the one function that logs an Error properly:
| Error | Logged as |
|---|---|
|
level from |
|
level |
anything else |
level |
The stock ErrorMiddleware calls it for every error it handles (so a 404 is a warning unless the error’s
logLevel says otherwise), which is why Abort errors appear in the log with their file:line. The custom
middleware above reports only the 5xx class at the error level and logs the others at info, a common production
policy. Use it also in places where an error is swallowed on purpose, such as a background task in Task { } or the
catch of the entrypoint:
let logger = req.logger
Task {
do {
_ = try await store.add(new)
} catch {
logger.report(error: error, metadata: ["isbn": "\(new.isbn)"])
}
}
A thrown error that is never caught in a Task is lost silently, because there is no caller to receive it; logging it
is the only trace. Structured logging, log levels and metadata are covered by the Logging page. When a TracingMiddleware wraps the chain, a second ErrorMiddleware inside it lets the span keep the response status code; see Tracing, Metrics & OpenTelemetry.
unwrap(or:) and optionals
EventLoopFuture has an extension, available when the future’s value is an Optional, that turns nil into an error:
// Legacy futures style
app.get("legacy", ":id") { req -> EventLoopFuture<Book> in
let id = req.parameters.get("id", as: Int.self) ?? 0
let maybeBook: EventLoopFuture<Book?> = req.eventLoop.makeSucceededFuture(
id == 1 ? Book(id: 1, title: "Dune", isbn: "9780441013593") : nil)
return maybeBook.unwrap(or: Abort(.notFound)) // EventLoopFuture<Book>
}
Source: Vapor docs: Errors
It exists only on futures (unwrap(or: @autoclosure @Sendable () -> Error)), and the error is built lazily. Async code has
no equivalent and does not need one: Swift’s own guard let is clearer, as the GET /books/:id handler above shows, and
Fluent’s find returns Book? in async form. The books written for Vapor 3 and early Vapor 4 use unwrap(or:) in
almost every handler; in modern code it appears only when you maintain a future-based codebase.
Mapping errors to status codes
The table below is the policy the sample middleware implements; adapt it to your API contract.
| Situation | Status | Notes |
|---|---|---|
Malformed JSON, a missing or mistyped field, a path parameter that does not parse |
400 |
|
Syntactically valid but semantically wrong (an order for an out-of-stock book) |
422 |
|
Missing or invalid credentials |
401 |
must carry a |
Authenticated but not allowed |
403 |
do not use 404 to hide existence unless that is a deliberate policy |
The identified resource does not exist |
404 |
|
Unique-index or version conflict |
409 |
|
Rate limit exceeded |
429 |
add |
Unhandled error |
500 |
never put the cause in the body in production |
Database or dependency unavailable |
503 |
add |
The 404 versus 400 pair deserves care, because both are common mistakes in tutorials. GET /books/abc is a malformed
request (400): the client could never succeed with that URL. GET /books/99 is a well-formed request about a record that
does not exist (404). Returning 404 for both hides the client’s bug; returning 400 for a missing record is wrong
because the request was valid.
For success, return the status that matches the verb. Creating a resource is 201 with a Location header pointing at
it and, usually, the created representation (the POST /books handler above does both). A DELETE that succeeds and
has nothing to say is 204 with an empty body, which Vapor produces when the handler returns HTTPStatus.noContent;
returning 200 with an empty body, or 200 with "deleted", is the common mistake. A handler cannot return an
Optional model as a shortcut for a 404: Optional is not ResponseEncodable in Vapor 4.122.2, so unwrap it
explicitly and throw the 404 yourself.
Fluent specific mapping (a failed find, driver constraint messages) and the Abort-based
shortcuts for models live with the Fluent Models and Fluent Queries and Pagination pages; only DatabaseError is shown here because it is the
documented, driver-independent hook.
Errors in the test harness
VaporTesting makes the mapping testable without sockets. The test below asserts the status, the media type and the
body of a 404, and it is the harness that produced the captured output of this page:
import Testing
import VaporTesting
@testable import App
@Suite struct ErrorTests {
@Test func notFoundIsProblemJSON() async throws {
let app = try await Application.make(.testing)
do {
try await configure(app)
try await app.testing().test(.GET, "books/99") { res async in
#expect(res.status == .notFound)
#expect(res.headers.contentType == .problemJSON)
}
} catch {
try await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
}
Source: Vapor docs: Testing
To exercise the database branch without a database, register a route that throws a stub type conforming to Fluent’s
DatabaseError (three Bool properties) and assert the 409.
Swift 6 concurrency notes
-
Thrown errors must be
Sendable. A struct or enum withSendablestored properties is implicitly; the error closures ofErrorMiddlewareand the types above compile in the Swift 6 language mode without annotations. -
The
ErrorMiddlewareclosure is@Sendable: capture onlySendablevalues (aletenvironment or logger, never a mutable class). -
A custom middleware is a
Sendabletype. TheProblemDetailsMiddlewareabove stores only anEnvironment, which isSendable; keep per-request state in theRequest, not in the middleware. -
catchinside an unstructuredTaskmust handle the error itself (seereport(error:)), because the error cannot propagate to the request.
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
The client receives a bare failure response instead of the JSON body |
No error middleware in the chain (for example |
Every error is 500 with |
The custom type does not conform to |
|
|
A |
|
|
|
A |
An error with a non- |
A diagnostic asking for |
|
The |
Capture a copy of the needed |
A 404 for an unknown path is logged as a warning |
The custom middleware logs by status; |
|
Return |
A handler returning |
Vapor does not encode an |
Browser forms show JSON error bodies |
|
Version notes
| Version | Change |
|---|---|
Vapor 3 |
|
4.0 |
the protocol is |
4.122.2 |
current 4.x; |
4.121.0 |
|
5.0.0-beta.4 |
unreleased beta; verify |
Where the books are outdated
Yeung (Hands-On Server-Side Web Development with Swift, Packt, chapters 5 and 11) and the Kodeco book
(Condon et al.) were written for Vapor 3 and early Vapor 4. Their Abort(.notFound) and unwrap(or:) on Fluent
futures still compile, but futures are legacy and guard let in an async handler replaces them. Registering
ErrorMiddleware through MiddlewareConfig and configure(_:_:_:) no longer exists: the list is app.middleware.
Treat the books' status codes with care: a missing record must be 404 and a created resource 201 with Location,
not a 200 with the model, and a successful DELETE is 204. None of the books covers Problem Details, the
release-versus-debug behavior of Environment.isRelease, the public RouteNotFound or Logger.report(error:).
References
-
vapor/vapor 4.122.2: Error, ErrorMiddleware.swift and DefaultResponder.swift
-
vapor/fluent-kit (
DatabaseError)