Skip to content

Exception Handlers & Pydantic

Mapping Tortoise database errors to HTTP status codes, and generating Pydantic schemas from Tortoise models — including the type mappings that lose information and the fields you must exclude.

Two integration points between sillo.record and the HTTP layer. The first turns database exceptions into sensible status codes instead of 500s. The second generates request-validation schemas from model definitions so the shape is not written twice.

Without handlers, a DoesNotExist raised by await User.get(id=999) propagates to the framework’s catch-all and becomes a 500 — the wrong answer, since nothing is broken and the client asked for something that is not there.

one line of setup
from sillo import silloApp
from sillo.record import register_db_exception_handlers
app = silloApp()
register_db_exception_handlers(app)

That registers four handlers:

Tortoise exceptionStatusBody
DoesNotExist404{"error": "Not Found", "detail": "<exception message>"}
IntegrityError409{"error": "Conflict", "detail": "<exception message>"}
ValidationError422{"error": "Validation Error", "detail": "<exception message>"}
OperationalError503{"error": "Service Unavailable", "detail": "Database unavailable"}

OperationalError is the one that does not include the exception text — it returns a fixed string, because a connection error message can carry the host, port, and username of your database server.

The other three do include str(exc), and that is the thing to think about before shipping.

Register the handlers in any application where handlers query the database directly. The alternative — a try/except around every get() — is more code and gets forgotten.

Do not register them if your handlers already catch these exceptions and produce domain-specific responses; a handler that catches DoesNotExist locally will never reach the registered one anyway, so there is no conflict, only redundancy.

add_exception_handler works as a direct call or as a decorator when the handler argument is omitted:

both forms
app.add_exception_handler(DoesNotExist, custom_404)
@app.add_exception_handler(DoesNotExist)
async def custom_404(request, response, exc):
return response.json(
{"error": "Resource not found", "code": "NOT_FOUND"},
status_code=404,
)

Handlers take (request, response, exc) and return a response. They run inside the middleware stack, so CORS headers, request logging, and anything else you have registered still apply to the error response.

With setup_record and register_db_exception_handlers wired up, a missing row and a duplicate insert produce:

verified responses
GET /missing -> 404 {"error": "Not Found",
"detail": "Object \"Thing\" does not exist"}
GET /dupe -> 409 {"error": "Conflict",
"detail": "UNIQUE constraint failed: thing.name"}

The second response names the table and the column. That is the leak described above, in concrete form.

_lookup_exception_handler iterates type(exc).__mro__ and returns the first class with a registered handler. Most specific wins, which matters because Tortoise’s hierarchy is not flat:

DoesNotExist -> NotExistOrMultiple -> OperationalError -> BaseORMException
IntegrityError -> OperationalError -> BaseORMException
ValidationError -> BaseORMException
FieldError -> BaseORMException

DoesNotExist and IntegrityError are both subclasses of OperationalError. The four default handlers coexist correctly because each exception finds its own handler earlier in the MRO than the OperationalError one.

get_or_none() never raises DoesNotExist — it swallows every exception and returns None. If a handler seems not to fire, check whether the call site is using get_or_none and returning a None you then have to handle yourself.

The four handlers cover the common cases, not every database error. Notably absent: TransactionManagementError, FieldError (a bad lookup name such as filter(emial="x")), ParamsError (a negative offset), and ConfigurationError (an unregistered model). Those still surface as 500s, which is arguably correct — each is a bug in your code rather than a client mistake — but you will want them logged loudly.

covering the rest
from tortoise.exceptions import BaseORMException
@app.add_exception_handler(BaseORMException)
async def handle_orm_error(request, response, exc):
logger.exception("unhandled ORM error on %s", request.url.path)
return response.json({"error": "Internal Server Error"}, status_code=500)

pydantic_model_from_tortoise inspects a model’s fields_map and builds a Pydantic model, so a request schema does not have to restate every column.

generating a create schema
from sillo.record import pydantic_model_from_tortoise
UserCreate = pydantic_model_from_tortoise(
User,
name="UserCreate",
exclude=["id", "created_at", "updated_at", "deleted_at"],
optional_fields=["bio"],
)
@app.post("/users", request_model=UserCreate)
async def create_user(request, response):
user = await User.create(**request.validated_data.model_dump())
return response.json(user.to_dict(), status_code=201)
ParameterTypeEffect
model_classtypeThe Tortoise model to read
namestrName of the generated class; defaults to <Model>Schema
excludelist[str]Fields to omit
includelist[str]If set, only these fields
optional_fieldslist[str]Fields to mark Optional with a None default

fields_map includes forward and reverse relations, and _tortoise_to_python_type has no case for either, so both fall through to the str fallback.

pydantic_model_from_tortoise(Tag, name="TagSchema")
# article: Optional[str] <- the ForeignKeyFieldInstance
# article_id: Optional[int] <- the underlying column, correct
pydantic_model_from_tortoise(Article, name="ArticleSchema")
# tags: Optional[str] <- a reverse relation, meaningless

The article_id column is the one you want; the article relation accessor is not a value a client can send. Exclude relation accessors and reverse relations explicitly, and let the _id columns through:

TagCreate = pydantic_model_from_tortoise(
Tag,
name="TagCreate",
exclude=["id", "created_at", "updated_at", "deleted_at", "article"],
)
Tortoise fieldGenerated typeConsequence
IntField, SmallIntField, BigIntFieldintFine
FloatFieldfloatFine
DecimalFieldfloatPrecision loss — money should not round-trip through a binary float
BooleanFieldboolFine
CharField, TextFieldstrNo max_length constraint carried over
DatetimeField, DateFieldstrNo parsing or validation — any string passes
TimeDeltaFieldfloat
JSONFielddictA JSON array fails validation
anything else (UUID, enum, relations, binary)strSilently wrong

Two of these deserve a decision rather than a shrug.

DecimalField → float means a price stored as 19.99 becomes a float that cannot represent 19.99 exactly. For monetary values, hand-write the field as Decimal or store integer cents.

DatetimeField → str means the schema accepts "tomorrow" and "not a date" without complaint, and your handler passes that straight to the database. Override it.

CharField(max_length=50) generating a bare str means the length constraint is enforced only by the database, which raises after the round trip instead of returning a clean 422.

The generated class is an ordinary Pydantic model, so subclass it to correct the types that matter and add the constraints that were lost:

fixing what the generator cannot know
from datetime import datetime
from decimal import Decimal
from pydantic import EmailStr, Field
_Base = pydantic_model_from_tortoise(
Product,
name="_ProductBase",
exclude=["id", "created_at", "updated_at", "deleted_at", "reviews"],
)
class ProductCreate(_Base):
name: str = Field(min_length=1, max_length=200)
price: Decimal = Field(gt=0, max_digits=10, decimal_places=2)
available_from: datetime

Subclassed fields replace the generated ones by name, so you only restate what the generator got wrong. This is the pattern to reach for whenever a model has money, dates, emails, URLs, or enums in it — which is most models.

For a PATCH endpoint, mark every field optional so a client can send one key, and combine with exclude_unset=True when applying:

PATCH
UserUpdate = pydantic_model_from_tortoise(
User,
name="UserUpdate",
exclude=["id", "created_at", "updated_at", "deleted_at"],
optional_fields=["name", "email", "bio"],
)
@app.patch("/users/{user_id}", request_model=UserUpdate)
async def update_user(request, response, user_id: str):
user = await User.get_or_none(id=user_id)
if user is None:
return response.json({"error": "Not found"}, status_code=404)
await user.update_from_dict(
request.validated_data.model_dump(exclude_unset=True)
)
return response.json(user.to_dict())

optional_fields has to list every field you want optional — there is no “all optional” switch. Miss one and a PATCH that omits it fails validation.

Generation earns its keep when a model is wide, flat, and made of plain scalars — an internal admin CRUD table, a settings row, a lookup table.

Hand-write the schema when the model has relations, money, dates, enums, or any field where the API’s shape differs from the table’s. That is most public endpoints. A request schema is part of your API contract; a table is an implementation detail, and coupling them means a column rename becomes a breaking API change.

Do not return raw IntegrityError messages from a public endpoint. They name tables and columns and enable user enumeration.

Do not register a handler for Exception before the specific ones. It catches everything first.

Do not generate a schema without exclude. id, created_at, and updated_at come out required.

Do not let relation accessors into a generated schema. They map to str.

Do not accept DecimalField as float for money. Override with Decimal.

Do not trust a generated DatetimeField. It is an unvalidated string.

Do not couple a public API schema to a table. Hand-write it.

register_db_exception_handlers costs nothing at request time — handler lookup is a dict access, and only on the exception path.

pydantic_model_from_tortoise calls pydantic.create_model, which builds and compiles a validator. Do it once at import time, never inside a request handler. A generated schema built per request would recompile the validator on every call.

Validation itself is pydantic-core, which is Rust and fast. The expensive part of a request is the database round trip, not the schema.

NameSignatureNotes
register_db_exception_handlers(app) -> NoneRegisters all four handlers
handle_does_not_exist(request, response, exc)404
handle_integrity_error(request, response, exc)409; includes str(exc)
handle_validation_error(request, response, exc)422; includes str(exc)
handle_operational_error(request, response, exc)503; message is fixed
pydantic_model_from_tortoise(model_class, *, name="", exclude=None, include=None, optional_fields=None) -> type[BaseModel]Call at import time