HTTP Status Code Reference: Code Examples

Searchable reference for all 1xx–5xx status codes — official IANA codes plus notable vendor extensions. Runs entirely in your browser, works offline.

ZERO UPLOAD · ALL LOCAL
  1. Type a code number (e.g. 404) to find codes by number only, or a word (e.g. "timeout", "rate limit") to search names and detail content.
  2. Use the category pills (1xx–5xx) to browse codes by class when not searching.
  3. Click Details on any card to expand causes and resolution steps in a full-width panel below the row.
  4. Click Copy Markdown on an expanded card to copy a ready-to-paste summary for Jira tickets, GitHub issues, or Slack.

1XX INFORMATIONAL — 4 codes

100 Continue
RFC 9110
101 Switching Protocols
RFC 9110
102 Processing
RFC 2518
103 Early Hints
RFC 8297

2XX SUCCESS — 10 codes

200 OK
RFC 9110
201 Created
RFC 9110
202 Accepted
RFC 9110
203 Non-Authoritative Information
RFC 9110
204 No Content
RFC 9110
205 Reset Content
RFC 9110
206 Partial Content
RFC 9110
207 Multi-Status
RFC 4918
208 Already Reported
RFC 5842
226 IM Used
RFC 3229

3XX REDIRECTION — 8 codes

300 Multiple Choices
RFC 9110
301 Moved Permanently
RFC 9110
302 Found
RFC 9110
303 See Other
RFC 9110
304 Not Modified
RFC 9110
305 Use Proxy
RFC 9110
307 Temporary Redirect
RFC 9110
308 Permanent Redirect
RFC 9110

4XX CLIENT ERROR — 35 codes

400 Bad Request
RFC 9110
401 Unauthorized
RFC 9110
402 Payment Required
RFC 9110
403 Forbidden
RFC 9110
404 Not Found
RFC 9110
405 Method Not Allowed
RFC 9110
406 Not Acceptable
RFC 9110
407 Proxy Authentication Required
RFC 9110
408 Request Timeout
RFC 9110
409 Conflict
RFC 9110
410 Gone
RFC 9110
411 Length Required
RFC 9110
412 Precondition Failed
RFC 9110
413 Content Too Large
RFC 9110
414 URI Too Long
RFC 9110
415 Unsupported Media Type
RFC 9110
416 Range Not Satisfiable
RFC 9110
417 Expectation Failed
RFC 9110
418 I'm a Teapot (Unofficial)
RFC 2324
420 Enhance Your Calm (Unofficial)
Twitter
421 Misdirected Request
RFC 9110
422 Unprocessable Content
RFC 9110
423 Locked
RFC 4918
424 Failed Dependency
RFC 4918
425 Too Early
RFC 8470
426 Upgrade Required
RFC 9110
428 Precondition Required
RFC 6585
429 Too Many Requests
RFC 6585
431 Request Header Fields Too Large
RFC 6585
444 No Response (Unofficial)
nginx
451 Unavailable For Legal Reasons
RFC 7725
494 Request Header Too Large (Unofficial)
nginx
495 SSL Certificate Error (Unofficial)
nginx
496 SSL Certificate Required (Unofficial)
nginx
499 Client Closed Request (Unofficial)
nginx

5XX SERVER ERROR — 19 codes

500 Internal Server Error
RFC 9110
501 Not Implemented
RFC 9110
502 Bad Gateway
RFC 9110
503 Service Unavailable
RFC 9110
504 Gateway Timeout
RFC 9110
505 HTTP Version Not Supported
RFC 9110
506 Variant Also Negotiates
RFC 2295
507 Insufficient Storage
RFC 4918
508 Loop Detected
RFC 5842
510 Not Extended
RFC 2774
511 Network Authentication Required
RFC 6585
520 Web Server Returns an Unknown Error (Unofficial)
Cloudflare
521 Web Server Is Down (Unofficial)
Cloudflare
522 Connection Timed Out (Unofficial)
Cloudflare
523 Origin Is Unreachable (Unofficial)
Cloudflare
524 A Timeout Occurred (Unofficial)
Cloudflare
525 SSL Handshake Failed (Unofficial)
Cloudflare
526 Invalid SSL Certificate (Unofficial)
Cloudflare
527 Railgun Listener to Origin Error (Unofficial)
Cloudflare
No codes match your search.

HTTP Status Codes in Express.js

Express.js sets status codes through the chainable res.status() method. Every response in an Express route handler should call res.status() before res.json() or res.send(), or use res.sendStatus() when no body is needed. Returning a status code without calling res.status() defaults to 200 OK, which silently misclassifies errors. Consequently, a missing res.status(404) on a not-found route tells HTTP-aware infrastructure the request succeeded. Express does not enforce status code selection: the developer is responsible for choosing the semantically correct code for each branch. This guide covers the most common Express status code patterns and the custom error middleware that centralises status code logic across an application.

Setting status codes on route handlers

Inside an Express route handler, call res.status() before sending the response body to ensure the response signals the correct outcome to HTTP-aware infrastructure. For a successful POST that creates a resource, the pattern is res.status(201).json({ id: newRecord.id }). For a not-found case, res.status(404).json({ error: 'Not found' }) communicates the absence correctly.

res.sendStatus(204) is the right shorthand for a DELETE that returns no body: it sets the code and sends the status message as the plain-text body in a single call.1 Route handlers that call res.json() without res.status() implicitly send 200 OK, which can mask errors that reach that branch due to missing return statements earlier in the handler.1

Custom error middleware

Error middleware is the recommended pattern for handling all error responses in an Express application, because it keeps status code selection and response formatting in one place instead of duplicating that logic across dozens of route handlers. Without it, every route handler must duplicate the same res.status().json() calls for every error path, which leads to inconsistent error bodies and makes it easy to forget the correct status code on a new error branch.

Centralising status code logic in error middleware

Express identifies error middleware by its four-parameter signature: (err, req, res, next), which distinguishes it from regular middleware that only takes three parameters.2 Place it after all routes so that it only runs when a route calls next(err) or throws. Throwing or passing a custom Error object with a status property lets each route signal its intended code without duplicating res.status() calls.

A route can set the code like this: const err = new Error("Not found"); err.status = 404; next(err). The error middleware reads err.status and calls res.status(err.status || 500).json({ error: err.message }). Routes that handle multiple error conditions can set different status codes on different Error subclasses, keeping the mapping between error types and HTTP codes in one place. This approach also makes it easy to add logging or error-tracking middleware that inspects err.status before the response is sent.

Async route handlers require a try/catch wrapper or the express-async-errors package to ensure thrown errors reach the error middleware rather than causing an unhandled rejection.3 Without this, an exception thrown inside an async handler produces an unhandled promise rejection that crashes the process in Node.js 15 or later, so the wrapper is not optional for production applications that use async/await in route handlers. The express-async-errors package patches Express's routing layer automatically, which is the lowest-effort way to add this protection. Wrapping handlers in a higher-order function that catches rejections and calls next(err) achieves the same result without adding a dependency, and is the pattern recommended by the Express.js documentation for teams that prefer explicit control. The wrapper function typically looks like const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next), which you can apply to every async route handler in your application.

404 handler placement

Express evaluates middleware and route handlers in the exact order they are registered with app.use() and app.get/post/put/delete calls, so the position of each middleware function relative to the route definitions determines which requests it handles.4 A 404 handler is a regular middleware function placed after every route definition and sub-router. Placing it before a router causes legitimate requests to match the 404 handler before the router ever sees them.

The pattern is app.use((req, res) => res.status(404).json({ error: "Route not found" })). Adding this as the final middleware after all route registrations catches any request that no handler matched, including requests to paths that were never defined in your application. This catch-all approach is safer than defining a 404 handler per router because a single central handler cannot accidentally be skipped when a new router is added.

A missing 404 handler causes Express to return an empty 200 response for unmatched routes in some configurations, which is harder to diagnose than an explicit 404 because the client and any monitoring tools see a successful response where none was intended. The CapyToolkit HTTP Status Reference includes the full list of 4xx codes and when to use each one.

Validation middleware and status code selection in Express

Validation middleware in Express runs before route handlers, intercepting requests with invalid input before any business logic executes. Libraries like express-validator perform structural validation that checks whether required fields are present, whether values match expected types, and whether format constraints such as email patterns or UUID shapes are satisfied. Choosing the right status code for each failure mode requires understanding where in the request lifecycle the failure occurs: structural failures at the middleware level are 400 Bad Request, while semantic failures discovered further down in business logic are 422 Unprocessable Content.5

Using express-validator's validationResult() function, you collect all validation errors from the request and can return them in a structured 400 response before the route handler runs. Standardise on which codes your middleware returns and document those decisions for API consumers, because a pattern that returns different codes from middleware versus route handlers creates inconsistency that confuses client-side error handling. Returning 400 from middleware for a structural failure but 422 from a route handler for the same kind of failure forces clients to implement two different error-handling paths for what is logically the same problem.

Forwarding validation errors to Express error middleware

Validation errors detected in middleware should be forwarded to your central error middleware using next(err) with a structured error object, rather than sending the response directly from the middleware. This keeps all response formatting in one place and ensures the error body structure is consistent whether the error comes from middleware or from route handler business logic. Attach err.status = 400 and err.errors = validationResult(req).array() to the error object before calling next(err).

Rate limiting in Express and returning 429 with Retry-After

The express-rate-limit package adds configurable rate limiting to Express applications with minimal setup. The default configuration uses a fixed window algorithm, counting requests per IP address within a time window.6 When a client exceeds the limit, express-rate-limit calls your configured handler function rather than automatically returning 429. Configuring the handler to return 429 with a Retry-After header requires only a few lines in the package configuration.

Set the handler option to a function that reads the rate limit window duration: (req, res) => res.status(429).set('Retry-After', Math.ceil(options.windowMs / 1000)).json({ error: 'Too many requests' }). The Retry-After value should reflect the remaining time in the current window, not the full window duration, because reporting the full window duration causes clients to wait longer than necessary and increases the chance of a thundering herd when the window resets. The package exposes req.rateLimit.resetTime for this calculation.7

Adding informational rate limit headers alongside 429

Add X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers to all responses from rate-limited routes, not just on 429 responses. Express-rate-limit populates req.rateLimit.limit, req.rateLimit.remaining, and req.rateLimit.resetTime on every request, giving your application the values it needs to set these headers in a response middleware that runs after each rate-limited route. Clients that read these informational headers on 200 responses can pace their requests proactively and avoid triggering 429 responses entirely, which improves the experience for both the client and the origin server.

Notes

res.status(code) is chainable: res.status(201).json({ id: newId }). res.sendStatus(code) sets the code and sends the status text as the body. Express error middleware receives (err, req, res, next): four parameters. The 404 handler must be placed after all routes (including sub-routers). err.status or err.statusCode on a custom Error lets error middleware read the intended code from the thrown object. express-async-errors (npm) or a try/catch wrapper is required to forward async errors to the error middleware.

Examples

Route handler: 201 Created with Location header

app.post('/users', async (req, res) => {
  const user = await db.users.create(req.body);
  res.status(201).location(`/users/${user.id}`).json(user);
});

Include a Location header pointing to the created resource URI on all 201 responses.

Custom error middleware with status code passthrough

app.use((err, req, res, next) => {
  const status = err.status || err.statusCode || 500;
  res.status(status).json({
    error: err.message || 'Internal server error',
  });
});

Place this after all routes. Reads err.status or err.statusCode from thrown Error objects.

404 catch-all handler

// Place after all routes and routers
app.use((req, res) => {
  res.status(404).json({ error: 'Route not found' });
});

Try in the tool

Express status code patterns

  • res.status(code) chainable — e.g. res.status(201).json({ id: newId })
  • res.sendStatus(code) sets the code and sends the status text as the body
  • Error middleware signature (err, req, res, next) — four parameters, placed after all routes
  • 404 handler placement must be the last app.use() call, after every route and sub-router

Verify with the HTTP Status Code Reference tool.

Try it in the tool ↑
Sources
  1. 1.

    Express.js, "Response API — res.status() and res.sendStatus()," expressjs.com, accessed June 2026. https://expressjs.com/en/4x/api/response/

  2. 2.

    Express.js, "Error Handling," expressjs.com, accessed June 2026. https://expressjs.com/en/guide/error-handling/

  3. 3.

    npm, "express-async-errors," docs.npmjs.com, accessed June 2026. https://docs.npmjs.com/package/express-async-errors

  4. 4.

    Stack Overflow, "Order of Router Precedence in Express.js," stackoverflow.com, accessed June 2026. https://stackoverflow.com/questions/32603818/order-of-router-precedence-in-express-js

  5. 5.

    Express-validator, "express-validator — Validation Middleware for Express.js," github.com, accessed June 2026. https://github.com/express-validator/express-validator

  6. 6.

    Express-rate-limit, "express-rate-limit — Rate Limiting Middleware for Express.js," github.com, accessed June 2026. https://github.com/express-rate-limit/express-rate-limit

  7. 7.

    Stack Overflow, "Remaining limits of an API using express-rate-limit," stackoverflow.com, accessed June 2026. https://stackoverflow.com/questions/62711905/remaining-limits-of-an-api-using-express-rate-limit-framework-in-node-js

FAQ

HTTP Status Codes in Django REST Framework

Django REST Framework uses named status constants from rest_framework.status instead of raw integers.1 Every response in a DRF view should pass a status keyword argument using these constants: Response(data, status=status.HTTP_201_CREATED). Using raw integers like 201 works but loses the readability benefit and introduces typo risk. DRF also provides a built-in exception hierarchy that maps exception types to status codes automatically: raise NotFound() returns 404, raise PermissionDenied() returns 403, and raise ValidationError() returns 400. Consequently, most DRF views do not need to set status codes explicitly on the happy path; the framework handles them via its exception handling mechanism. This guide covers the three patterns for controlling status codes in DRF: status constants, the exception hierarchy, and custom exception handlers.

Using DRF status constants

DRF's status module provides named constants for every standard HTTP status code, making view code readable without requiring developers to memorise integer codes. Import the module with from rest_framework import status and pass constants to the Response class: return Response(serializer.data, status=status.HTTP_201_CREATED). For empty responses that return no body, such as a successful DELETE, return Response(status=status.HTTP_204_NO_CONTENT). Using status constants also makes it easy to search your codebase for all places that return a specific code. The full list of constants is available in the DRF documentation under the status module, and most IDEs can autocomplete the constant names as you type. Teams that adopt a consistent convention of always using named constants find that code reviews catch status code mistakes more easily because the constant name communicates intent where an opaque integer does not.

Using named constants rather than raw integers allows static analysis tools and code reviewers to verify that the intended status code is correct without looking up integer values. DRF's Response class defaults to HTTP 200 OK when no status argument is provided, so explicit status arguments are only needed when the correct code differs from 200.2

GET endpoints that return existing data typically omit the status argument because HTTP 200 OK is the correct default. PUT endpoints that update an existing resource also default to 200 when no new resource is created. POST endpoints that create resources should always pass HTTP_201_CREATED explicitly so that the Location header and the status line both signal that a new resource was created.

Raising DRF exceptions

DRF provides a built-in exception hierarchy that maps Python exceptions to HTTP status codes automatically, so most view code never needs to call res.status() directly on the happy path.3 raise NotFound() produces a 404 response with a JSON error body. raise PermissionDenied() produces a 403. raise ValidationError({"field": "error message"}) produces a 400 with a structured errors body.

Raising these exceptions from anywhere in the view, the serializer, or a permission class produces the correct HTTP response without requiring explicit status code handling in each location. A common DRF pattern is to place all business rule validation inside a serializer's validate method and raise ValidationError there, so the exception handler converts it to a 400 automatically. Centralising validation logic in the serializer keeps views thin and ensures that the same validation rules apply regardless of which view triggers the serializer.

Custom exception types

Raising APIException() directly allows setting a custom status_code and default_detail on a base class, which is useful for creating reusable exception types for domain-specific error conditions. Subclass APIException once for each distinct error type in your application, such as PaymentRequired, SubscriptionExpired, or QuotaExceeded, and raise those subclasses from your business logic so that the exception handler converts each one to the correct HTTP status code automatically.

Custom exception handlers

DRF's default exception handler converts known DRF exceptions to HTTP responses and passes unknown exceptions to Django's standard error handling, which produces an HTML error page rather than a JSON response. Configuring a custom exception handler via the EXCEPTION_HANDLER setting in REST_FRAMEWORK intercepts all exceptions before they reach the default handler, giving you full control over the response format.3

Inside the custom handler, you can map exception types to status codes, add custom fields to the error body such as a correlation ID or machine-readable error code, and log the exception with structured data. A custom handler that formats all errors as RFC 7807 Problem Details provides consistent error bodies across the entire API without modifying individual views.

A custom handler that silently swallows exceptions or returns 200 for all errors defeats DRF's built-in exception handling and creates debugging difficulty because the original error is never surfaced to logs or monitoring. Always call exception_handler(exc, context) inside your custom handler and modify the response it returns rather than replacing the entire error handling chain.

Returning 422 in DRF with a custom exception handler

Django REST Framework returns 400 for all ValidationError exceptions by default. Teams that prefer 422 for semantic validation failures while keeping 400 for structural failures need a custom exception handler that distinguishes between the two. A custom handler receives every exception before DRF converts it to an HTTP response, giving you control over the status code per exception type.

Write a custom handler that checks whether the exception is a ValidationError raised by your serializer's validate method versus a ValidationError raised by field-level type coercion. Field-level type coercion errors (passing a string where an integer is expected) map to 400. Business-rule validation errors from the validate method map to 422. Mark the exception with an attribute to distinguish them: set exc.status_code = 422 in your custom validator before raising it from the validate method.

Registering the custom exception handler in DRF settings

Configure the custom exception handler by setting EXCEPTION_HANDLER in your REST_FRAMEWORK settings dictionary. The setting accepts a Python path string pointing to your handler function. Your custom handler must call the default exception_handler function and modify the returned response object rather than building a new response from scratch. Returning None from the handler passes the exception to Django's standard error handling, which produces an HTML error page rather than a JSON response.

DRF throttling classes and 429 responses

Django REST Framework provides built-in throttling classes that return 429 automatically when a client exceeds the configured rate.4 AnonRateThrottle applies limits to unauthenticated requests by IP address. UserRateThrottle applies limits to authenticated requests by user account. Both classes read their rate configuration from DEFAULT_THROTTLE_RATES in the REST_FRAMEWORK settings dictionary, where rates are expressed as requests per time period: "100/day", "20/hour", or "5/minute".

DRF's 429 responses include a Retry-After header automatically when the throttle class implements the wait() method. Both AnonRateThrottle and UserRateThrottle implement wait(), so their 429 responses carry the correct header without any custom code. The Retry-After value in seconds tells the client how long to wait before its next request will be accepted.

Applying throttle classes per view or globally

Apply throttle classes globally by adding them to DEFAULT_THROTTLE_CLASSES in REST_FRAMEWORK settings, or per-view by setting throttle_classes = [AnonRateThrottle] on an APIView subclass. View-level throttle classes override global defaults for that view only. A common pattern applies light global throttling for all views and tighter per-view throttling for expensive endpoints like search or report generation, without requiring separate middleware configuration.

Combining global and per-view throttling creates a layered defense against abuse. A global limit of "1000/day" for authenticated users and "100/day" for anonymous users provides a baseline, while a per-view limit of "20/minute" on a search endpoint prevents a single client from monopolizing expensive query resources. DRF evaluates throttles in the order they are declared: the first throttle that blocks the request determines the 429 response, so ordering global throttles before per-view throttles ensures the stricter limit is checked first. This layering approach gives you fine-grained control over rate limits without duplicating configuration across multiple views.

Notes

Status constants live in rest_framework.status: HTTP_200_OK, HTTP_201_CREATED, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY, HTTP_429_TOO_MANY_REQUESTS, HTTP_500_INTERNAL_SERVER_ERROR. The EXCEPTION_HANDLER setting in REST_FRAMEWORK controls the global exception handler: set it to 'myapp.exceptions.custom_exception_handler' to override the default. raise APIException() with a status_code attribute is the base class for custom exceptions. Serializer validation failures raise rest_framework.exceptions.ValidationError, which DRF maps to HTTP 400 by default.

Examples

POST view returning 201 with Location header

from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView

class UserCreateView(APIView):
    def post(self, request):
        serializer = UserSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        user = serializer.save()
        headers = {'Location': f'/users/{user.id}/'}
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

raise_exception=True on is_valid() automatically raises ValidationError (400) if the serializer is invalid.

Raising NotFound for a missing resource

from rest_framework.exceptions import NotFound

class OrderDetailView(APIView):
    def get(self, request, pk):
        try:
            order = Order.objects.get(pk=pk)
        except Order.DoesNotExist:
            raise NotFound(detail='Order not found.')
        serializer = OrderSerializer(order)
        return Response(serializer.data)

raise NotFound() produces a 404 response with a JSON body. No explicit status code needed.

Custom exception handler adding a request ID

from rest_framework.views import exception_handler
import uuid

def custom_exception_handler(exc, context):
    response = exception_handler(exc, context)
    if response is not None:
        response.data['request_id'] = str(uuid.uuid4())
    return response

Set EXCEPTION_HANDLER in REST_FRAMEWORK settings to "myapp.exceptions.custom_exception_handler".

Try in the tool

DRF exception-to-status mapping

  • 404
  • 403
  • 400 (DRF's default, even for semantic failures)
  • 429, with Retry-After added automatically when wait() is implemented

DRF returns 400 for all ValidationError exceptions by default — getting 422 for semantic validation failures requires a custom exception handler.

Verify with the HTTP Status Code Reference tool.

Try it in the tool ↑
Sources
  1. 1.

    Django REST Framework, "Status codes," django-rest-framework.org, accessed June 2026. https://www.django-rest-framework.org/api-guide/status-codes/

  2. 2.

    encode, "rest_framework/response.py," github.com, accessed June 2026. https://github.com/encode/django-rest-framework/blob/main/rest_framework/response.py

  3. 3.

    Django REST Framework, "Exceptions," django-rest-framework.org, accessed June 2026. https://www.django-rest-framework.org/api-guide/exceptions/

  4. 4.

    encode, "rest_framework/throttling.py," github.com, accessed June 2026. https://github.com/encode/django-rest-framework/blob/main/rest_framework/throttling.py

FAQ

HTTP Status Codes in FastAPI

FastAPI provides multiple ways to set HTTP status codes, each suited to a different scenario. For error conditions, raise HTTPException(status_code=404, detail="Not found") is the standard approach: FastAPI converts the exception to a JSON response with a detail field automatically.1 For success responses that differ from 200, declare the status code in the route decorator: @app.post("/users", status_code=201).2 For empty responses, return Response(status_code=204) directly. FastAPI also provides named constants in the fastapi.status module that mirror the rest_framework.status pattern from Django REST Framework. Consequently, FastAPI routes are explicit about their expected success code in the route signature, which OpenAPI schema generation uses to document the correct response code in the Swagger UI. This guide covers all three patterns and their appropriate use cases.

Using HTTPException for error responses

Inside a FastAPI route function, raise HTTPException to return an error response with a JSON body. HTTPException accepts a status_code integer and a detail argument that can be a string, a dict, or a list. FastAPI converts the exception to a JSON response with the structure {"detail": "Not found"} and sets the correct Content-Type and status code headers automatically.1

Import HTTPException from fastapi, not from starlette.exceptions, to get the version with FastAPI's exception handling integration. Use a dict for the detail argument when you want to return structured error information: raise HTTPException(status_code=422, detail={"errors": [{"field": "email", "message": "Invalid format"}]}). Using fastapi.HTTPException ensures that FastAPI's built-in exception handler processes the response, which applies any custom exception handlers you have registered for HTTPException subclasses.

Raising a Python ValueError or other non-HTTPException inside a route function produces an unhandled 500 unless you register a custom exception handler with @app.exception_handler. Registering a handler for ValueError with @app.exception_handler(ValueError) lets you convert ValueError responses to a more appropriate status code such as 400 or 422, depending on the context where the error was raised.

Status constants in route decorators

Declaring the expected success status code in the route decorator makes it visible in the OpenAPI schema and in the Swagger UI documentation, which helps client integrators understand the expected response without reading the implementation.2 For a POST route that creates a resource, use @app.post("/users", status_code=status.HTTP_201_CREATED). FastAPI returns this code when the route function completes normally without raising an exception.

The same pattern applies to any non-200 success code, including 202 Accepted for async operations and 204 No Content for empty responses. Declaring the status code in the decorator also prevents FastAPI from returning an unexpected 200 when a route that should return 204 accidentally returns None. Omitting the status_code decorator on a route that returns 201 creates a mismatch between the documented behaviour and the actual response, which causes confusion during integration testing and makes it harder for consumers to trust the accuracy of the generated OpenAPI spec.

The status_code in the decorator is what FastAPI uses when generating the OpenAPI spec: the Swagger UI shows "201 Created" as the success response for this endpoint, and client libraries generated from the OpenAPI spec will use this code to deserialise the response correctly. The decorator status_code is the default for normal completion; a route can still raise HTTPException with a different code for error conditions without affecting the documented success code.

Always declare the status_code in the decorator for any route that should return a non-200 success code to ensure the API documentation accurately reflects the actual behaviour. An undocumented 201 or 204 response can cause client integrators to misinterpret the success code because their HTTP client or testing framework defaults to expecting 200.

Returning non-200 codes without exceptions

Some responses require a specific status code without raising an exception: a 204 No Content for a DELETE that returns no body, or a 202 Accepted for an async operation. Returning a Response object directly from the route function gives full control over the status code and body.3 Return Response(status_code=status.HTTP_204_NO_CONTENT) for empty success responses: FastAPI forwards the Response object without adding any automatic serialisation.

Explicit control vs decorator defaults

Returning JSONResponse(content={"id": user.id}, status_code=201) is equivalent to returning the data dict with the status_code decorator, but gives explicit control from inside the function. Returning a plain dict from a route configured with status_code=204 in the decorator will still include the dict as a JSON body, because FastAPI serialises non-Response return values using the declared response model. Use Response() when the body must be empty.

Customizing FastAPI's 422 validation error body

FastAPI returns a specific 422 response body format for Pydantic validation failures: a detail array where each item contains a loc list (the error location), a msg string (the human-readable message), and a type string (the Pydantic error code). Many teams prefer a different error body format that matches their error response contract across all endpoints. Customising the 422 response requires registering a custom exception handler for RequestValidationError.

Import RequestValidationError from fastapi.exceptions and JSONResponse from fastapi.responses. Register the handler with @app.exception_handler(RequestValidationError).4 Inside the handler, transform the Pydantic error list into your preferred format: flatten the loc tuple into a dot-notation field path, rename msg to message, and wrap everything in your standard errors array. Return a JSONResponse with status_code=422 and the transformed body.

Preserving OpenAPI documentation after a custom 422 handler

FastAPI generates OpenAPI documentation for the default 422 body structure automatically. After registering a custom handler, the OpenAPI spec still documents the default Pydantic error format, which no longer matches your actual response. Update your route decorators to include responses={422: {"model": YourValidationErrorSchema}} to replace the auto-generated 422 schema with your custom one. This keeps the generated Swagger UI and API documentation accurate for clients integrating with your API.

Dependency injection and status codes in FastAPI

FastAPI's dependency injection system runs before the route handler and is the correct place to centralise authentication, authorisation, and input resolution. Dependencies can raise HTTPException directly, which FastAPI converts to the appropriate error response before the route handler ever runs. An authentication dependency that raises HTTPException(status_code=401) stops the request at the dependency layer, not inside the route handler. This keeps route handlers focused on business logic rather than authentication concerns.

A reusable authorisation dependency accepts the required permission as a parameter: Depends(require_permission("orders:write")). The dependency function checks the authenticated user's permissions and raises HTTPException(status_code=403, detail="Insufficient permissions") if the user lacks the required permission. Composing multiple dependencies in a route (authentication, then authorisation, then input validation) builds a layered guard that executes in order before the handler body runs.

FastAPI dependency injection versus middleware for 401 and 403

Authentication logic in FastAPI middleware applies to all routes in the application. Authentication logic in a dependency applies only to routes that declare the dependency. For APIs where most routes require authentication, middleware is more efficient: you write the logic once and all routes benefit automatically. For APIs with mixed public and authenticated routes, per-route dependencies are more precise and avoid adding authentication overhead to public endpoints that do not need it.

A hybrid approach combines middleware for global authentication with dependencies for per-route authorisation. A middleware that validates the JWT and attaches the user to request.state runs on every request, while a dependency that checks specific permissions runs only on routes that need it. This separation keeps the authentication logic centralised while still allowing fine-grained permission checks where they matter. Testing the middleware independently with a suite of valid and invalid tokens ensures the authentication layer works correctly before the authorisation dependencies even run, which simplifies debugging when a 403 occurs because you can verify the authentication layer passed successfully first.

Notes

from fastapi import status provides constants: status.HTTP_200_OK, status.HTTP_201_CREATED, status.HTTP_204_NO_CONTENT, status.HTTP_400_BAD_REQUEST, status.HTTP_404_NOT_FOUND, status.HTTP_422_UNPROCESSABLE_ENTITY. FastAPI returns 422 (not 400) for Pydantic validation failures by default: the response body follows a specific structure with a detail array. raise HTTPException(status_code=N, detail="message") is the recommended way to return error responses. Return Response(status_code=204) for empty responses. The status_code parameter in @app.post(status_code=201) sets the documented and default success code for that route.

Examples

POST route returning 201 Created

from fastapi import FastAPI, status
from fastapi.responses import JSONResponse

app = FastAPI()

@app.post("/users", status_code=status.HTTP_201_CREATED)
async def create_user(user: UserCreate):
    new_user = await db.users.create(user.dict())
    return new_user

Declaring status_code=201 in the decorator documents the success response in OpenAPI and returns 201 when the function completes normally.

Raising HTTPException for a not-found resource

from fastapi import HTTPException, status

@app.get("/orders/{order_id}")
async def get_order(order_id: int):
    order = await db.orders.get(order_id)
    if not order:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Order {order_id} not found",
        )
    return order

FastAPI converts HTTPException to a JSON response with {"detail": "..."} automatically.

DELETE route returning 204 No Content

from fastapi import Response, status

@app.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(user_id: int):
    await db.users.delete(user_id)
    return Response(status_code=status.HTTP_204_NO_CONTENT)

Return Response(status_code=204) explicitly for empty responses. Returning None or a plain dict on a 204 route may include a body.

Try in the tool

Three ways FastAPI sets a status code

  • raise HTTPException(status_code=N, detail=...) standard pattern for error responses
  • @app.post(..., status_code=201) declares the success code in the route decorator
  • return Response(status_code=204) for empty responses with explicit control
  • 422 for validation FastAPI returns 422, not 400, for Pydantic validation failures by default

Verify with the HTTP Status Code Reference tool.

Try it in the tool ↑
Sources
  1. 1.

    FastAPI, "Handling Errors," fastapi.tiangolo.com, accessed June 2026. https://fastapi.tiangolo.com/tutorial/handling-errors/

  2. 2.

    FastAPI, "Response Status Code," fastapi.tiangolo.com, accessed June 2026. https://fastapi.tiangolo.com/tutorial/response-status-code/

  3. 3.

    Encode, "Responses – Starlette Documentation," starlette.dev, accessed June 2026. https://starlette.dev/responses/

  4. 4.

    Encode, "Exceptions – Starlette Documentation," starlette.dev, accessed June 2026. https://starlette.dev/exceptions/

FAQ

HTTP Status Codes in Next.js API Routes

Next.js has two API routing systems with different status code APIs. In the Pages Router (pages/api/), status codes are set via the res object: res.status(404).json({ message: "Not found" }).1 In the App Router (app/api/ in Next.js 13 and later), status codes are set via NextResponse: return NextResponse.json({ error: "Not found" }, { status: 404 }).2 The two systems share the same HTTP semantics but use entirely different APIs, which causes confusion when migrating between them or when working in a project that uses both. Consequently, knowing which router a file belongs to is the first step before writing any status code logic. This guide covers the status code patterns for both the Pages Router and App Router, with the key differences highlighted.

Pages Router status code patterns

Inside a Pages Router API route, the response object follows a Node-inspired API. The chained pattern res.status(code).json(data) sets the status code and serialises the data as JSON in a single expression. For empty responses, res.status(204).end() sends the 204 with no body. For a resource-not-found case, res.status(404).json({ message: "Not found" }) is the canonical pattern. The response object exposes the full set of Node server response methods, so you have send, end, redirect, and setHeader available for constructing responses that need more control than the chained pattern provides.

Common status code patterns in the Pages Router

Calling res.json(data) without res.status() defaults to 200 OK: missing a status code on an error branch silently returns a success code.1 The Pages Router does not prevent you from calling res.status() or res.json() multiple times: the first call wins for the status code, but calling json() twice results in a "headers already sent" error. Always use early return statements after sending a response to prevent this.3

App Router Route Handler patterns

App Router Route Handlers return Response or NextResponse objects rather than writing to a response object. Return NextResponse.json(body, { status: 404 }) to send a 404 with a JSON body. Return new Response(null, { status: 204 }) for an empty 204 response. For a 201 Created with a Location header, return new Response(JSON.stringify(resource), { status: 201, headers: { "Content-Type": "application/json", "Location": "/users/123" } }). Because App Router handlers return a Response object instead of mutating a server-side response, the entire handler body can be type-checked at build time: if a code path does not return a Response, TypeScript reports the missing return.

Async handlers and error boundaries

App Router route handlers can be async functions that use await at the top level without any wrapper:2 this is a significant improvement over the Pages Router's callback-based pattern. However, throwing an error in an App Router route handler produces an unhandled 500 unless you catch it explicitly inside the handler. There is no equivalent of Express's error middleware: each handler must manage its own error cases.

Error handling differences between the two routers

The Pages Router and App Router differ in how unhandled errors are surfaced to clients. In the Pages Router, an unhandled exception inside a handler causes Next.js to return a 500 with a generic JSON error body in development and a bare 500 with no body in production. In the App Router, an unhandled exception inside a Route Handler also produces a 500, but the error logging and response body vary by configuration.4

Pages Router handlers export a config object to increase body size limits and enable response streaming. This config object goes directly in the handler file with a named export, which keeps the configuration co-located with the handler logic. App Router handlers configure these via Segment Config Options exported from the same file, using a different set of named exports that control runtime, region, and body parser limits. Both approaches serve the same purpose but use incompatible APIs, so migrating a handler between routers always requires rewriting these options.

Wrapping App Router route handlers in try/catch and returning NextResponse.json({ error: "..." }, { status: 500 }) for caught errors gives more control over the 500 response body than relying on the default Next.js error handling. This pattern also lets you distinguish between expected business errors (such as a 422 validation failure) and truly unexpected failures that should surface as 500. Logging the caught error before returning the 500 response ensures the server-side error details are recorded even though the client receives a generic message, which preserves security while maintaining observability.

Next.js Middleware and status codes at the edge

Next.js Middleware runs on the edge before any page or API route is rendered. It executes for every incoming request matching its configured matcher, allowing you to return a response (including 401, 403, or 307 redirects) before the request reaches any route handler. Authentication checks that run in Middleware add no latency to authenticated requests: the Middleware only intercepts unauthenticated requests and exits immediately for valid sessions.

Returning a 401 from Middleware is straightforward: return new NextResponse(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } }).5 Because Middleware runs in the edge runtime rather than Node.js, it does not have access to Node.js APIs or most npm packages. Database queries and complex authentication logic should run in the route handler; use Middleware for lightweight checks like verifying a session cookie exists or validating a JWT signature using the Web Crypto API.

Middleware matchers and selective status code handling

Configure the matcher export in middleware.ts to restrict which routes trigger the Middleware. A matcher of /api/:path* applies authentication checks only to API routes, leaving public pages and static assets unaffected. Applying Middleware to /api/public/:path* routes and returning early with NextResponse.next() exempts those routes from authentication checks without duplicating Middleware logic. Selective matchers prevent Middleware from adding latency to routes that do not need the processing it performs. When your application has many public endpoints that should bypass authentication entirely, combining a broad matcher with early-return logic inside the Middleware function is more maintainable than listing every protected route individually.

Testing Middleware locally with the Next.js development server requires a different approach than testing route handlers, because Middleware runs in the edge runtime and some features are only available in production. The Next.js CLI provides a preview command that builds and serves the application with production-like Middleware behaviour, which is essential for verifying that status code responses from Middleware (especially 307 redirects and 401/403 errors) behave correctly before deploying. Adding integration tests that hit the Middleware endpoints directly ensures that matcher logic and status code responses remain correct across deployments.

Notes

Pages Router: API routes live in pages/api/. The handler receives (req, res) where res is a Node.js-like response object with .status(code), .json(data), .send(data), .end(). Use res.status(code).json(data) to set code and body together. App Router: Route Handlers live in app/api/route.ts (or .js). Export named functions GET, POST, PUT, DELETE, etc. Return a Response or NextResponse object. NextResponse.json(body, { status: code }) is the standard pattern. import { NextResponse } from 'next/server'. NextResponse extends the Web API Response. You cannot use res.status() in App Router route handlers.

Examples

Pages Router: 201 Created on POST

// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).json({ message: 'Method not allowed' });
  }
  const user = await db.users.create(req.body);
  res.setHeader('Location', `/api/users/${user.id}`);
  return res.status(201).json(user);
}

Check req.method to return 405 for unsupported methods. Set Location header before calling res.status().json().

App Router: 404 Not Found on GET

// app/api/orders/[id]/route.ts
import { NextResponse } from 'next/server';

export async function GET(request: Request, { params }: { params: { id: string } }) {
  const order = await db.orders.findById(params.id);
  if (!order) {
    return NextResponse.json({ error: 'Order not found' }, { status: 404 });
  }
  return NextResponse.json(order);
}

App Router route handlers return a NextResponse or Response object. Do not use res.status() — that is Pages Router only.

App Router: 204 No Content on DELETE

// app/api/users/[id]/route.ts
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
  await db.users.delete(params.id);
  return new Response(null, { status: 204 });
}

Return new Response(null, { status: 204 }) for empty bodies. NextResponse.json() with no body is not the correct approach for 204.

Try in the tool

Pages Router vs App Router

  • res.status(404).json({ message: 'Not found' })
  • NextResponse.json({ error: 'Not found' }, { status: 404 })
  • new Response(null, { status: 204 })
  • produces a 500 in both routers — no Express-style error middleware in App Router

The two routers share the same HTTP semantics but use entirely different APIs, which is the main source of confusion when migrating between them.

Verify with the HTTP Status Code Reference tool.

Try it in the tool ↑
Sources
  1. 1.

    Next.js, "Routing: API Routes," nextjs.org, accessed June 2026. https://nextjs.org/docs/pages/building-your-application/routing/api-routes

  2. 2.

    Next.js, "Getting Started: Route Handlers," nextjs.org, accessed June 2026. https://nextjs.org/docs/app/getting-started/route-handlers

  3. 3.

    Stack Overflow, "Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client NextJS and Prisma," stackoverflow.com, accessed June 2026. https://stackoverflow.com/questions/70611317/error-err-http-headers-sent-cannot-set-headers-after-they-are-sent-to-the-cli

  4. 4.

    Vercel, "API Routes - Default error handling like Express.js," github.com, accessed June 2026. https://github.com/vercel/next.js/discussions/17832

  5. 5.

    Vercel, "middleware.js," github.com, accessed June 2026. https://github.com/vercel/next.js/blob/v15.5.6/docs/01-app/03-api-reference/03-file-conventions/middleware.mdx

FAQ

HTTP Status Codes with the Fetch API

The Fetch API does not throw on 4xx or 5xx HTTP responses. This is one of the most common sources of bugs in client-side JavaScript and Node.js code: developers expect fetch to reject its promise when the server returns a 404 or 500, but it only rejects on network failures (no connection, DNS failure, or request aborted).1 A 404 or 500 response fulfills the promise successfully and the error code is available via response.status and the boolean response.ok. Consequently, every fetch call that communicates with an API must explicitly check response.ok or response.status before treating the response as a success. This guide covers the three key properties for reading status codes from a fetch response and the standard wrapper pattern that adds automatic error throwing to fetch.

Checking response.ok

The simplest way to detect an HTTP error in a fetch response is to check response.ok immediately after awaiting the fetch call. response.ok is true when the status code is in the range 200 through 299 and false for any other status code.2 This boolean saves you from writing explicit range checks like response.status >= 200 && response.status < 300, and it covers all successful codes including 200 OK, 201 Created, and 204 No Content in a single test.

The canonical pattern: const response = await fetch(url); if (!response.ok) { throw new Error(HTTP error: ${response.status}); } const data = await response.json(). This ensures that 4xx and 5xx responses are not silently treated as successful responses. Without this guard, downstream code that reads data.id or data.items will crash on undefined properties instead of surfacing the HTTP error, making the root cause harder to trace in logs and error monitoring.

Checking response.ok before parsing the body

The check must happen before calling response.json(). Calling json() on a 4xx response that has an empty body throws a JSON parse error, which obscures the original status code from the error handler. For this reason, always gate the body parsing behind the response.ok check: if the check fails, parse the body inside the error branch so you can include both the status code and the parsed error detail in the error you throw.

Reading response.status

For finer control than the boolean response.ok allows, read response.status directly. Response.status gives the exact integer status code: 200, 201, 204, 404, 422, 500, and so on. This enables status-specific handling: a 401 response can trigger a token refresh and retry, a 429 response can read the Retry-After header and schedule a delayed retry, and a 503 response can surface a "service unavailable" message rather than a generic error.

Production API clients typically branch on response.status rather than just response.ok to implement appropriate retry and error surfacing logic per status code. This granular approach distinguishes between transient failures like 502 or 503, which may resolve on retry, and permanent client errors like 400 or 403, which require user intervention. Mapping each code to a specific recovery strategy produces more resilient API consumers than a simple success-or-failure check.

Avoid response.statusText for logic

Reading response.statusText gives the HTTP phrase for the status code, but this value is unreliable: servers may override it, and HTTP/2 does not transmit status phrases at all.3 This means a 404 might report "Not Found" over HTTP/1.1 but return an empty string over HTTP/2, even though the status code is identical. Use response.status for all programmatic checks and reserve response.statusText for human-readable display only, where an empty phrase is harmless.

Building a fetch wrapper that throws on non-2xx

A reusable fetch wrapper centralises the response.ok check and error parsing so every call site benefits automatically. The wrapper awaits the fetch call, checks response.ok, and if false, reads the error body and throws an error that includes the status code, status text, and parsed error body. By moving this logic into a shared function, you eliminate the risk of forgetting the response.ok check at an individual call site and make the error format consistent across your entire application.

The error thrown by the wrapper should include the status code as a property so callers can branch on it: error.status === 401 triggers re-authentication, error.status === 429 triggers backoff. The wrapper can also read the Content-Type header to decide whether to parse the body as JSON or plain text: a 400 from a JSON API typically has an application/json body, but a 400 from a server-rendered error page has a text/html body.

A simple wrapper that always calls response.json() on error bodies will throw a JSON parse error for non-JSON error responses, masking the original status code. This is a common pitfall when the API returns a mix of JSON and HTML error pages depending on the endpoint and the error severity. Add a try/catch around the json() parse and fall back to response.text() if JSON parsing fails, so the status code is always available to the caller regardless of body format.

Retry patterns with fetch: exponential backoff and idempotency

Retrying failed fetch requests requires knowing which requests are safe to retry and how long to wait between attempts. GET, HEAD, OPTIONS, and DELETE requests are idempotent: retrying them produces the same result as the first attempt.4 POST requests are not idempotent by default: retrying a POST that creates a resource creates a duplicate if the first request succeeded but the response was lost. Idempotency keys solve this for POST: include a unique Idempotency-Key header with a UUID on each POST request, and the server uses the key to return the same response for duplicate submissions without creating duplicate resources.5

Exponential backoff with jitter is the standard retry timing strategy for rate-limited or overloaded APIs.6 Start with a base wait of 1 second, double it on each retry, and add a random jitter value (between 0 and the current wait time) to prevent multiple clients from retrying simultaneously. A maximum of three to five retry attempts with a final wait of 16 to 32 seconds prevents the retry loop from running indefinitely while giving transient failures time to resolve.

When not to retry: 400, 401, 403, and 422

Never retry on 400, 401, 403, or 422 responses automatically. These codes indicate client errors that will not resolve by retrying the same request. A 400 needs the request content fixed. A 401 needs the client to re-authenticate. A 403 indicates a permanent permission denial for this identity. A 422 needs the submitted data corrected. Retry only on 429 (after Retry-After), 503 (after Retry-After), and 500 or 502 (only for idempotent methods with exponential backoff). Retrying on non-transient errors amplifies load on the server without any chance of success, and in systems with automatic retry logic this feedback loop can turn a single bad request into a sustained spike of wasted traffic.

Monitoring your fetch retry patterns in production reveals whether your backoff configuration is effective or needs adjustment. A common blind spot is retry storms: when a downstream service recovers after an outage, all clients that were backing off may send their queued retries simultaneously, overwhelming the service again. Adding a small random delay to the initial retry (before exponential backoff starts) distributes the retry load across a wider time window and prevents the thundering herd problem that turns a recovery into a second outage.

Notes

response.ok is true when status is in the range 200-299. response.status is the integer HTTP status code (e.g., 404). response.statusText is the HTTP status phrase (e.g., "Not Found"). fetch() only rejects the promise on network errors, not on HTTP error status codes. Parsing the response body with response.json() on a 4xx/5xx response returns the server's error body (if any) as a parsed object. The AbortController API is used to cancel a fetch request (which does cause a rejection with AbortError). CORS errors cause fetch to reject with a TypeError, not with an HTTP error status.

Examples

Checking response.ok before parsing

async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    const errorBody = await response.json().catch(() => null);
    throw Object.assign(new Error(`HTTP ${response.status}`), {
      status: response.status,
      body: errorBody,
    });
  }
  return response.json();
}

Always check response.ok before calling response.json(). fetch() does not throw on 4xx or 5xx responses.

Status-specific handling: 401 refresh and 429 backoff

async function apiFetch(url, options) {
  const response = await fetch(url, options);
  if (response.status === 401) {
    await refreshToken();
    return fetch(url, { ...options, headers: getAuthHeaders() });
  }
  if (response.status === 429) {
    const retryAfter = response.headers.get('Retry-After') ?? '5';
    await sleep(parseInt(retryAfter, 10) * 1000);
    return apiFetch(url, options);
  }
  if (!response.ok) {
    throw new Error(`HTTP error ${response.status}`);
  }
  return response.json();
}

Branch on response.status for status-specific retry logic. 429 reads Retry-After before sleeping.

Reusable fetch wrapper with JSON or text error body

async function safeFetch(url, options) {
  const response = await fetch(url, options);
  if (!response.ok) {
    const contentType = response.headers.get('Content-Type') ?? '';
    const body = contentType.includes('application/json')
      ? await response.json()
      : await response.text();
    const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
    error.status = response.status;
    error.body = body;
    throw error;
  }
  return response;
}

Check Content-Type before calling .json() to avoid parse errors on non-JSON error responses.

Try in the tool

Reading a fetch response

  • response.ok true when status is in the range 200–299
  • response.status the exact integer code — 200, 201, 204, 404, 422, 500, etc.
  • fetch() rejection only rejects on network failure, never on a 4xx/5xx HTTP response
  • Don't retry 400, 401, 403, 422 — these won't resolve by retrying the same request

Verify with the HTTP Status Code Reference tool.

Try it in the tool ↑
Sources
  1. 1.

    Mozilla Developer Network, "Window: fetch() method," developer.mozilla.org, accessed June 2026. https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch

  2. 2.

    Mozilla Developer Network, "Response: ok property," developer.mozilla.org, accessed June 2026. https://developer.mozilla.org/en-US/docs/Web/API/Response/ok

  3. 3.

    M. Thomson and C. Benfield, "HTTP/2," RFC 9113, IETF, June 2022. https://www.rfc-editor.org/rfc/rfc9113

  4. 4.

    R. Fielding, M. Nottingham, and J. Reschke, "HTTP Semantics," RFC 9110, IETF, June 2022. https://www.rfc-editor.org/rfc/rfc9110

  5. 5.

    Stripe, "Idempotent Requests," docs.stripe.com, accessed June 2026. https://docs.stripe.com/api/idempotent_requests

  6. 6.

    AWS, "Exponential Backoff And Jitter," aws.amazon.com, accessed June 2026. https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/

FAQ

HTTP Status Codes in Spring Boot

Spring Boot provides three ways to control HTTP status codes in REST controllers. ResponseEntity<T> is the most explicit: it wraps the response body and status code in a single return type. The @ResponseStatus annotation on a method or exception class declares the status code at the class level. @ControllerAdvice combined with @ExceptionHandler centralises status code logic for all controllers in global exception handling. Each approach has appropriate use cases, and mixing them within a single controller creates inconsistency. Furthermore, Spring's HttpStatus enum provides named constants for all standard HTTP status codes: HttpStatus.CREATED, HttpStatus.NOT_FOUND, HttpStatus.NO_CONTENT. Using the enum rather than raw integers makes the intent explicit and prevents typos in multi-digit codes.1

Returning status codes with ResponseEntity

ResponseEntity<T> gives complete control over the status code, headers, and body from within the controller method. For a successful resource creation, return ResponseEntity.created(uri).body(createdResource): this sets status 201 and the Location header in a single fluent call.2 For a successful deletion, return ResponseEntity.noContent().build(): this sets status 204 with no body.

The convenience methods (created, ok, noContent, notFound) are preferred over ResponseEntity.status(HttpStatus.X).body(y) because they are more concise and the return type clearly communicates the intent. When you use these builders, the method name itself documents what HTTP status the response carries, which makes the controller code easier to review during code review without tracing through the HttpStatus enum.

ResponseEntity.badRequest().body(errors) for 400 and ResponseEntity.unprocessableEntity().body(errors) for 422 follow the same pattern and include the error body in the response. Choosing between 400 and 422 depends on your API convention: 400 signals malformed JSON or invalid syntax, while 422 indicates that the syntax was valid but the data failed business-rule validation. Returning the field-level error details inside the body alongside the correct status code gives the client enough information to fix the request without a round-trip to documentation.

The @ResponseStatus annotation

Annotating a @PostMapping or @DeleteMapping method with @ResponseStatus(HttpStatus.CREATED) or @ResponseStatus(HttpStatus.NO_CONTENT) sets the default status code for that method when it returns normally. This is simpler than wrapping the return type in ResponseEntity when you do not need to set custom headers or inspect the status code programmatically. The annotation approach works well for straightforward CRUD operations where every successful invocation of a method always produces the same status code, keeping the method signature clean and readable.

Exception-level annotation

@ResponseStatus can also be placed on a custom exception class: @ResponseStatus(HttpStatus.NOT_FOUND) on a ResourceNotFoundException means Spring maps that exception to a 404 response automatically whenever it is thrown from any controller. Throwing a well-annotated custom exception from service or repository code produces the correct HTTP status without any explicit status code handling in the controller.

ResponseEntity provides more control when the status code must be determined at runtime or when response headers like Location need to be set alongside the status. Reserve @ResponseStatus for simple methods that always return a fixed status, and switch to ResponseEntity as soon as the method needs conditional status logic, header manipulation, or a body that varies by outcome.

Global exception handling with @ControllerAdvice

@ControllerAdvice combined with @ExceptionHandler methods centralises HTTP status code decisions for all controllers in the application.1 A single @ControllerAdvice class can handle multiple exception types and return the correct ResponseEntity for each. This separation keeps the controllers focused on business logic while all HTTP error mapping lives in one place, making the status code policy easy to audit and update without touching every controller.

@ExceptionHandler(MethodArgumentNotValidException.class) catches Spring's built-in validation exceptions (from @Valid annotation processing) and converts them to a 400 or 422 response with a structured error body containing field-level validation messages. Without this handler, Spring's default behaviour returns a 400 with a vague message that omits the specific fields that failed. Global exception handling ensures that no exception escapes the API boundary as an unhandled 500.

A @ControllerAdvice that maps all exceptions to 500 without logging or inspecting the exception type prevents developers from seeing the real error, which slows debugging. Log the full exception inside the handler before returning the response. This logging step is especially important in production environments where you cannot attach a debugger: the stack trace in the log file is often the only way to reconstruct what triggered the failure.

Spring Boot 3 and RFC 9457 ProblemDetail

Spring Boot 3 (Spring Framework 6) introduces native support for RFC 9457 Problem Details, the successor to RFC 7807.3 Enabling it is a single configuration property: set spring.mvc.problemdetails.enabled=true in your application.properties or application.yml. With this property set, Spring MVC automatically formats all built-in exception responses (MethodArgumentNotValidException, NoHandlerFoundException, and others) as RFC 9457 Problem Details JSON bodies with Content-Type: application/problem+json.3

The ProblemDetail class in Spring Framework 6 is a model object you can construct and return directly from a @ControllerAdvice handler. Use ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "Order 123 not found") to create a Problem Details response, then add custom properties with problemDetail.setProperty("orderId", 123). Custom properties appear alongside the standard five fields in the response body without requiring a custom class or serializer.

Adding custom fields to Problem Details responses

Your @ControllerAdvice can extend ResponseEntityExceptionHandler, which already handles many Spring MVC exceptions and returns Problem Details when the feature is enabled. Override specific handler methods to add domain-specific context: override handleMethodArgumentNotValid to include the list of failing field names in the Problem Details properties map. This gives validation errors a consistent Problem Details format with your custom field-level error information, without building a custom response schema from scratch.

Bean Validation integration and status codes in Spring Boot

Spring's @Valid annotation on @RequestBody parameters activates Jakarta Bean Validation before the controller method runs. When validation fails, Spring throws MethodArgumentNotValidException, which Spring MVC converts to a 400 Bad Request response by default. Adding a @ControllerAdvice with @ExceptionHandler(MethodArgumentNotValidException.class) lets you change the status code to 422 for these failures and return a structured field-level error body.

Inside the exception handler, access the failing constraints through ex.getBindingResult().getFieldErrors(). Each FieldError contains the field name, the rejected value, and the validation message. Map these to your error body structure: a JSON array where each item has a field string, a message string, and an optional rejectedValue. Return the array wrapped in your standard error schema with status 422 or 400 according to your API's convention.

Constraint annotation ordering and partial validation

By default, Jakarta Bean Validation validates all constraints on all fields simultaneously and collects all failures before returning. If you need constraints to be checked in a specific order (for example, validate that a field is not null before validating its format), use constraint groups and @GroupSequence.4 This approach prevents confusing error messages like "must not be null" and "invalid email format" appearing together when the email field was null in the first place, producing a cleaner response for the client.

Notes

ResponseEntity<T> is the generic return type for explicit status codes: return ResponseEntity.status(HttpStatus.CREATED).body(resource). Convenience methods: ResponseEntity.ok(body) for 200, ResponseEntity.created(uri).body(body) for 201, ResponseEntity.noContent().build() for 204, ResponseEntity.notFound().build() for 404. @ResponseStatus(HttpStatus.CREATED) on a @PostMapping method sets the default status for that method. @ControllerAdvice with @ExceptionHandler(EntityNotFoundException.class) handles exceptions globally. HttpStatus is in org.springframework.http. ResponseEntity is in org.springframework.http.

Examples

POST controller returning 201 Created with Location header

@PostMapping("/users")
public ResponseEntity<UserDto> createUser(@RequestBody @Valid UserCreateRequest request) {
    UserDto created = userService.create(request);
    URI location = URI.create("/users/" + created.getId());
    return ResponseEntity.created(location).body(created);
}

ResponseEntity.created(uri).body(body) sets status 201 and the Location header in one fluent call.

@ResponseStatus on a custom exception

@ResponseStatus(HttpStatus.NOT_FOUND)
public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long id) {
        super("Order " + id + " not found");
    }
}

// In the service:
throw new OrderNotFoundException(orderId);

Spring maps the annotated exception to 404 automatically. No explicit ResponseEntity needed in the controller.

@ControllerAdvice global exception handler

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, List<String>>> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, List<String>> errors = ex.getBindingResult()
            .getFieldErrors().stream()
            .collect(Collectors.groupingBy(
                FieldError::getField,
                Collectors.mapping(FieldError::getDefaultMessage, Collectors.toList())
            ));
        return ResponseEntity.unprocessableEntity().body(errors);
    }
}

MethodArgumentNotValidException fires when @Valid validation fails. Map field errors to a 422 response body.

Try in the tool

Three ways Spring Boot sets a status code

  • 201, with Location header
  • 204
  • on an exception class — maps automatically whenever it's thrown
  • centralizes status logic for every controller

Spring Boot 3 adds native RFC 9457 Problem Details support via a single property: spring.mvc.problemdetails.enabled=true.

Verify with the HTTP Status Code Reference tool.

Try it in the tool ↑
Sources
  1. 1.

    Spring, "Error Responses," docs.spring.io, accessed June 2026. https://docs.spring.io/spring/reference/web/webmvc/mvc-ann-rest-exceptions.html

  2. 2.

    Spring, "ResponseEntity," docs.spring.io, accessed June 2026. https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/http/ResponseEntity.html

  3. 3.

    M. Nottingham, E. Wilde, and S. Dalal, "Problem Details for HTTP APIs," RFC 9457, IETF, July 2023. https://www.rfc-editor.org/rfc/rfc9457

  4. 4.

    Eclipse Foundation, "Jakarta Validation Specification 3.1," jakarta.ee, March 2024. https://jakarta.ee/specifications/bean-validation/3.1/jakarta-validation-spec-3.1.html

FAQ

HTTP Status Codes with Python requests

The Python requests library provides straightforward access to HTTP status codes through three properties on the response object. response.status_code returns the integer status code. response.ok returns True when the status code is below 400. response.raise_for_status() raises an HTTPError exception for 4xx and 5xx responses and does nothing for 2xx and 3xx responses. Unlike the Fetch API in JavaScript, requests does not silently swallow HTTP errors: calling raise_for_status() is the idiomatic way to convert HTTP errors into Python exceptions that propagate up the call stack. Consequently, well-written API client code in Python typically calls raise_for_status() after every request unless it needs to handle specific status codes differently.1 This guide covers all three approaches and how to combine them for robust HTTP error handling.

Checking response.status_code

Reading response.status_code directly gives the exact integer HTTP status code and enables branching on specific codes. After making a request, compare response.status_code to known values or ranges: if response.status_code == 404 handles not-found cases; if 200 <= response.status_code < 300 covers the full success range. Direct comparison is the most flexible approach because it lets you treat each code differently without converting the integer to a boolean first.

Reading specific headers alongside the status code enables status-specific retry logic: if response.status_code == 429, read response.headers.get('Retry-After') and sleep for that duration before retrying. response.status_code == 201 combined with response.headers.get('Location') gives the URL of the newly created resource on creation responses. This pattern of pairing a status code with a relevant header is standard in REST clients and avoids guessing the server's intent from the code alone.

Checking response.status_code == 200 when the server correctly returns 201 for creation causes the condition to fail silently. This is a common bug in code that assumes all successful responses return 200. Check for the 2xx range or use response.ok when any success code is acceptable, and reserve exact equality checks for status codes that require specific handling like 401 or 429.

Using raise_for_status()

Calling response.raise_for_status() immediately after making a request converts HTTP error status codes into Python exceptions without writing explicit if statements for each possible error code. When the status code is in the 4xx or 5xx range, raise_for_status() raises requests.exceptions.HTTPError. When the status code is in the 2xx or 3xx range, it does nothing and returns None.

The raised HTTPError has a response attribute: except requests.exceptions.HTTPError as err: err.response.status_code gives the exact status code, and err.response.json() parses the error body if it is JSON. Accessing these attributes inside the except block lets you build a structured error object that includes both the HTTP status and the server's error detail, which is essential for error reporting and user-facing messages.

Avoid swallowing the error detail

raise_for_status() is the standard pattern in production API client code because it ensures HTTP errors do not go unnoticed. Calling raise_for_status() inside a try/except Exception block without inspecting the status code swallows the error information and prevents status-specific handling. Instead of catching the broad Exception base class, catch HTTPError specifically and inspect the status attribute on the response so you can handle 401 differently from 429 or 500, preserving the error detail for each status and allowing the caller to take the appropriate recovery action for the specific failure mode.

Handling specific codes with conditional checks

Some API client scenarios require handling specific status codes differently rather than treating all 4xx and 5xx as equivalent failures. A 401 response triggers a token refresh and retry. A 404 response signals a missing resource and returns None from the client function rather than raising. A 429 response reads Retry-After and sleeps before retrying. Each of these codes demands a different recovery strategy, and a blanket catch-all that treats them identically discards the information the server sent to help the client respond appropriately.

Combine raise_for_status() with specific code checks by catching HTTPError and inspecting err.response.status_code inside the except block: except requests.exceptions.HTTPError as err: if err.response.status_code == 429: handle_rate_limit(err.response); else: raise. This two-step approach gives you the safety net of raise_for_status() for unanticipated errors while allowing targeted handling for codes you expect and know how to recover from.

Checking response.ok before calling raise_for_status() adds no value: raise_for_status() already encapsulates the response.ok check. Use response.ok only when you want a boolean and do not need the HTTPError exception. For example, a health-check endpoint might use response.ok to return a simple True or False without raising, while any client that needs to propagate errors should rely on raise_for_status() instead.

Session-level retry configuration with urllib3

Python requests uses urllib3 under the hood, which provides a configurable retry adapter. Mounting an HTTPAdapter with a Retry configuration on a requests Session adds automatic retry logic to every request made through that session. The Retry class from urllib3.util.retry accepts the number of retry attempts, the HTTP status codes that should trigger a retry, and the HTTP methods that are safe to retry.2

Configuring retries for 429 and 503 responses with exponential backoff covers the most common transient failure scenarios. The backoff_factor parameter sets the base for the exponential wait: with backoff_factor=1, the waits are 0, 2, 4, 8, 16 seconds between attempts. Set respect_retry_after_header=True on the Retry object so the adapter honours the server's Retry-After header on 429 responses instead of relying solely on the exponential formula.

Limiting retry scope to idempotent methods

Configure allowed_methods on the Retry object to restrict automatic retries to idempotent HTTP methods. The default allowed methods are DELETE, GET, HEAD, OPTIONS, PUT, and TRACE. Add PUT and DELETE if your application logic guarantees those operations are safe to retry. Exclude POST from allowed methods unless your API uses idempotency keys: retrying a POST without an idempotency key can create duplicate resources if the first request succeeded but the response was lost in transit.

Setting timeouts to prevent hanging requests

Python requests does not set a connection or read timeout by default.3 Without a timeout, a request to an unresponsive server blocks the calling thread indefinitely, which stalls your application and eventually exhausts your thread pool or connection pool. Set an explicit timeout on every request or configure a session-level default. The timeout parameter accepts either a single float (applied to both connection and read operations) or a two-tuple (connect_timeout, read_timeout) for separate control.

A connect timeout of 5 seconds and a read timeout of 30 seconds covers most API interactions: the connection should establish quickly, but some responses require the server to perform non-trivial computation. For long-running operations that trigger background processing, increase the read timeout to match the server's documented maximum response time. Passing timeout=None explicitly in code to disable a session-level default is a code smell: document why a timeout-free request is acceptable rather than silently removing the protection.

Handling ReadTimeout versus ConnectTimeout exceptions

When a timeout expires, requests raises either requests.exceptions.ConnectTimeout (connection phase) or requests.exceptions.ReadTimeout (read phase). Both are subclasses of requests.exceptions.Timeout.4 Catching Timeout as a single exception handles both cases. If you need to distinguish between them (for example, to retry ConnectTimeout but not ReadTimeout), catch each subclass separately rather than catching the broad RequestException base class, which would silently swallow non-timeout failures alongside timeouts.

Configuring per-method timeouts in your retry adapter provides finer control over failure recovery. A POST request that triggers a long-running database transaction may need a 120-second read timeout, while a health check should complete in under 5 seconds. Rather than relying on a single global timeout, configure a session-level default that works for the majority of requests and override it on specific calls that have different latency characteristics. This approach prevents the common bug where a single timeout value is too short for legitimate slow endpoints and too long for endpoints that should fail fast, leading to either false-positive timeouts or hung threads on genuinely broken services.

Notes

response.status_code is an integer: 200, 201, 404, 500, etc. response.ok is True for status < 400. response.raise_for_status() raises requests.exceptions.HTTPError for 4xx and 5xx responses. The HTTPError instance contains the response object as the .response attribute: err.response.status_code. response.json() parses the response body as JSON; raises json.JSONDecodeError if the body is not valid JSON. requests.get/post/put/delete all return a Response object. Does not raise on HTTP errors by default, only on network errors (ConnectionError, Timeout, etc.).

Examples

Using raise_for_status() with error body parsing

import requests

def get_user(user_id: int) -> dict:
    response = requests.get(f"https://api.example.com/users/{user_id}")
    try:
        response.raise_for_status()
    except requests.exceptions.HTTPError as err:
        error_body = err.response.json() if err.response.content else {}
        raise ValueError(
            f"API error {err.response.status_code}: {error_body.get('message', 'Unknown error')}"
        ) from err
    return response.json()

err.response gives access to the original Response object. Parse the error body from err.response.json() for structured API errors.

Status-specific handling: 404 returns None, 429 retries

import time
import requests

def fetch_order(order_id: str):
    response = requests.get(f"/api/orders/{order_id}")
    if response.status_code == 404:
        return None
    if response.status_code == 429:
        retry_after = int(response.headers.get('Retry-After', '5'))
        time.sleep(retry_after)
        return fetch_order(order_id)
    response.raise_for_status()
    return response.json()

Handle specific codes before calling raise_for_status() to implement per-code logic without losing the generic error raising for other codes.

Checking response.ok for a simple success test

import requests

def health_check(base_url: str) -> bool:
    try:
        response = requests.get(f"{base_url}/health", timeout=5)
        return response.ok
    except requests.exceptions.RequestException:
        return False

response.ok is True for status codes < 400. Use it for simple boolean checks where you do not need to distinguish specific error codes.

Try in the tool

Reading status in Python requests

  • response.status_code the exact integer code — 200, 201, 404, 500, etc.
  • response.ok True when status is below 400
  • response.raise_for_status() raises HTTPError for 4xx/5xx, does nothing for 2xx/3xx
  • err.response.status_code read the code back out of a caught HTTPError

Verify with the HTTP Status Code Reference tool.

Try it in the tool ↑
Sources
  1. 1.

    Kenneth Reitz, "Quickstart," docs.python-requests.org, accessed June 2026. https://docs.python-requests.org/en/latest/user/quickstart/

  2. 2.

    Andrey Petrov, "Utilities," urllib3.readthedocs.io, accessed June 2026. https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html

  3. 3.

    Kenneth Reitz, "Advanced Usage," docs.python-requests.org, accessed June 2026. https://docs.python-requests.org/en/latest/user/advanced/

  4. 4.

    PSF, "API Reference," requests.readthedocs.io, accessed June 2026. https://requests.readthedocs.io/en/stable/api/

FAQ