Skip to content

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:

python
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 ​

ClassStatuscodeparams
NotFoundError(pk)404not_found{pk}
ReadOnlyCollectionError()405read_only—
SessionExpiredError()401session_expired—
NotAuthorizedError()403not_authorized—
RateLimitedError()429rate_limited—
IdempotencyKeyInvalidError()400idempotency_key_invalid—
IdempotencyKeyReusedError(key)422idempotency_key_reused{key}
ResultTimeoutError(task, timeout)504result_timeout{task, timeout}
ResultReaderStoppedError(queue_key)503result_reader_stopped{queue_key}
ActionDispatchError(task, reason=None)503action_dispatch_failed{task, reason}
ActionInterruptedError(task, reason)504 for time_limit, else 503action_interrupted{task, reason}
ActionFailedError(task, reason="error")500action_failed{task, reason}
UnsupportedListShapeError(shape, allowed)422unsupported_list_shape{shape, allowed}
EventTopicInvalidError(topic)422event_topic_invalid{topic}
EventTopicRefusedError(topic)403event_topic_refused{topic}
EventSubscriptionsExceededError(limit)409event_subscriptions_exceeded{limit}
EventCursorsExceededError(count, limit)422event_cursors_exceeded{count, limit}
CursorRequestError(error)400one of the cursor codes belowvaries

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:

codeparamsWhen
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
python
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):

codereasonThe action
action_dispatch_failedsendwas not sent: the broker is unreachable, or the message cannot be encoded
serializationwas not sent: its arguments, context or request cannot be serialized
unknown_taskdid not run: the worker has no task of that name
rejecteddid not run: the worker rejected the message
revoked, expireddid not run: it was revoked, or expired, before it started
action_interruptedtime_limitexceeded its soft or hard time limit (504)
terminatedwas terminated (503)
lostlost the worker process executing it (503)
action_failederrorraised an error that is not an HTTPException; detail names the task only, the error is logged in the worker
resultreturned a result that cannot be serialized, or that the caller cannot deserialize
executordid 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:

python
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:

ts
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' }
FieldValue
codedetail_code; an empty string where the body has none (the handler is not registered, or the error is a view's own HTTPException)
paramsdetail_params as the server sent them; {} where the body has none
detaildetail, 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:

json
{
  "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:

ts
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:

ts
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:

codedetailParams
not_foundItem with pk {pk} not foundpk
read_onlyThis collection is read-only—
session_expiredSession expired or invalid—
not_authorizedNot authorized to perform this action—
rate_limitedRate limit exceeded—
result_timeoutNo result for {task} within {timeout} stask, timeout
result_reader_stoppedThe result reader for {queue_key} stopped before the result arrivedqueue_key
action_dispatch_failed{task} could not be handed to its executortask, 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} failedtask, reason
idempotency_key_invalidIdempotency-Key must be a non-empty Structured Field string—
idempotency_key_reusedIdempotency key {key} was used for a different requestkey
unsupported_list_shapeunsupported list shape '{shape}'; this endpoint offers {allowed}shape, allowed
event_topic_invalidTopic {topic} is neither a path starting with / nor a source name with an optional keytopic
event_topic_refusedTopic {topic} is not admittedtopic
event_subscriptions_exceededThis socket already holds {limit} topicslimit
event_cursors_exceededA poll carries at most {limit} cursors, this one carries {count}count, limit
cursor_unreadablecursor is not readable: {error}error
cursor_missing_positioncursor is not readable: no position in it—
cursor_stalethis cursor was issued for a different ordering or filter - start from the first page—
cursor_missing_keyscursor has no value for ordering key(s): {missing}missing
cursor_value_mismatchcursor 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.

Released under the MIT License.