Error Codes
Every HTTPException this package raises on its own behalf carries a stable code and the params its English detail was built from, alongside detail itself. This page covers why that exists, how to opt into it on the backend, and how the frontend reads it.
Design
detail is plain English text meant for a developer reading a response in a browser or a log, not for showing to an end user in their own language. Translating it would mean either the server picking a locale for a client it does not know, or an application parsing English sentences to recover what went wrong - both wrong. A code names the failure independently of the wording, and params are the values that wording was interpolated with, so a client can rebuild the message in any language without ever reading detail.
code and params are additive, top-level siblings - detail_code and detail_params - never nested inside detail itself: detail keeps its plain-string type for every consumer that already reads it as text, and the two new fields appear only once the application opts in:
from fastapi_viewsets.exceptions import DfViewSetError, df_viewset_exception_handler
app.add_exception_handler(DfViewSetError, df_viewset_exception_handler)An application that never registers this receives {"detail": "..."}, from FastAPI's own default HTTPException handling. A view's own raise HTTPException(status_code, detail="...") is unaffected either way - only exceptions descending from DfViewSetError carry a code.
The server never picks a locale for an error: detail is always English, and the client shows the error in its own language by its code.
The handler is registered on the application, so it shapes a DfViewSetError over REST and over muxws alike.
Backend
Built-in errors
| Class | Status | code | params |
|---|---|---|---|
NotFoundError(pk) | 404 | not_found | {pk} |
ReadOnlyCollectionError() | 405 | read_only | — |
SessionExpiredError() | 401 | session_expired | — |
NotAuthorizedError() | 403 | not_authorized | — |
RateLimitedError() | 429 | rate_limited | — |
IdempotencyKeyInvalidError() | 400 | idempotency_key_invalid | — |
IdempotencyKeyReusedError(key) | 422 | idempotency_key_reused | {key} |
ResultTimeoutError(task, timeout) | 504 | result_timeout | {task, timeout} |
ResultReaderStoppedError(queue_key) | 503 | result_reader_stopped | {queue_key} |
ActionDispatchError(task, reason=None) | 503 | action_dispatch_failed | {task, reason} |
ActionInterruptedError(task, reason) | 504 for time_limit, else 503 | action_interrupted | {task, reason} |
ActionFailedError(task, reason="error") | 500 | action_failed | {task, reason} |
UnsupportedListShapeError(shape, allowed) | 422 | unsupported_list_shape | {shape, allowed} |
EventTopicInvalidError(topic) | 422 | event_topic_invalid | {topic} |
EventTopicRefusedError(topic) | 403 | event_topic_refused | {topic} |
EventSubscriptionsExceededError(limit) | 409 | event_subscriptions_exceeded | {limit} |
EventCursorsExceededError(count, limit) | 422 | event_cursors_exceeded | {count, limit} |
CursorRequestError(error) | 400 | one of the cursor codes below | varies |
The cursor codes, raised as fastapi_viewsets.cursor.CursorError (a ValueError carrying code and params) when cursor pagination rejects a request, and surfaced through CursorRequestError:
code | params | When |
|---|---|---|
cursor_unreadable | {error} | the cursor string does not decode |
cursor_missing_position | — | it decodes but carries no position |
cursor_stale | — | it was issued for a different ordering or filter |
cursor_missing_keys | {missing} | it has no value for one or more ordering keys |
cursor_value_mismatch | {name, error} | a value in it does not fit the field's type |
from fastapi_viewsets.exceptions import NotFoundError
raise NotFoundError(pk)
# unregistered: {"detail": "Item with pk 42 not found"}
# registered: {"detail": "Item with pk 42 not found", "detail_code": "not_found", "detail_params": {"pk": 42}}Executor outcomes
The errors that end a call to a celery_viewset action without its result, by reason (see Outcomes of a call):
code | reason | The action |
|---|---|---|
action_dispatch_failed | send | was not sent: the broker is unreachable, or the message cannot be encoded |
serialization | was not sent: its arguments, context or request cannot be serialized | |
unknown_task | did not run: the worker has no task of that name | |
rejected | did not run: the worker rejected the message | |
revoked, expired | did not run: it was revoked, or expired, before it started | |
action_interrupted | time_limit | exceeded its soft or hard time limit (504) |
terminated | was terminated (503) | |
lost | lost the worker process executing it (503) | |
action_failed | error | raised an error that is not an HTTPException; detail names the task only, the error is logged in the worker |
result | returned a result that cannot be serialized, or that the caller cannot deserialize | |
executor | did not complete because the worker failed around it, or Middleware.executor_started() raised |
ResultTimeoutError and ResultReaderStoppedError descend from ActionOutcomeUnknownError: the caller stopped waiting, and the action may have run, may still run, or never will.
Custom errors
Subclass DfViewSetError for an application's own errors that should carry the same shape:
from fastapi_viewsets.exceptions import DfViewSetError
class InsufficientBalanceError(DfViewSetError):
def __init__(self, required: float, available: float):
message = f"balance {available} is short of the required {required}"
super().__init__(402, message, "insufficient_balance", {"required": required, "available": available})Registering df_viewset_exception_handler for DfViewSetError covers every subclass, this package's own errors and an application's own alike.
An application's own subclass raised by a celery_viewset action reaches the FastAPI process as a DfViewSetError with the same status, detail, code, params and headers; only the classes fastapi_viewsets.exceptions defines are rebuilt as their own class.
Frontend
@dynamicforms/fastapi-viewsets converts an error body into an ErrorDescription: code, params, the English detail and origin: 'server'. The interface has the shape of ErrorDescription in @dynamicforms/vue-forms, so the application renders and translates a server's errors with the same function as its validators' errors. The package holds no translations. How an application converts an error into text in its own language is described in vue-forms: Error messages and translation; the codes of this page are added to the application's translations next to the validators' codes.
One error
toErrorDescription(body) converts the body of a failed request:
import { toErrorDescription } from '@dynamicforms/fastapi-viewsets';
const error = toErrorDescription(body); // body: { detail, detail_code?, detail_params? }
// { code: 'not_found', params: { pk: 42 }, detail: 'Item with pk 42 not found', origin: 'server' }| Field | Value |
|---|---|
code | detail_code; an empty string where the body has none (the handler is not registered, or the error is a view's own HTTPException) |
params | detail_params as the server sent them; {} where the body has none |
detail | detail, unchanged |
origin | 'server' |
Field errors
A request whose body or parameters fail validation is answered by FastAPI with status 422 and a list of failures, one per failing value; over muxws the body is the same:
{
"detail": [
{ "type": "string_too_short", "loc": ["body", "name"], "msg": "String should have at least 3 characters", "input": "a", "ctx": { "min_length": 3 } },
{ "type": "int_parsing", "loc": ["body", "address", "zip"], "msg": "Input should be a valid integer", "input": "x" }
]
}toFieldErrors(body) converts it into errors keyed by field name. The name is loc without its first element (body, query, path, header or cookie), joined with .; a failure of the body as a whole is keyed ''. Each error has pydantic's type as code, ctx as params and msg as detail:
import { toFieldErrors } from '@dynamicforms/fastapi-viewsets';
toFieldErrors(body);
// {
// name: [{ code: 'string_too_short', params: { min_length: 3 }, detail: 'String should have at least 3 characters', origin: 'server' }],
// 'address.zip': [{ code: 'int_parsing', params: {}, detail: 'Input should be a valid integer', origin: 'server' }],
// }A name is a path through the members of a vue-forms form: a Group member by its name, a List row by its index. The errors keyed '' belong to the form itself, and so do the errors of a name the form does not hold. A SubmitAction sends the form through a ViewSet and, where the server refuses it, writes the server's errors to the form before it throws:
import {
type ApiErrorBody,
type ErrorDescription,
type FieldErrorsBody,
toErrorDescription,
toFieldErrors,
} from '@dynamicforms/fastapi-viewsets';
import { Action, type FieldBase, Group, List, SubmitAction, ValidationError } from '@dynamicforms/vue-forms';
function elementAt(root: FieldBase, name: string): FieldBase | undefined {
let element: FieldBase | null | undefined = root;
for (const key of name ? name.split('.') : []) {
if (element instanceof Group) element = element.field(key);
else if (element instanceof List) element = element.get(Number(key));
else return undefined;
}
return element ?? undefined;
}
function addErrors(element: FieldBase, errors: ErrorDescription[]) {
element.errors = [
...element.errors,
...errors.map((e) => new ValidationError(e.code, e.params, e.detail, e.origin)),
];
}
const save = new Action({
value: { label: 'Save', defaultConfirm: true },
actions: [
new SubmitAction(
(action) => action.parent?.parent,
async (value, target) => {
try {
return await itemsApi.update(value.id, value);
} catch (error: any) {
const status = error.response?.status;
const body = error.response?.data;
if (status === 422) {
for (const [name, errors] of Object.entries(toFieldErrors(body as FieldErrorsBody))) {
addErrors(elementAt(target, name) ?? target, errors);
}
} else if (typeof body?.detail === 'string') {
addErrors(target, [toErrorDescription(body as ApiErrorBody)]);
}
throw error;
}
},
),
],
});
const form = new Group({ /* the item's fields */ actions: new Group({ save }) });execute() of the action, and form.confirm(), resolve with a SubmitFailedException whose cause is the error; see vue-forms' SubmitAction. Its button in <df-actions> of @dynamicforms/vuetify-inputs is disabled while the form is invalid, so the form is not sent again until the user has changed a value the server refused.
Whether an error of origin 'server' counts toward valid and when it is removed is set by vue-forms' ErrorPolicy. By default it counts, and an element drops it when the element, or a member below it, is marked touched while the element holds a value other than the one it held when the error was written.
The codes are pydantic's error types (missing, string_too_short, int_parsing, ...), and params holds the values pydantic built msg from.
Codes
The codes this package raises, with their English detail:
code | detail | Params |
|---|---|---|
not_found | Item with pk {pk} not found | pk |
read_only | This collection is read-only | — |
session_expired | Session expired or invalid | — |
not_authorized | Not authorized to perform this action | — |
rate_limited | Rate limit exceeded | — |
result_timeout | No result for {task} within {timeout} s | task, timeout |
result_reader_stopped | The result reader for {queue_key} stopped before the result arrived | queue_key |
action_dispatch_failed | {task} could not be handed to its executor | task, reason |
action_interrupted | {task} exceeded its time limit (time_limit), {task} was terminated (terminated), {task} lost the process executing it (lost) | task, reason |
action_failed | {task} failed | task, reason |
idempotency_key_invalid | Idempotency-Key must be a non-empty Structured Field string | — |
idempotency_key_reused | Idempotency key {key} was used for a different request | key |
unsupported_list_shape | unsupported list shape '{shape}'; this endpoint offers {allowed} | shape, allowed |
event_topic_invalid | Topic {topic} is neither a path starting with / nor a source name with an optional key | topic |
event_topic_refused | Topic {topic} is not admitted | topic |
event_subscriptions_exceeded | This socket already holds {limit} topics | limit |
event_cursors_exceeded | A poll carries at most {limit} cursors, this one carries {count} | count, limit |
cursor_unreadable | cursor is not readable: {error} | error |
cursor_missing_position | cursor is not readable: no position in it | — |
cursor_stale | this cursor was issued for a different ordering or filter - start from the first page | — |
cursor_missing_keys | cursor has no value for ordering key(s): {missing} | missing |
cursor_value_mismatch | cursor value for '{name}' does not fit the field: {error} | name, error |
allowed and missing are arrays. An application's own DfViewSetError subclasses add their codes to the same translations.
