Error Codes¶
Catalog of NeNe REST API error codes. The runtime source of truth is config/error_codes.php; this file restates the catalog in a form OpenAPI consumers can read alongside docs/api/openapi.yaml.
For the envelope shape rationale, see ADR 0003.
Envelope¶
Every failure response uses the same envelope, documented in OpenAPI as ApiFailureEnvelope:
{
"Result": true,
"Data": {
"status": "failure",
"errorCode": "TODO-NOT-FOUND",
"errorMessage": "TODO item was not found."
}
}
Result stays true because the response was produced by the controller (a 5xx wraps a different shape). Data.status is always failure for the codes below. The HTTP status is set by the controller based on httpStatus in the runtime catalog.
Catalog¶
| Error code | HTTP status | Message | Notes |
|---|---|---|---|
SESSION-CLOSED |
401 | Session timeout. Please log in again. | Returned when an authenticated endpoint is called without a valid PHPSESSID cookie. |
LOGIN-FAILED |
401 | Wrong user ID or user PASS | Returned by POST /session/login for rejected credentials. |
CSRF-TOKEN-INVALID |
403 | Invalid CSRF token. | Returned when a state-changing request from a logged-in client is missing or has a wrong X-CSRF-Token header. |
METHOD-NOT-ALLOWED |
405 | The HTTP method is not allowed for this endpoint. | Returned with the Allow response header listing valid methods. |
NOT-FOUND |
404 | The requested resource was not found. | Emitted by the dispatcher when the route resolves to no controller or no action and the request's Accept header prefers application/json. HTML callers still receive the static 404.html page. |
INTERNAL-ERROR |
500 | An unexpected internal error occurred. | Emitted by htdocs/index.php when an unhandled \Throwable reaches the top-level catch on a REST request. HTML callers receive the static 500.html page instead. |
ROUTE-CONFLICT |
500 | Route configuration conflict. | Internal — surfaces when controller dispatch is ambiguous. |
TODO-ID-REQUIRED |
400 | TODO id is required. | /todo/item/id_X with missing or non-numeric id. |
TODO-NOT-FOUND |
404 | TODO item was not found. | /todo/item/id_X where no row matches the signed-in user. |
TODO-TITLE-REQUIRED |
400 | TODO title is required. | POST /todo/index or PUT /todo/item/id_X with empty title. |
UPLOAD-FILE-REQUIRED |
400 | Upload file is required. | Thrown by UploadedFile::validate() when the upload is missing or is_uploaded_file() returns false. |
UPLOAD-TOO-LARGE |
413 | Upload exceeds size limit. | Thrown by UploadedFile::validate(['maxBytes' => N]) when size() > N. |
UPLOAD-MIME-REJECTED |
415 | Upload mime type is not allowed. | Thrown by UploadedFile::validate(['allowedMime' => [...]]) when finfo mime is not on the allowlist. |
ACCOUNT-LOCKED |
423 | Account is locked due to too many failed login attempts. | Returned by login endpoints when LoginAttemptTracker::isLocked() is true. Cleared by reset() after successful authentication or by an admin SQL update. |
BATCH-ITEM-FAILED |
422 | One or more batch items failed. | Recorded per-item in BatchResult::addFailure() when a single batch item cannot be processed; the overall response may be 207 (partial) or 422 (all failed). |
BATCH-TOO-LARGE |
400 | Batch request exceeds the maximum number of items. | Returned by batch endpoints when the input array exceeds the configured per-endpoint maximum (guard with BATCH-TOO-LARGE before the loop). |
CIRCUIT-OPEN |
503 | The downstream service is temporarily unavailable. | Returned when a CircuitBreaker is in the OPEN state and the caller should not attempt the downstream call. See docs/development/circuit-breaker.md. |
CONFLICT |
409 | A conflicting operation is already in progress. | Reserved for operations where a second request conflicts with one already underway (e.g. duplicate idempotency key). |
FORBIDDEN |
403 | You do not have permission to perform this action. | Returned by RoleGuard::require() / requireAny() when the JWT role claim does not satisfy the endpoint's role requirement. |
INVALID-TRANSITION |
409 | The requested state transition is not allowed. | Thrown by WorkflowDefinition::assertTransition() when the from → to state pair is not in the workflow's allowed-transitions map. |
JWT-INVALID |
401 | The JWT token is invalid or expired. | Thrown by JwtCodec::require() when the Authorization: Bearer header is absent, the token is malformed, the signature is wrong, the token has expired, or the algorithm is not HS256. |
PRECONDITION-FAILED |
412 | The resource was modified by another request. Fetch the latest version and retry. | Thrown by OptimisticLock::conflict() when a conditional UPDATE affects zero rows, indicating a concurrent writer incremented the version. |
PRECONDITION-REQUIRED |
428 | If-Match header is required for this operation. | Thrown by OptimisticLock::requireVersion() when a conditional write is attempted without an If-Match header (RFC 9110 §13.1). |
RATE-LIMIT-EXCEEDED |
429 | Too many requests. Please try again later. | Thrown by RateLimiter::check() when the per-key counter exceeds the configured limit within the current window. Response includes Retry-After header. |
SIGNED-URL-EXPIRED |
410 | The signed URL has expired. | Thrown by SignedUrl::requireValid() when the expires timestamp is in the past. |
SIGNED-URL-INVALID |
403 | The signed URL is invalid. | Thrown by SignedUrl::requireValid() when the signature does not match or required parameters are missing. |
TOKEN-ALREADY-USED |
409 | The reset token has already been used. | Returned by password-reset complete endpoint when used_at is not null. |
TOKEN-EXPIRED |
410 | The reset token has expired. | Returned by password-reset complete endpoint when PasswordResetToken::isExpired() is true. |
VALIDATION-FAILED |
422 | One or more input fields failed validation. | Returned by controllers using Nene\Func\Validator when $v->passes() returns false. Use $v->errors() or $v->firstErrors() to include field-level detail in the response body. |
WEBHOOK-SIGNATURE-INVALID |
401 | Webhook signature is invalid or stale. | Returned when inbound X-Webhook-Signature header is missing, malformed, or fails HMAC verification. See docs/development/webhook-signing.md. |
Adding a new error code¶
- Add the entry to
config/error_codes.phpwithmessageandhttpStatus. - Add a row to the table above. Match the order of the runtime file so the two are easy to diff.
- If the code is referenced from a new OpenAPI endpoint, the endpoint's failure response references the shared
ApiFailureEnvelope— no per-code schema is added. The endpoint MAY include anexampleshowing the specificerrorCodevalue it produces. - The contract test (
tests/Http/OpenApiRuntimeContractTest) automatically discovers new endpoints and asserts that observed statuses appear in the documented status list. No test changes are needed for a new error code on an existing endpoint.
HTML rendering of domain failures¶
When a controller throws Nene\Xion\DomainException from inside an HTML *Action() method, the top-level catch in htdocs/index.php branches on RouteContext::isRest():
- REST callers receive the JSON
ApiFailureEnvelopecarrying the thrownerrorCode, with HTTP status from the catalog (no change from prior behavior). - HTML callers receive the static
domain-error.htmlpage at the project root with{{errorCode}}and{{errorMessage}}placeholders substituted byhtmlspecialchars-escaped values from the catalog. HTTP status still comes from the catalog (e.g.TODO-NOT-FOUND→ 404).
The HTML rendering uses a static file and simple strtr() substitution rather than Smarty so the catch path does not need to bootstrap a view layer. Replace the template at the project root to rebrand or extend the markup.
Response decoration and the error-path early-exit trap¶
NeNe currently emits no framework-level decoration on top of the envelope (no security headers, no request IDs). If a future change adds such decoration, the placement matters because several error paths exit before ControllerBase::run() returns:
ControllerBase::sessionCheck()emits theSESSION-CLOSED401 envelope (or theunauthorizedRedirect()302) and terminates.ControllerBase::run()'s CSRF check emits theCSRF-TOKEN-INVALID403 envelope and terminates.Dispatcher::outputJsonFailure()emits theMETHOD-NOT-ALLOWED405 envelope and terminates beforeControllerBase::run()is ever called.Dispatcher::notFoundResponse()emits the 404 response beforeControllerBase::run()is ever called.- The top-level
\Throwablecatch inhtdocs/index.phpruns afterrun()returned (or threw), but skipsrun()'s tail entirely.
Place cross-cutting response decoration in Nene\Xion\HttpEmitter (or wrap HttpEmitter::emit()) — not in ControllerBase::run()'s tail. Decoration added at run()'s tail will not reach 401 / 403 / 404 / 405 / 500 responses, even though it reaches every 2xx.
This is the PHP analogue of the nene2-python FT75 LIFO-middleware trap. Surveyed in FT7 (docs/field-trials/2026-05-field-trial-7.md F-6); FT8 F-4 (#331) addressed the access-log corner of it; FT14 / ADR-0007 (docs/adr/0007-response-decoration-boundary.md) introduced Nene\Xion\ResponseDecorator as the canonical boundary class. New cross-cutting concerns now plug into ResponseDecorator::headers() (via env) or ResponseDecorator::decorate() / sendHeaders() (via code) — see docs/development/security-headers.md. The "trap" wording is preserved here for historical context; the resolution is ResponseDecorator.
Related¶
config/error_codes.php— runtime catalog.docs/development/error-rendering.md— how each catalog code renders on the REST vs HTML side (404 / 500 / domain failure / CSRF / auth / 405).docs/api/openapi.yaml— OpenAPI contract; referencesApiFailureEnvelopefor every failure response.docs/api/reference-client.md— failure-mode table for external consumers.- ADR 0003 — rationale for the generic envelope shape.