Skip to main content

Error Codes

Forja uses a structured error code system. Every error path in the backend has a unique, stable identifier that maps to a user-facing localized message on the frontend.

Error codes are a product feature. They enable precise error handling, clear user messaging, and targeted regression tests.


Format​

<DOMAIN>_<CAUSE>
  • DOMAIN — the feature area or resource (uppercase, no spaces)
  • CAUSE — what went wrong (uppercase, descriptive, specific)

There is no ERR_ prefix — the code is the bare screaming-snake-case string exactly as it appears in the code field of a ProblemDetails response. The frontend resolver also keys regex fallbacks off this shape (e.g. any *_NOT_FOUND code gets a generic "not found" message), so the convention is load-bearing, not cosmetic.

Examples (real codes — see the registry for the full list):

CodeMeaning
AUTH_API_KEY_INVALIDThe provided API key is invalid
WORKFLOW_REVIEW_REQUIREDEditorial workflow requires content to be submitted for review before publishing
MEDIA_UPLOAD_TOO_LARGEUploaded file exceeds the maximum allowed size
SITE_EXPORT_FORBIDDENOnly the site owner, a site admin, or a system admin may export a site archive
ENTITY_NOT_FOUNDThe requested entity instance does not exist (an entity_type field identifies which domain)

Rules:

  • Once a code is shipped, it does not change. Clients and tests depend on it.
  • Use specific causes — a dedicated code beats overloading the generic RESOURCE_NOT_FOUND / BAD_REQUEST fallbacks.
  • Do not add suffixes like _ERROR or _FAILURE — the name already implies an error context.
  • For genuinely cross-domain resources, prefer a single ENTITY_* code plus the entity_type tag over one near-identical code per domain (this is why ENTITY_NOT_FOUND / ENTITY_SLUG_TAKEN exist instead of BLOG_NOT_FOUND, PAGE_NOT_FOUND, …).

Backend to Frontend Flow​

Error codes travel from the Rust backend to the React frontend through a defined pipeline:

Step 1 — Backend: attach the code to an ApiError

ApiError is not one variant per failure. It is a small enum keyed by HTTP status (NotFound, BadRequest, Forbidden, Conflict, Gone, Locked, Internal, …), each wrapping an ErrorMeta. A specific machine code is attached with the .with_code() builder; for the cross-domain ENTITY_* codes, .with_entity_type() adds the domain tag:

// backend/src/axum_app/handlers/site.rs
return Err(ApiError::forbidden(
"Only the site owner, a site admin, or a system admin may export this site",
)
.with_code(codes::SITE_EXPORT_FORBIDDEN));

// An ENTITY_* code carries which domain it was raised for:
return Err(ApiError::not_found("CV entry not found")
.with_code(codes::ENTITY_NOT_FOUND)
.with_entity_type("cv"));

The code constant lives in backend/src/errors/codes.rs and is registered in the ALL array (one ErrorCodeDef { code, domain, http_status, description } row). If .with_code() is omitted, ApiError::code() falls back to a status-derived generic (RESOURCE_NOT_FOUND, FORBIDDEN, VALIDATION_ERROR, …).

Step 2 — Backend: ProblemDetails response (RFC 7807)

ApiError::to_problem_details() serializes the error. The bare machine code is in the code field; the RFC 7807 type field is a URI derived from it (https://forja.dev/errors/<code lowercased>) — type is not the bare code:

{
"type": "https://forja.dev/errors/site_export_forbidden",
"title": "Forbidden",
"status": 403,
"detail": "Only the site owner, a site admin, or a system admin may export this site",
"code": "SITE_EXPORT_FORBIDDEN",
"entity_type": "cv"
}

entity_type is present only for ENTITY_* codes; errors (an array of FieldError) is present only for field-level validation failures.

Step 3 — Frontend: centralized resolution

The frontend does not hand-roll if (code === …) per call site. Every error flows through resolveError() (admin/src/utils/errorResolver.ts), which detects a ProblemDetails and delegates to resolveErrorCode(code, entity_type) (admin/src/utils/errorCodes.ts). Resolution is tried in order:

  1. ENTITY_TYPE_OVERRIDES — `${code}:${entity_type}` (e.g. ENTITY_SLUG_TAKEN:blog).
  2. ERROR_CODE_MAP — exact code → i18n keys.
  3. PATTERN_FALLBACKS — regex on the code (/_NOT_FOUND$/, /^AUTH_/, /^VALIDATION_/, …) so new backend codes get a sane message with no frontend change.
  4. null — the resolver falls back to the server-supplied title / detail.

Call sites consume the resolved result through hooks, not by reading .code directly: useErrorSnackbar() for toast-style errors and useFormErrorHandler(setError) for mapping field errors onto a React Hook Form.

Step 4 — Frontend: i18n message

A resolved code maps to keys under the errorCodes namespace (not errors.<domain>.<cause>) in admin/src/i18n/locales/en.json — a message and an optional action hint:

{
"errorCodes": {
"AUTH_TOKEN_INVALID": { "message": "Session expired", "action": "Please sign in again." },
"patterns": {
"notFound": { "message": "Not found", "action": "It may have been moved or deleted." }
}
}
}

The same keys must be added to all 11 locale files (ar, de-AT, de, en, es, fr, it, nl, pl, pt, uk). A code with no entry still resolves via a patterns.* fallback or the server detail, so adding an errorCodes.<CODE> entry is only needed when you want a tailored, localized message.


Adding a New Error Code​

Follow this four-step process when introducing a new failure path:

Step 1 — Define the code

Choose a code following <DOMAIN>_<CAUSE> (no ERR_ prefix). Document it in the issue's Error Cases table before writing any code. Before adding a domain-specific code, check whether an existing ENTITY_* code plus an entity_type tag already covers it.

Step 2 — Register the code and raise it (backend)

In backend/src/errors/codes.rs, add the constant and a matching row in the ALL array:

pub const WIDGET_LIMIT_REACHED: &str = "WIDGET_LIMIT_REACHED";

// inside `pub const ALL: &[ErrorCodeDef] = &[ … ]`
ErrorCodeDef { code: WIDGET_LIMIT_REACHED, domain: "widget", http_status: 409,
description: "The site has reached its widget quota" },

Then raise it from the handler with the status constructor that matches http_status, plus .with_code():

return Err(ApiError::conflict("Widget quota reached for this site")
.with_code(codes::WIDGET_LIMIT_REACHED));

Do not add a new ApiError enum variant — the enum is intentionally status-keyed, not per-failure.

Step 3 — Add the i18n message (frontend, optional)

This is only needed when you want a tailored, localized user message — otherwise the resolver already falls back to a errorCodes.patterns.* match or the server detail. To add one, register the code in ERROR_CODE_MAP in admin/src/utils/errorCodes.ts and add the keys under the errorCodes namespace in all 11 locale files:

{
"errorCodes": {
"WIDGET_LIMIT_REACHED": {
"message": "Widget limit reached",
"action": "Remove an existing widget or upgrade your plan."
}
}
}

Step 4 — Verify, don't wire

No per-component wiring is required: useErrorSnackbar() / useFormErrorHandler() already route every error through resolveError(). Just confirm the failing call surfaces through one of those hooks (most mutations already do). Regenerate the registry table from codes.rs.


Error Code Registry​

The canonical source of all error codes is the ALL array in backend/src/errors/codes.rs — every code is one ErrorCodeDef { code, domain, http_status, description } entry, and that array is what the public GET /api/v1/error-codes catalog endpoint (ErrorCodeCatalogResponse) and the test suite assert against. The table below is generated from that array: each row's Code is the wire value sent in the type field of a ProblemDetails response (not the Rust constant name — for a few forms codes the two differ, e.g. the FORM_BOT_PROTECTION_MISSING constant is sent as BOT_PROTECTION_MISSING).

To add or change a code, edit codes.rs and regenerate this table — do not hand-edit rows here, and never reuse or repurpose a shipped code (clients and tests depend on the exact string).

The i18n Key / Added In columns from the original placeholder were dropped: no errors.* namespace exists in the locale files, and the version a code shipped in is not tracked anywhere — populating either would mean inventing data. git log -S'"THE_CODE"' -- backend/src/errors/codes.rs recovers the introducing commit if you need provenance for a specific code.

CodeDomainHTTPDescription
ENTITY_NOT_FOUNDentity404The requested entity instance does not exist (entity_type identifies which domain)
ENTITY_SLUG_TAKENentity409An entity with this slug already exists (entity_type identifies which domain)
ENTITY_LOCALIZATION_EXISTSentity400A localization for this locale already exists on the entity (entity_type identifies which domain)
ENTITY_LOCALIZATION_NOT_FOUNDentity404No localization found for the requested entity and locale (entity_type identifies which domain)
SITE_CREATE_REQUIRES_ADMINsite403Admin API key required to create sites
SITE_CREATE_INVALID_LOCALESsite400Exactly one locale must be marked as default when creating a site
SITE_CREATE_SCOPED_KEYsite403Site-scoped API keys cannot create new sites
SITE_RESTORE_EXPIREDsite410The 30-day grace window for restoring a soft-deleted site has lapsed
SITE_EXPORT_FORBIDDENsite403Only the site owner, a site admin, or a system admin may export a site archive
SITE_EXPORT_JOB_NOT_FOUNDsite404The site export job does not exist for this site, or its artifact has expired and been purged
SITE_EXPORT_ALREADY_RUNNINGsite409An export job for this site is already queued or running; wait for it to finish before requesting another
SITE_EXPORT_FAILEDsite500The export worker failed to build or store the site archive
BLOG_SAMPLE_EXISTSblog400Sample content already exists for this site
BLOG_NO_DEFAULT_LOCALEblog400No default locale configured for this site
BLOG_BULK_STATUS_REQUIREDblog400The status field is required for UpdateStatus bulk action
PAGE_BULK_STATUS_REQUIREDpage400The status field is required for UpdateStatus bulk action
CONTENT_INVALID_STATUScontent400Invalid status transition for this content item
CONTENT_PUBLISH_DATE_INVALIDcontent400publish_end must be after publish_start
CONTENT_UNKNOWN_ENTITY_TYPEcontent400Unknown content entity type
CONTENT_NO_DEFAULT_ENVIRONMENTcontent400No default environment configured
CONTENT_SLUG_GENERATION_FAILEDcontent400Could not generate a unique slug - too many copies
CONTENT_ROUTE_GENERATION_FAILEDcontent400Could not generate a unique route - too many copies
CONTENT_REVIEW_INVALID_STATUScontent400Content must be in InReview status to perform a review action
DOCUMENT_PASSWORD_REQUIREDdocument401This document is password-protected and requires a password to access
DOCUMENT_PASSWORD_INCORRECTdocument403The provided password is incorrect
DOCUMENT_ACCESS_RATE_LIMITEDdocument429Too many failed password attempts — try again later
DOCUMENT_ENCRYPTION_FAILEDdocument500Failed to encrypt the document
DOCUMENT_DECRYPTION_FAILEDdocument500Failed to decrypt the document — wrong password or corrupted data
DOCUMENT_NOT_UPLOADABLEdocument400Only uploaded documents (not URL-based) can be password-protected
DOCUMENT_EXPIREDdocument410The private document's access window has expired and it is no longer downloadable
DOCUMENT_LOCKEDdocument423The document is locked after 3 failed password attempts and must be unlocked by an admin
DOCUMENT_NOT_LOCKEDdocument409Unlock requested on a document that is not currently locked
DOCUMENT_INVALID_TTLdocument400expires_at must be in the future and at most one year ahead
MEDIA_UPLOAD_TOO_LARGEmedia400Uploaded file exceeds the maximum allowed size
MEDIA_UPLOAD_INVALID_TYPEmedia400The uploaded file type is not allowed
MEDIA_UPLOAD_EMPTYmedia400The uploaded file is empty or no file data was received
MEDIA_UPLOAD_NO_DATAmedia400No file data received in the upload request
MEDIA_UPLOAD_READ_FAILEDmedia500Failed to read the uploaded file data
MEDIA_TAG_INVALIDmedia400Invalid tag format
MEDIA_TOO_MANY_TAGSmedia400Too many tags on media file
STORAGE_QUOTA_EXCEEDEDstorage413Upload would exceed the per-site storage quota
TRASH_ALREADY_RESTOREDtrash400The item is not in the trash (already restored or never deleted)
TRASH_NOT_DELETEDtrash400The item is not soft-deleted and cannot be permanently removed from trash
AUTH_MISSING_CREDENTIALSauth401No Authorization Bearer token or X-API-Key header provided
AUTH_TOKEN_INVALIDauth401The provided authentication token is invalid or expired
AUTH_TOKEN_AUDIENCEauth401The JWT aud claim does not match the configured expected audience
AUTH_TOKEN_ISSUERauth401The JWT iss claim does not match the configured expected issuer
AUTH_INSUFFICIENT_ROLEauth403Your role does not have sufficient permissions for this action
AUTH_SITE_ACCESS_DENIEDauth403You do not have access to the requested site
AUTH_API_KEY_INVALIDauth401The provided API key is invalid
AUTH_API_KEY_SITE_DENIEDauth403The API key does not have access to the requested site
AUTH_ACCOUNT_SOLE_OWNERauth409Cannot delete account while being the sole owner of a site
AUTH_CLERK_NOT_CONFIGUREDauth500Clerk authentication service is not configured
AUTH_RATE_LIMITEDauth429Too many failed authentication attempts — wait before retrying
API_KEY_PERMISSION_EXCEEDEDapi_key403Your role cannot create API keys with the requested permission level
API_KEY_INVALID_STATUSapi_key422Invalid API key status value
API_KEY_INVALID_PERMISSIONapi_key422Invalid API key permission value
API_KEY_SITE_FILTER_REQUIREDapi_key403Site admins must specify a site_id filter when listing API keys
LOCALE_CODE_TAKENlocale409A locale with this code already exists
LOCALE_DELETE_IN_USElocale409Cannot delete locale that is assigned to one or more sites
SITE_LOCALE_LAST_LANGUAGEsite_locale409Cannot remove the last language from a site
MEMBER_ALREADY_EXISTSsite_membership409The user is already a member of this site
MEMBER_ROLE_OWNER_REQUIREDsite_membership403Only the site owner can assign Admin or Owner roles
MEMBER_CANNOT_REMOVE_OWNERsite_membership403Cannot remove the site owner - transfer ownership first
MEMBER_REQUIRES_CLERK_AUTHsite_membership400This operation requires Clerk JWT authentication
SITE_OWNER_CANNOT_LEAVEsite_membership403Site owners must transfer ownership before leaving
LEGAL_PUBLISH_MISSING_TITLElegal400Legal document must have a title before publishing
LEGAL_PUBLISH_MISSING_BODYlegal400Legal document must have content before publishing
LEGAL_VERSION_SOURCE_DELETEDlegal404Cannot create a version from a deleted document
CV_BULK_STATUS_REQUIREDcv400The status field is required for UpdateStatus bulk action on CV entries
PROJECT_BULK_STATUS_REQUIREDproject400The status field is required for UpdateStatus bulk action on projects
REDIRECT_SAME_PATHredirect400Source and destination redirect paths must be different
WEBHOOK_TEST_FAILEDwebhook500The webhook test delivery failed
WEBHOOK_URL_SSRFwebhook400The webhook URL targets a private or internal network address
WEBHOOK_INVALID_DEBOUNCEwebhook422Debounce value must be between 0 and 300 seconds
WEBHOOK_INVALID_STATS_WINDOWwebhook400Stats time window must be one of: 1h, 24h, 7d, 30d
NOTIFICATION_ACCESS_DENIEDnotification403You can only access your own notifications
NOTIFICATION_REQUIRES_CLERKnotification403Notification endpoints require Clerk JWT authentication
ENVIRONMENT_NO_DEFAULTenvironment404No default environment is configured
ANALYTICS_NOT_ENABLEDanalytics403Analytics is not enabled for this site
AI_NOT_CONFIGUREDai400AI is not configured for this site
AI_PROVIDER_UNAVAILABLEai503The AI provider is unavailable or returned an error
AI_RESPONSE_PARSE_FAILEDai500Failed to parse the AI provider response
AI_TRANSLATE_INVALIDai400Invalid translation request content or missing fields
AI_URL_SSRFai400The AI base URL targets a private or internal network address
AI_VISION_MISSING_IMAGEai400image_url is required for vision actions (auto_tag, alt_text)
AI_SECTION_CONTEXT_INSUFFICIENTai400section_context with section_type is required for section_content action
AI_SECTION_TYPE_UNKNOWNai400Unknown page-section type — must be one of the SectionType enum values
AI_CONTEXT_INSUFFICIENTai400Content is too short for the AI to produce a useful suggestion (e.g. blog body shorter than the minimum word count for tag generation)
CLERK_NOT_CONFIGUREDclerk500Clerk service is not configured
CLERK_USER_NOT_FOUNDclerk404The requested Clerk user does not exist
CLERK_API_FAILEDclerk500Clerk API request failed
CLERK_INVALID_IDENTIFIERclerk400Invalid user identifier format
CLERK_INVALID_ROLEclerk422Invalid role value
WORKFLOW_REVIEW_REQUIREDworkflow403Editorial workflow requires content to be submitted for review before publishing
WORKFLOW_INVALID_STATUSworkflow403Reviewers can only transition content that is InReview
WORKFLOW_NO_PERMISSIONworkflow403You do not have permission to change content status
MODULE_NOT_ENABLEDmodule403The requested module is not enabled for this site
ONBOARDING_REQUIRES_CLERKonboarding400Onboarding progress requires Clerk authentication
FORM_DUPLICATE_FIELD_LABELSforms400Form definition contains duplicate field labels
FORM_TEMPLATE_NAME_EXISTSforms409Another form template on this site already uses this name
BOT_PROTECTION_MISSINGforms400Form requires a bot_protection_token but none was provided
BOT_PROTECTION_INVALIDforms400Captcha provider reported the supplied token did not pass verification
BOT_PROTECTION_NOT_CONFIGUREDforms503Form is Mandatory-protected but the site has no captcha verifier configured
BOT_PROTECTION_PROVIDER_ERRORforms503Captcha provider was unreachable or returned an unexpected response
FORM_VALIDATION_FAILEDforms400One or more submission fields failed validation
FORM_CONSENT_REQUIREDforms400Form requires consent but consent_given was false
SUBMISSION_DELETEDforms410Submission has been deleted (idempotent self-service signal)
INVALID_REFERENCE_CODEforms404Reference code does not match any submission
INVALID_STATUS_TRANSITIONforms400Requested submission status transition is not allowed
VALIDATION_REQUIRED_FIELDvalidation422A required field is missing
VALIDATION_INVALID_FORMATvalidation422A field has an invalid format
VALIDATION_OUT_OF_RANGEvalidation422A field value is outside the allowed range
RATE_LIMIT_EXCEEDEDsystem429Too many requests - rate limit exceeded
SERVICE_UNAVAILABLEsystem503A required service is temporarily unavailable
INTERNAL_ERRORsystem500An unexpected internal error occurred
DATABASE_ERRORsystem500A database error occurred
STORAGE_ERRORsystem500A storage backend error occurred
RESOURCE_NOT_FOUNDsystem404The requested resource was not found (generic)
BAD_REQUESTsystem400The request was malformed or invalid (generic)
VALIDATION_ERRORsystem422One or more fields failed validation (generic)
UNAUTHORIZEDsystem401Authentication is required (generic)
FORBIDDENsystem403You do not have permission for this action (generic)
CONFLICTsystem409The request conflicts with existing data (generic)
PAYLOAD_TOO_LARGEsystem413The request payload exceeds the allowed limit (generic)