In the 21-app set behind my 2026 audits, the Reliability & Correctness pillar averaged 31.4 out of 100. API error handling best practices cover what happens when a request fails: one error shape, a status code that matches what happened, one boundary that turns exceptions into responses, and no failure dressed as success.

What API error handling best practices are: four rules for every failure

API error handling best practices come down to four rules: every failure gets the same error body, with a request id; the status code says what actually happened; one boundary per service turns unexpected exceptions into that body with a 500; and no failed operation is ever reported to the client as a success.

That 31.4 is an average over all 21 apps, each scored on this pillar. The 21 are two groups from my own June and July 2026 audits: 11 third-party public vibe-coded apps audited exhaustively across all 12 pillars, and 10 disjoint held-out third-party apps the engine had never seen, audited blind. I picked them, so the number describes that set, not a random sample and not a rate for AI-built apps in general. Error handling is one of the controls behind hardening SaaS applications for resilience, and it is the one every caller meets first.

The table is my rule set, one row per rule, with the code that breaks each one.

The ruleWhat it means in codeWhat breaks it
One error bodyEvery failure returns the same fields, including a request idEach route inventing its own error shape
The matching status codeThe HTTP status says what actually happenedA validation failure answered with a 500, or a failure answered with a 200
One boundary for the unexpectedOne registered handler turns any uncaught exception into the error body with a 500 and records ittry and catch copied into every route, or no handler at all
No failure reported as successThe client shows success only after the operation confirms itAn empty catch block, or a success message that runs whether or not the work happened

ESLint’s no-empty rule catches the first half of the last row: it disallows empty block statements, sits in the recommended config, ignores a block that contains a comment, and lets an empty catch through only when the allowEmptyCatch option is on.

My reading of how to handle errors in any API fits in one sentence: decide at the boundary what the caller is told and what the log records, and never let the two disagree. Error handling is important for a plain reason, too: the error response is the only part of a failure that the caller and the monitor ever see. In a REST API, the best practice is to handle each exception once, at that boundary, so error handling stays out of the individual routes. Integration error handling best practices are the same four rules plus a deliberate timeout on every outside call and a retry only where repeating the operation is safe.

If the problem in front of you is an app that already reports success for work that never happened, start with when your app fails silently and says 200 OK; the rules here are what keep that from happening again.

Error handling and exception handling are not the same thing

Error handling is the whole strategy for failures; exception handling is one mechanism for it. An expected condition such as a missing record or a declined card is best handled as a value, and an exception is kept for the unexpected, caught at one boundary that turns it into a response.

Programming languages give error handling three types of mechanism, told apart by how a failure travels: return values and error codes (C’s errno, Go’s error values), exceptions (Java, C#, JavaScript and Python), and result types (Rust’s Result<T, E>). The table further down names each one from the language’s own docs. The four rules sit above the mechanism, so exception and error handling end in the same place: one body, the right status, one boundary, no false success.

Here is an example of error handling that uses both. A lookup for an invoice that does not exist returns a not-found value, and the route turns it into a 404 with the error body. A dropped database connection throws, nothing in the route catches it, and the one boundary turns it into a 500 and records it. Exception handling best practices follow from that split: return a value for the expected, throw for the unexpected, and catch in one place rather than in every function.

What goes wrong without it

Each failure below is what one missing rule looks like from outside. The middle column is my reading of the effect, not a measurement.

The failureWhat the caller or the owner seesThe rule it breaks
Each route invents its own error bodyClients handle one shape and misread the rest1, one error body
A validation failure answered with a 500Monitors page someone for a user’s typo, and the user is told the server broke2, the matching status code
An unexpected exception with no handler of your ownThe framework’s default decides what the caller sees (the case below)3, one boundary
A failure that looks like successThe user believes the work happened when it did not4, no failure reported as success

Take a Node API built on Express with no error-handling middleware of its own, running on a server that does not set NODE_ENV to production. A route passes an error to next(), and Express’s built-in handler answers it. Express’s guide says that handler writes the error to the client with the stack trace, the response body being err.stack, and leaves the trace out only in the production environment. Setting NODE_ENV to production plus one error middleware that returns the error body keeps stack traces off the wire and gives every failure the same shape. The lesson I take from it: the framework’s default is also an error-handling decision, so make it on purpose with one handler.

The fourth row has a quieter form. In one small web app I audited, a PDF export showed “PDF downloaded successfully” on a fixed timer, whether or not a PDF was produced. My take: a success message has to wait for the outcome it reports.

Nothing outside the app catches these on its own either. 17 of the 21 third-party apps had no error tracking or alerting: when a user hits an error, nothing records it. That count covers the same 21 apps from my June and July 2026 audits, a group I chose rather than sampled, so read it as what those apps showed and not as a rate for every app. A tracker and a fixed event shape are part of error logging best practices; on the API side, the request id below is what ties a response to its event.

Asked as questions, the four rows make my error handling code review checklist: does every route return the same body, does each failure get its own status, is there exactly one registered handler, and can any failure path show success?

How to do it: the shape, the codes and one handler per framework

Putting the four rules into an API means three pieces of code: a problem-details error body with a request id, a table that maps each failure to its status code, and one error handler registered where the framework expects it. Each language spells the handler differently; the contract stays the same.

Every framework and language detail below is taken from that project’s own documentation; where the docs are silent, the cell says so.

One error shape: problem details and the request id

RFC 9457 problem details is the IETF’s standards-track format for an HTTP API error body, the nearest thing to a standard REST API error response format, and it obsoletes RFC 7807. It defines five members: type, a URI reference that identifies the problem type; title, a short, human-readable summary of the problem type; status, the HTTP status code as a number; detail, a human-readable explanation specific to this occurrence; and instance, a URI reference that identifies the specific occurrence. As JSON, the body uses the application/problem+json media type, and the status member must match the status the response actually carries. When type is left out it is assumed to be about:blank, and the RFC then says the title should be the status phrase, such as “Not Found” for 404.

My working rule adds one extension member, a request id: generate it where the request enters, return it in a response header and in the body, and write it on every log line and tracker event for that request. The RFC allows extension members and tells clients to ignore any they don’t recognize, so the field costs nothing to a client that does not read it.

Content-Type: application/problem+json
X-Request-Id: <request-id>

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "No invoice matches the id in this request.",
  "instance": "/invoices/<invoice-id>",
  "request_id": "<request-id>"
}

A stack trace never goes in the body. The RFC says problem details are not a debugging tool for the underlying implementation, and that detail ought to focus on helping the client correct the problem rather than giving debugging information. The client shows the title or the detail, and offers a retry only when the status says one is safe; the full event sits in the log under the same request id.

Returning the right status code, with FastAPI’s 404 as the worked example

The right status code names what happened: 400 or 422 for rejected input, 401 for no valid credentials, 403 for credentials without permission, 404 for a missing resource, 409 for a conflict, 429 for too many requests, 500 for an unexpected failure, 502 or 503 when a dependency is down. In FastAPI, a 404 is raise HTTPException(status_code=404).

The first column is my mapping of failures to codes. The last column is each code’s name as RFC 9110 gives it, plus 429 from RFC 6585.

What happenedStatusThe RFC’s name for it
The request is malformed (broken JSON, bad framing)400Bad Request
No valid credentials came with the request401Unauthorized
Credentials came, but they do not allow this403Forbidden
The single resource the URL names does not exist404Not Found
The request conflicts with the resource’s current state409Conflict
Well-formed input that fails validation422Unprocessable Content
Too many requests in a given amount of time429Too Many Requests
An unexpected failure in your own code500Internal Server Error
A provider you called sent back an invalid response502Bad Gateway
Your service cannot take requests for now (overload, maintenance, or a dependency it needs is down)503Service Unavailable

Two rows need a word. RFC 9110 defines 502 for a server acting as a gateway or proxy that received an invalid response from an inbound server, and 503 for a server that is currently unable to handle the request due to a temporary overload or scheduled maintenance, with an optional Retry-After header. An API that calls a provider is in a position much like a gateway’s, which is why my mapping uses 502 when that provider fails and 503 when my own service has to turn work away.

To return a 404 in FastAPI, raise an HTTPException with a status code and a detail, as FastAPI’s guide to handling errors shows. Because it is a Python exception, you raise it rather than return it, and the request ends there.

from fastapi import FastAPI, HTTPException
app = FastAPI()

@app.get("/items/{item_id}")
async def read_item(item_id: str):
    item = find_item(item_id)  # placeholder for your lookup
    if item is None:
        raise HTTPException(status_code=404, detail="Item not found")
    return item

When a request fails validation, FastAPI raises RequestValidationError and answers through a default handler. The handling-errors page names no status for that default; FastAPI’s generated OpenAPI lists the response as 422 “Validation Error”.

Express errors follow the same rule. An error passed to next() goes to error-handling middleware, the function with four arguments, (err, req, res, next), and that function is rule 3’s boundary in Express. For Express error handling best practices on status codes, put the status on the error: Express’s error-handling guide says the built-in handler sets the response status from err.status (or err.statusCode) and uses 500 when that value is outside the 4xx or 5xx range.

For status codes in API testing, every failure path gets two assertions, its status and its body; the five injected failures further down are that test list.

One handler per framework: Express, FastAPI, ASP.NET Core and Next.js

The one handler goes in a different place in each framework.

FrameworkWhere the one handler goesWhat it returns by default, as the docs state it
ExpressError-handling middleware with four arguments, defined last, after other app.use() and routes callsThe built-in handler writes the error with the stack trace, the body being err.stack; with NODE_ENV set to production, the body is the HTML of the status code message
FastAPI@app.exception_handler(...) on the app, for your own exceptions, Starlette’s HTTPException and RequestValidationErrorDefault handlers return default JSON responses for an HTTPException and for invalid request data
ASP.NET CoreUseExceptionHandler, with IExceptionHandler implementations registered through AddExceptionHandler<T>With AddProblemDetails, the exception handler middleware generates a problem details response when a custom handler is not defined
Next.jserror.js files in route segments, the error boundaries for uncaught exceptions; onRequestError in instrumentation to track server errorsExpected errors are modeled as return values; how a Route Handler’s uncaught error is answered is not stated in Next.js’s docs

Best practices for error handling in Node.js start with the Express row and one version check. The current Express guide (v5.x) says route handlers and middleware that return a Promise call next(value) automatically when they reject or throw an error. The 4.x guide says errors from asynchronous functions must be passed to next(), and that an async handler that throws leaves the rejection unhandled, which crashes the process on current Node.js versions. JavaScript and TypeScript error handling best practices add one rule of mine on top: let rejected promises reach that one boundary, and give your error classes a status so the boundary can pick the code.

Next.js error handling best practices follow the split in Next.js error handling: model expected errors as return values and let uncaught exceptions reach an error boundary. The route handler reference does not say how an uncaught error in a Route Handler is answered, so my rule is to return the error body from each handler and use onRequestError to record what escapes.

In C#, web API error handling best practices map onto ASP.NET Core error handling: call AddProblemDetails and UseExceptionHandler, and the middleware generates a problem details response when a custom handler is not defined. Registering an IExceptionHandler is not enough on its own: if UseExceptionHandler isn’t called, the registered handlers are never called. My working rule for exception handling best practices in C# follows from that: let exceptions reach the one handler instead of catching them in every controller.

Knowing how to add a frontend error boundary is the UI half of the same rule; it does not replace the handler on the server.

Go: errors are values, and there is no throw

Go has no throw and no exceptions: a function returns an error value and the caller checks it. Wrap an error with context once where it is understood, test it with errors.Is or errors.As, and keep panic for bugs, recovered at one boundary rather than used for control flow.

The Go FAQ gives the reason: “We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code.” The language spec’s keyword list has no throw, try or catch. The go.dev tutorial has a function return an error to its caller, add nil as the second value on success so the caller can see it worked, and has the caller check if err != nil. In Golang, then, there is no throw and no exception to catch: errors travel back as values and each caller decides.

The Go 1.13 errors post added the %w verb to fmt.Errorf, as in fmt.Errorf("decompress %v: %w", name, err), and wrapping with %w makes the original error available to errors.Is, which compares an error to a value, and errors.As, which tests whether an error is a specific type. The same post warns that wrapping an error makes that error part of your API, which is why my rule is to wrap once, where the error is understood. Go’s errors package now says “For most uses, prefer AsType”, a generic form of As added in Go 1.26.0.

The FAQ describes panic and recover as built-in functions to signal and recover from truly exceptional conditions. My working rule: keep panic for bugs, and recover it once, at the server’s outermost boundary, where it becomes the same 500 error body as any other unexpected failure. Golang error handling best practices look different from the other languages on this page and land on the same four rules.

The same rules in Java, Rust, Dart, C and Angular

Each row comes from the language’s or framework’s own documentation.

LanguageHow errors travelThe one boundary
Java (Spring)Checked exceptions, which a well-written application should anticipate and recover from; unchecked ones are Error, RuntimeException and their subclasses@ExceptionHandler methods in an @ControllerAdvice class, which apply to any controller
RustResult<T, E> for recoverable errors, with ? passing an Err to the calling code; panic! for unrecoverable onesmain returning Result<(), Box<dyn Error>>, which lets any Err value return early; an Err from main exits with a nonzero value
Dart and FlutterErrors in framework callbacks go to FlutterError.onError; errors with no Flutter callback on the stack go to the PlatformDispatcher’s error callbackFlutterError.onError plus PlatformDispatcher.instance.onError
CSeveral standard library functions signal errors by writing positive integers to errno; it is 0 at program startup, and library functions never store 0 in itNot stated in the C reference
AngularThrown exceptions; ErrorHandler is the hook for centralized exception handlingA custom ErrorHandler that replaces the default, which prints to the console

Exception handling best practices in Java start from the tutorial’s bottom line: “If a client can reasonably be expected to recover from an exception, make it a checked exception. If a client cannot do anything to recover from the exception, make it an unchecked exception.” Oracle’s Java exceptions tutorial says it was written for JDK 8, so its examples predate newer releases; in a Spring app, the one boundary is Spring’s controller advice.

For Rust, the Rust book on error handling sets the best practices: Rust doesn’t have exceptions, ? returns an Err from the whole function to the calling code, and panic! is for unrecoverable errors, which the book calls always symptoms of bugs.

Flutter error handling best practices set both hooks from Flutter’s error handling docs, because by default the framework’s handler dumps errors to the device logs and the PlatformDispatcher’s callback only prints them.

In C, error handling best practices start from the rules for errno in the C reference: library functions may write to it whether or not an error occurred, so my rule is to read it only after a function’s return value reports a failure.

Angular’s ErrorHandler says to write a custom exception handler that replaces the default as appropriate for your app, so Angular error handling best practices start by replacing it with one that reports to your tracker.

Python error handling best practices follow the same four rules, with FastAPI’s exception handlers above as the API boundary.

How to verify it: inject five failures

API error handling is verified by injecting failures and reading what comes back: a failed write, a slow dependency, a rejected input, an exception inside a handler and a client that ignores an error status. No injected failure may produce a success response, and each one leaves a log line with its request id.

Run the five on staging or on a local copy you own, never on production, and remove the test route and the broken URLs when you finish.

  1. 01 A database write that fails: make one write fail on purpose, for example by pointing that call at a table that does not exist on staging. Pass: a 5xx error body with a request id, a failure message in the UI, and a log line with the same request id. Keep the request, the response and the log line.
  2. 02 A dependency that is slow or down: point its URL at a closed port on staging. Pass: a 502 or 503, or the timeout the app sets, never a hang and never a success. Keep the response and how long it took.
  3. 03 A rejected input: send a request with a required field missing or of the wrong type. Pass: a 400 or 422 that names the field. Keep the request and the response.
  4. 04 An exception thrown inside a handler: add a temporary test route that throws. Pass: a 500 with the error body, no stack trace, and an event in the tracker. Keep the response and the event.
  5. 05 A client that ignores an error status: write a UI test that feeds the client a 500 and expects an error state. Pass: the error state shows and no success message appears. Keep the test run.

Pass means no injected failure produced a success response or a silent state. Doing this on a schedule, against real infrastructure, is closer to what chaos testing is.

In the Production Hardening Sprint, deliverable 6.1 is verified this way: we inject failures and verify accurate responses and diagnostic records.

Where the sprint does this

Deliverable 6.1 removes swallowed failures and empty catch blocks, replacing them with deliberate recovery or useful error reporting. Deliverable 6.2 implements global backend error handling and frontend error boundaries with clear recovery states. The result goes in the production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. New features that change the product’s core capabilities are separate work; the sprint includes only the supporting interfaces the listed controls need. When “something went wrong” turns out to be a security incident, start from an incident response plan template. Every deliverable is listed in the published scope.

Common questions about handling API errors

How to handle errors gracefully?

Handle errors gracefully by telling the user what happened in plain words, keeping what they entered, offering a retry only when repeating the action is safe, and recording the failure with a request id instead of hiding it. That is my reading of “gracefully”: the user can recover, and you can find the event later.

What are some common API errors?

The errors every API should plan for are rejected input (400 Bad Request or 422 Unprocessable Content), missing or insufficient credentials (401 Unauthorized, 403 Forbidden), a resource that does not exist (404 Not Found), too many requests (429 Too Many Requests), an unexpected server failure (500 Internal Server Error) and a failing upstream (502 Bad Gateway or 503 Service Unavailable).

What is a good error message?

A good error message explains the problem and the solution: what caused it, which input was wrong, what the requirements are, and how to fix it, with an example where one helps. That is the structure of Google’s technical-writing course on error messages, which names vague, unactionable messages with an unclear cause among the problems of bad ones.

What are the 5 keywords in Java exception handling?

The five keywords are try, catch, finally, throw and throws. Oracle’s Java tutorial covers try, catch and finally as blocks, throw as the statement that throws an exception, and throws as the clause that lists the exceptions a method can throw.

What is @ControllerAdvice and @ExceptionHandler?

@ExceptionHandler marks methods that handle exceptions from controller methods, and @ControllerAdvice is the class that makes such methods apply to any controller instead of only the one they are declared in. Spring’s reference adds that @RestControllerAdvice is a shortcut annotation that combines @ControllerAdvice with @ResponseBody.