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.

flowchart LR C["Client"] --> E["Error middleware (outermost)"] E --> M["Other middleware"] M --> R["Router"] R --> H["Handler"] H -- "throws" --> M R -- "RouteNotFound" --> M M -- "error" --> E E -- "Response (status, headers, JSON body)" --> C

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.")

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

status

the HTTPResponseStatus sent to the client

reason

the human-readable message; defaults to the status’s standard reason phrase (Not Found) when omitted

headers

extra response headers (WWW-Authenticate, Retry-After, Allow, Location)

identifier

a machine-friendly name; defaults to the status code as text ("404")

source

an ErrorSource (file, function, line, column) captured at the throw through the default arguments

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

identifier (required)

names this specific error; the full identifier is typeIdentifier + "." + identifier. Do not use String(describing: self) here, it recurses

reason (required)

one sentence, ending with a period

source

optional ErrorSource of the throw (default nil)

possibleCauses, suggestedFixes

arrays of strings, default empty

documentationLinks, stackOverflowQuestions, gitHubIssues

arrays of URLs as strings, default empty

logLevel

the Logger.Level used when the error is reported, default .warning

readableName, typeIdentifier

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()
    }
}

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:

  1. an error that is both DebuggableError and AbortError, like Abort: its reason, status, headers and source

  2. an AbortError: its reason, status and headers

  3. a DebuggableError only: its reason, status 500 and no headers

  4. anything else: status 500; the reason is String(describing: error) in a debug build and the fixed text Something 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 release for 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 reason of an AbortError, 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
    }
}

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)
}

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

DebuggableError

level from logLevel; message debuggableHelp(.short), or .long when the logger level is trace; file/line from source when set

AbortError (not debuggable)

level .warning, the reason

anything else

level .warning, String(reflecting: error)

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>
}

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

DecodingError and ValidationsError already give 400; parameters.require gives 422 for an unparsable value

Syntactically valid but semantically wrong (an order for an out-of-stock book)

422

.unprocessableEntity; 400 is also acceptable, but be consistent

Missing or invalid credentials

401

must carry a WWW-Authenticate header

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

GET /books/99 when 99 does not exist

Unique-index or version conflict

409

DatabaseError.isConstraintFailure; also optimistic-locking failures

Rate limit exceeded

429

add Retry-After

Unhandled error

500

never put the cause in the body in production

Database or dependency unavailable

503

add Retry-After; DatabaseError.isConnectionClosed

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()
    }
}

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 with Sendable stored properties is implicitly; the error closures of ErrorMiddleware and the types above compile in the Swift 6 language mode without annotations.

  • The ErrorMiddleware closure is @Sendable: capture only Sendable values (a let environment or logger, never a mutable class).

  • A custom middleware is a Sendable type. The ProblemDetailsMiddleware above stores only an Environment, which is Sendable; keep per-request state in the Request, not in the middleware.

  • catch inside an unstructured Task must handle the error itself (see report(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 app.middleware = Middlewares() without re-adding one), or the middleware was added after others and a throwing middleware sits outside it. Put the error middleware first.

Every error is 500 with Something went wrong. in development

The custom type does not conform to AbortError. A DebuggableError alone is 500 too.

Something went wrong. expected but the debug description is printed

Environment.isRelease follows the build configuration, not --env. Build with -c release.

A ValidationsError produced one sentence and not a field list

ValidationsError matched AbortError first. Put its case before the generic one.

Abort(.notFound, "text") does not compile

reason is a labeled argument: Abort(.notFound, reason: "text"); with headers, headers: comes first.

A Sendable diagnostic when throwing a custom error

An error with a non-Sendable stored property (a class, a closure); make it a struct with value properties.

A diagnostic asking for any before DatabaseError in a cast

ExistentialAny is enabled (the template turns it on); write error as any DatabaseError.

The @Sendable closure of ErrorMiddleware captures a non-Sendable value

Capture a copy of the needed Sendable value, or use a middleware type.

A 404 for an unknown path is logged as a warning

The custom middleware logs by status; RouteNotFound declares .debug as its logLevel, which only report(error:) honors.

DELETE returns 200 with an empty body

Return HTTPStatus.noContent (204) instead of HTTPStatus.ok.

A handler returning Book? does not compile

Vapor does not encode an Optional model. Use guard let ... else { throw Abort(.notFound) } (unwrap(or:) on futures).

Browser forms show JSON error bodies

ErrorMiddleware is JSON-only. A web application should catch the error and render a Leaf page or redirect instead (see Server-Rendered Web Apps and Forms).

Version notes

Version Change

Vapor 3

Debuggable (with identifier, reason, possibleCauses, suggestedFixes) lived in a separate package; errors were registered through the Services container

4.0

the protocol is DebuggableError inside Vapor; the stack is app.middleware, whose first entries are RouteLoggingMiddleware and ErrorMiddleware.default(environment:)

4.122.2

current 4.x; stackTrace and Abort.redirect(to:type:) are deprecated (redirect(to:redirectType:) replaces the latter); ErrorMiddleware still takes a synchronous closure

4.121.0

RouteNotFound public

5.0.0-beta.4

unreleased beta; verify Abort, ErrorMiddleware and the middleware protocols against the Vapor 5 release notes before upgrading, this page does not claim their 5.x shape (see What’s New, Upgrading and Vapor 5)

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:).