Errors#
Restly’s request errors are FastAPI HTTPExceptions. FastAPI renders each
one through the exception handlers of the app, so by default every error has
FastAPI’s body: {"detail": ...}. A database conflict in a write, such as a
duplicate unique value, becomes one of these errors too: a 409.
Your own code raises errors in the same way. Raise one in any method of a view, and the request stops there. If the error comes before the commit, Restly does not commit the write.
Errors do not pass through
to_response. That
method shapes only successful responses (see
Responses). To change the error body, change the
exception handlers of the app, as in any FastAPI app.
Raise an HTTP error#
The HTTP errors live in fr.exc. Each one is a subclass of
fr.exc.RestlyHTTPError, which is
a fastapi.HTTPException:
Exception |
Status |
When |
|---|---|---|
|
The row does not exist, or the scope hides it. Restly raises it for |
|
|
The caller may not do this action. Raise it in |
|
|
The request does not fit the current state of the row, such as publishing a post that is already published. Restly raises it for a database conflict in a write. |
|
|
Restly cannot apply a value of the list params, such as a filter value that fails the validation of its field. |
Raise them from your own code: a business method, a hook or a custom route.
The publish route of the blog example raises Conflict inside its write
action, so the write is not committed:
@fr.post("/{id}/publish", status_code=200)
async def publish(self, id: int) -> Post:
post = await self.get_one(id)
async with self.write_action(self.Action.PUBLISH, obj=post):
if post.published:
raise fr.exc.Conflict("Already published")
post.published = True
return post
The full file is
blog/main.py.
A plain fastapi.HTTPException works in the same places, for a status code
that has no class in fr.exc, such as 401.
To check whether the caller may do an action, raise Forbidden from
authorize. Restly calls it at the right moment, so the
business methods stay free of permission checks.
Details
The other names in fr.exc are not HTTP errors.
fr.exc.RestlyError and
RestlyConfigurationError
report a setup mistake. The warnings
RestlyUncommittedChangesWarning,
RestlyExpireOnCommitWarning,
RestlyMisuseWarning,
RestlyDuplicateSchemaNameWarning
and
RestlySkippedFilterMethodsWarning
are there too.
Why a bad list request returns 422 or 400#
Two steps check the query string of a list endpoint, and each has its own status code:
422: the query string does not match the list params. This covers an unknown key, such as?nope=1, a sort key that the list does not have, such as?sort=nope, and a value of the wrong type for a typed parameter, such as?page_size=oops.400: the key is known, but Restly cannot apply the value. Restly raisesBadQueryParamfor a filter value that fails the field’s validation, such as?age=oldon anintfield.
The detail of a 400 names the parameter and says what is wrong:
Invalid filter value for 'age': 'old'. Input should be a valid integer, unable to parse string as an integer
Details
The text after the value is Pydantic’s reason. For a field validator of
your own, it is Pydantic’s text for your error, such as
Value error, value is too long. An exception that is not a validation
error, such as an AttributeError in your validator, is a bug and returns
500, as it does for a request body.
A react-admin view follows the same two steps. JSON in sort, range or
filter that does not have their shape gets a 422. A sort key or a filter
that the view cannot apply raises BadQueryParam. It checks a filter value
against the type of the column, with Pydantic, so "2020-01-01" filters a
date column and "false" a boolean column. A handler for
RestlyHTTPError shapes all
these 400 answers; see Change the error body.
A filter value passes the first step as text. Restly checks it against the
field only when it builds the query, so a bad filter value returns 400, not
422.
Unique values and other database conflicts: 409#
A duplicate value in a unique column, a foreign key to a row that does not
exist, a NULL in a NOT NULL column, and a failed check constraint make
SQLAlchemy raise an IntegrityError, at a flush or at the commit. In a
Restly write, the request then returns 409, not 500. The response has
FastAPI’s normal shape:
{
"detail": "Unique constraint violated on user.email"
}
The detail text depends on the database driver. Restly knows the common
PostgreSQL error codes and SQLite error messages. For other databases, it uses
a general conflict message.
The write block makes the 409. Each CRUD write, and each
write_action block in a custom route, turns the
IntegrityError into
fr.exc.Conflict, with the
IntegrityError as its __cause__. So a handler for
fr.exc.RestlyHTTPError shapes
these conflicts too; see Change the error body.
Details
Three kinds of code get the raw IntegrityError instead:
A write inside a
shared_write_action_commit()block. There the outermost block turns what escapes it into a409, so a bulk route can still catch the error of one row.Code outside a write block, such as a bare
Viewroute or a plain FastAPI route that commits itself. It owns its commit, and it must handle its own errors.after_action_commit, and the after-hooks that run after a shared commit. The write is already saved then, so a409would be wrong.
A custom route that catches the conflict of its own write_action block
catches fr.exc.Conflict:
class Action(fr.Action):
RENAME = "rename"
@fr.post("/{id}/rename", status_code=200)
async def rename(self, id: int, name: str):
tag = await self.get_one(id)
try:
async with self.write_action(self.Action.RENAME, obj=tag):
tag.name = name
except fr.exc.Conflict:
raise fr.exc.Conflict(f"A tag named {name!r} already exists")
return self.to_response(tag)
Restly rolls back the change of the action that caused the conflict, so the route can catch the conflict and write again in the same request; see When a write action raises.
A null reaches the database only when the type of the schema field allows
None. Otherwise validation rejects it first, with 422, in the create body
and in the update body; see Validation.
Change the error body for the whole app#
To replace the {"detail": ...} body, register a handler for
fr.exc.RestlyHTTPError.
FastAPI finds a handler through the class of the exception and its base
classes, so this one handler covers all four errors in the table above,
including the database conflicts. The
handler below returns an RFC 9457 problem details body:
from fastapi.responses import JSONResponse
@app.exception_handler(fr.exc.RestlyHTTPError)
async def problem_handler(request, exc):
return JSONResponse(
status_code=exc.status_code,
content={
"type": "about:blank",
"title": exc.detail,
"status": exc.status_code,
},
media_type="application/problem+json",
)
With this handler, a missing row returns
{"type": "about:blank", "title": "Doc with id 999 was not found", "status": 404}
with the application/problem+json content type. The 400 errors of the
list routes get the same body, also on a react-admin view.
Some errors are not a RestlyHTTPError, so this handler does not change them:
A
422for a request body or a parameter that fails validation, such as?page_size=oops, and for an unknown key of the list params. This is afastapi.exceptions.RequestValidationError. Register a handler for it.A plain
fastapi.HTTPException. Restly raises one for a foreign key and its relationship field that name different rows (422). Register a handler forfastapi.HTTPException.
See also#
Request Lifecycle & Hooks: where
authorizeand the business methods run, and when the commit happens.Filtering, Sorting & Pagination: the list params that the
422and400errors check.Exceptions API: every name in
fr.exc.