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)