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

fr.exc.NotFound

404

The row does not exist, or the scope hides it. Restly raises it for /{id} routes and for a reference to a missing row.

fr.exc.Forbidden

403

The caller may not do this action. Raise it in authorize.

fr.exc.Conflict

409

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.

fr.exc.BadQueryParam

400

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 raises BadQueryParam for a filter value that fails the field’s validation, such as ?age=old on an int field.

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 a 409, so a bulk route can still catch the error of one row.

  • Code outside a write block, such as a bare View route 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 a 409 would 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 422 for 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 a fastapi.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 for fastapi.HTTPException.

See also#