FastAPI Custom Responses
Provides normalized response objects and error handling for FastAPI applications.
Features
- One error envelope for every failure: validation,
HTTPException,ValueError, and unhandled exceptions. - Pydantic validation errors rewritten as human-readable messages instead of raw error arrays.
- A stable
codenaming the condition, typed as an enum. - Native FastAPI response declarations for error codes, descriptions, examples, headers, media types and links.
- Generic
Response[T],SuccessResponse, andPaginatedResponse[T]envelopes for success payloads. ErrorResponseModelas both the error body the handlers emit and the schema documenting it.ErrorResponse.from_status_codefor an error carrying a status's standard HTTP phrase.
Quick Start
pip install fastapi-custom-responses
from http import HTTPStatus
from fastapi_custom_responses import (
EXCEPTION_HANDLERS,
DefaultErrorCode,
ErrorResponse,
Response,
ErrorResponseModel,
EXCEPTION_RESPONSES,
)
from fastapi import APIRouter, FastAPI
from enum import StrEnum
from pydantic import BaseModel
router = APIRouter()
app = FastAPI(
title="API",
description="My API",
version="1.0.0",
exception_handlers=EXCEPTION_HANDLERS,
responses=EXCEPTION_RESPONSES,
)
class Data(BaseModel):
example: str
class OrderErrorCode(StrEnum):
ORDER_LOCKED = "order_locked"
PAYMENT_DECLINED = "payment_declined"
@router.get(
"/",
operation_id="read_example",
response_model=Response[Data],
responses={HTTPStatus.FORBIDDEN: {"model": ErrorResponseModel[OrderErrorCode]}},
)
async def index() -> Response[Data]:
"""Index route."""
return Response(
success=True,
data=Data(example="hello"),
)
@router.get(
"/return-error",
responses={HTTPStatus.FORBIDDEN: {"model": ErrorResponseModel[OrderErrorCode]}},
)
async def error_route() -> Response[Data]:
"""Error route."""
raise ErrorResponse(
error="This order is locked.",
status_code=HTTPStatus.FORBIDDEN,
code=OrderErrorCode.ORDER_LOCKED,
)
app.include_router(router)
Response Envelopes
| Envelope | Body |
|---|---|
Response[T] |
{ "success": true, "data": { ... } } |
SuccessResponse |
{ "success": true } |
PaginatedResponse[T] |
{ "success": true, "data": [ ... ], "meta": { "offset": 0, "limit": 10, "total": 1 } } |
When using OpenAPI generators, use SuccessResponse instead of Response if your endpoint has no data to return.
Build a paginated response from a page of items and the bounds it was read with:
return PaginatedResponse.build_page(items, offset=offset, limit=limit, total=total)
Error Normalization
Register the handlers when you create the app:
from fastapi import FastAPI
from fastapi_custom_responses import EXCEPTION_HANDLERS, EXCEPTION_RESPONSES
app = FastAPI(exception_handlers=EXCEPTION_HANDLERS, responses=EXCEPTION_RESPONSES)
Every error then normalizes into one JSON shape:
{
"success": false,
"error": "Human-readable error message",
"code": "stable_error_identifier"
}
Handled Exception Types
| Exception | Status Code | Code | Behavior |
|---|---|---|---|
ErrorResponse |
Custom (default 400) |
Yours, if you pass one | Uses the provided error message directly |
RequestValidationError |
400 |
validation_error |
Pydantic validation errors are converted to human-readable messages (see below) |
HTTPException |
From exception | None | Uses the exception detail; also covers the 404 and 405 the router raises itself |
ValueError |
400 |
invalid_value |
Uses str(exc) as the error message |
ValidationError (Pydantic) |
500 |
internal_error |
A model failed to validate inside your app; logged and reported generically so its details stay out of the body |
Exception (catch-all) |
500 |
internal_error |
Reports the status phrase so the exception stays out of the body |
code is present when a condition was named — by you, or by one of the library's own handlers. It is absent otherwise, rather than restating the status.
Raising Errors
Raise ErrorResponse with a message and status code:
from http import HTTPStatus
from fastapi_custom_responses import ErrorResponse
raise ErrorResponse(error="Resource not found", status_code=HTTPStatus.NOT_FOUND)
Or create one from the status alone, which carries that status's standard HTTP phrase:
raise ErrorResponse.from_status_code(HTTPStatus.FORBIDDEN)
# { "success": false, "error": "Forbidden" }
The library ships no wording of its own, so pass error whenever the phrase is too terse for the reader:
raise ErrorResponse(error="That name is already taken", status_code=HTTPStatus.CONFLICT)
# { "success": false, "error": "That name is already taken" }
Validation Error Normalization
When a request fails Pydantic validation, FastAPI normally returns a verbose JSON array of raw Pydantic errors. With EXCEPTION_HANDLERS, these are automatically converted into concise, human-readable messages.
Before (default FastAPI):
{
"detail": [
{
"type": "missing",
"loc": ["body", "email"],
"msg": "Field required",
"input": {}
}
]
}
After (with EXCEPTION_HANDLERS):
{
"success": false,
"error": "Field 'email' is required",
"code": "validation_error"
}
When multiple fields fail validation, messages are joined with periods:
{
"success": false,
"error": "Field 'email' is required. Field 'age' must be a valid integer",
"code": "validation_error"
}
Supported Pydantic error types and their human-readable formats:
| Error Type | Example Message |
|---|---|
missing |
Field 'name' is required |
string_type |
Field 'name' must be a string |
int_type / int_parsing |
Field 'age' must be a valid integer |
float_type / float_parsing |
Field 'price' must be a valid number |
bool_type / bool_parsing |
Field 'active' must be a boolean |
enum |
Field 'status' must be one of: 'active' or 'inactive' |
uuid_type / uuid_parsing |
Field 'id' must be a valid UUID |
string_too_short |
Field 'name' must be at least 3 characters |
string_too_long |
Field 'name' must be at most 50 characters |
too_short / too_long |
Field 'items' must have at least 1 item |
greater_than / less_than |
Field 'age' must be greater than 0 |
greater_than_equal / less_than_equal |
Field 'age' must be at least 18 |
value_error |
Invalid email format (uses the validator message directly) |
json_invalid |
Invalid JSON in request body |
Any unrecognized error types fall back to the Pydantic error message prefixed with the field name.
Error Codes
error is human-readable and may be reworded or localized. code is the stable identifier clients branch on. Declare your codes as a StrEnum, so a module that never imports this package can own them:
from enum import StrEnum
class OrderErrorCode(StrEnum):
ORDER_LOCKED = "order_locked"
PAYMENT_DECLINED = "payment_declined"
The library's own handlers name their conditions too; import DefaultErrorCode to branch on validation_error, invalid_value, and internal_error.
Pass a member when raising; both ErrorResponse and from_status_code accept it:
raise ErrorResponse(
error="This order is locked",
status_code=HTTPStatus.FORBIDDEN,
code=OrderErrorCode.ORDER_LOCKED,
)
# { "success": false, "error": "This order is locked", "code": "order_locked" }
raise ErrorResponse.from_status_code(HTTPStatus.FORBIDDEN, code=OrderErrorCode.ORDER_LOCKED)
Documenting Responses
Use FastAPI's native response_model for the main success response and responses for additional responses:
from typing import Literal
from fastapi_custom_responses import ErrorResponseModel, Response
@router.post(
"/reports",
status_code=HTTPStatus.CREATED,
response_model=Response[Data],
responses={
HTTPStatus.FORBIDDEN: {
"model": ErrorResponseModel[Literal[OrderErrorCode.ORDER_LOCKED]],
"description": "This order is locked",
"headers": {"Retry-After": {"schema": {"type": "integer"}}},
"content": {
"application/json": {
"examples": {"locked": {"value": {
"success": False,
"error": "This order is locked",
"code": "order_locked",
}}}
},
"text/plain": {"schema": {"type": "string"}},
},
"links": {"example": {"operationId": "read_example"}},
},
},
)
async def reports() -> Response[Data]:
return Response(success=True, data=Data(example="hello"))
An entire enum can be used as ErrorResponseModel[OrderErrorCode]. Union enum types to document
several domains, or use Literal[OrderErrorCode.ORDER_LOCKED, DefaultErrorCode.INVALID_VALUE] to
select individual members. Pydantic produces the selected-value schema and rejects other values
when that model validates a body. The code field remains optional and nullable.
FastAPI accepts integer response codes, HTTPStatus values, status ranges such as "4XX", and
"default". Keep metadata and the model in the same response entry: replacing an entry with
{403: {"description": "..."}} also discards its model. Native response declarations preserve
these fields together without a separate conversion helper.
response_model validates and serializes the main response. Additional responses declarations
only document alternatives; they do not validate a body returned by an exception handler or a
Response object. The handler remains responsible for emitting the documented code.
Handler Documentation
Configure the matching native declarations alongside the handlers:
app = FastAPI(exception_handlers=EXCEPTION_HANDLERS, responses=EXCEPTION_RESPONSES)
EXCEPTION_RESPONSES documents the handlers' 400 and 500 models and the general 4XX/5XX
error envelopes. FastAPI's native 4XX declaration suppresses its automatic standard 422
validation response, matching this package's runtime 400 envelope. Explicitly declared 422
responses are preserved. Register both mappings before adding routes; registering handlers alone
does not change FastAPI's OpenAPI document.
Route and router response declarations override matching application entries. When a route raises
its own codes at 400 or 500, document the union of its codes and the handler codes there.
Local Development
Install the project with its development dependencies:
poetry install -E dev
Run the test suite:
poetry run pytest tests/ -v
Format and lint:
poetry run black .
poetry run isort .
poetry run pylint fastapi_custom_responses/ .github/scripts/
Metadata
Release files for fastapi-custom-responses 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| fastapi_custom_responses-0.3.0.tar.gz | 8.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| fastapi_custom_responses-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 18.3 kB
Release files / fastapi_custom_responses-0.3.0.tar.gz
| Download URL | fastapi_custom_responses-0.3.0.tar.gz |
|---|---|
| Size | 8.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
048171f865772b6c37ff55e641ea3244045a873c452fd54a4b59689cded01d5a
|
|
BLAKE2b-256 checksum How to use checksums |
ac80fac032bad185ab38a168b6b15e0aebdc89d0fe7574311a28e40181a7052d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency logRelease files / fastapi_custom_responses-0.3.0-py3-none-any.whl
| Download URL | fastapi_custom_responses-0.3.0-py3-none-any.whl |
|---|---|
| Size | 10.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
62da15e262c0257b3453aedc02726c3ff8953e1867391c648b24f00fcaa2f8e5
|
|
BLAKE2b-256 checksum How to use checksums |
ed5848fef05187f2a7757bc52cb1920767c962013568b41dcf29f2051718f4d5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency log