Errors and HTTP mapping

Copy Markdown View Source

AshOnetime.Error is a Splode error of class :invalid, so Ash recognizes it and preserves it through the action pipeline. When a protected action fails, the typed :code reaches the caller — either as the leaf error directly (single error) or inside Ash.Error.Invalid{errors: [...]} (multiple errors) — instead of being wrapped as an unknown error.

Reading the code

AshOnetime.Error.code/1 recovers the typed code from whatever Ash hands the caller, without pattern-matching the wrapper shape:

case Ash.create(changeset) do
  {:ok, record} -> record
  {:error, error} ->
    case AshOnetime.Error.code(error) do
      :nonce_already_used -> {:conflict, "nonce was already used"}
      :key_reused_with_different_request -> {:conflict, "key reused with a different request"}
      :request_in_progress -> {:conflict, "request is already processing"}  # 425 Too Early also fits
      :verification_failed -> {:unauthorized, "verification failed"}
      :verification_timeout -> {:service_unavailable, "verification timed out"}
      nil -> {:internal_server_error, "unexpected error"}  # not an ash_onetime error
    end
end

code/1 returns nil for any value that is not an AshOnetime.Error and contains no AshOnetime.Error leaf — so "ash_onetime rejected this with a known code" is cleanly distinguishable from "some other error occurred."

Class and HTTP

All AshOnetime.Error codes are class :invalid. AshJsonApi and AshGraphql auto-map class :invalid to the 4xx family. That default is correct for the client-input codes below, but a family of server-fault and transport codes overrides it to 5xx — a consumer mapping class→HTTP must special-case those (the two 5xx tables below), or a store outage, a trusted clock fault, or an internal invariant violation is mis-reported to the client as a 4xx.

This page lists every code a caller can observe from AshOnetime.Error.code/1, including the token-verification codes, the trusted-clock codes, and the store-fault/transport codes routed through the authoritative store — not only the codes raised on the happy admission path.

Client-input / operational codes (4xx)

CodeHTTPMeaning
:nonce_already_used409A one-time nonce was already spent.
:key_reused_with_different_request409/422An idempotency key was reused with a different request fingerprint.
:request_in_progress409 / 425A processing claim is still in flight for this key.
:verification_failed401A trusted verifier rejected the token.
:verification_timeout503A trusted verifier timed out (retryable).
:fingerprint_too_large422The request fingerprint exceeded its byte limit.
:fingerprint_unavailable422The request fingerprint could not be computed.
:key_too_large422A key component exceeded its byte limit.
:key_unavailable422A key component could not be resolved.
:key_resolution_failed422The key resolver callback failed.
:key_not_found404A referenced key was not found.
:scope_unavailable422A scope component could not be resolved.
:invalid_key422A key is structurally invalid.
:invalid_key_role422A key source role is unrecognized.
:invalid_window422A nonce window is malformed.
:invalid_nonce_window422The authoritative store rejected a malformed nonce window.
:invalid_expires_at422A verified expiry is malformed.
:invalid_token422A token is structurally invalid.
:malformed_token422A token envelope could not be parsed.
:invalid_key_id422A token key id is missing or out of bounds.
:invalid_namespace422A token namespace is missing or out of bounds.
:invalid_issued_at422A token issuance timestamp is malformed.
:invalid_trust_boundary422HMAC material did not prove same-service trust.
:invalid_encoding422A canonical encoding is invalid.
:noncanonical_encoding422A canonical encoding is non-canonical.
:noncanonical_envelope422A token envelope is non-canonical.
:invalid_signature422A token signature is invalid.
:signing_failed422A token could not be signed.
:invalid_message422A signer message is invalid.
:algorithm_mismatch422A token algorithm does not match.
:unsupported_algorithm422A token algorithm is not supported.
:namespace_mismatch422A token namespace does not match.
:token_too_large422A token exceeded its byte limit.
:duplicate_field422A canonical map carried a duplicate field.
:duplicate_map_key422A canonical map carried a duplicate key.
:unsupported_term422A canonical term is unsupported.
:limit_exceeded422A configured limit was exceeded.
:missing_option422A required DSL option is missing.
:invalid_option422A DSL option is invalid.
:invalid_options422DSL options are invalid.
:reserved_verification_input422Reserved verification input was supplied.
:response_rejected422The response classifier rejected the result.
:response_rollback422The response classifier requested a rollback.
:response_fields_invalid422The response field allowlist is invalid.
:response_value_invalid422The response value is invalid.
:response_codec_mismatch422The persisted response codec does not match.
:response_contract_mismatch422The persisted response contract does not match.
:external_effect_unavailable422An external effect is unavailable.
:external_recovery_unavailable422External recovery is unavailable.

Server-fault codes (5xx — override the class default)

These are NOT client input. A consumer mapping class→HTTP to a blanket 4xx would mis-categorize them; the per-code HTTP below overrides the :invalid class.

CodeHTTPMeaning
:store_invariant500The authoritative store returned a result that violated an internal invariant (integrity fault, not client input).
:outcome_unknown503External recovery was ambiguous; the effect's outcome could not be determined (retryable).
:invalid_evaluated_at500The trusted evaluation clock returned an invalid time.
:response_payload_invalid500The persisted response payload is invalid.
:response_persisted_state_invalid500The persisted response state is invalid.
:response_digest_mismatch500The persisted response digest does not match the payload.
:response_classifier_failed500The response classifier callback raised.
:response_classifier_invalid500The response classifier returned an invalid disposition.
:response_codec_failed500The response codec raised.
:response_codec_invalid500The response codec output is invalid.
:response_contract_invalid500The response contract is invalid.
:response_completion_failed500Response completion failed.
:admission_request_invalid500The admission request is internally invalid.
:admission_unavailable503Admission is unavailable (fail-closed; retryable).
:telemetry_invalid500A telemetry event was invalid (internal).

Store-fault and transport codes (5xx — override the class default)

When the authoritative PostgreSQL store is unavailable or reports a fault, admission fails closed (nonces always; idempotency unless untracked execution is explicitly enabled) and the store's reason surfaces verbatim through AshOnetime.Error.code/1. A generic :store_failure covers any reason not enumerated below. Treat the 503 codes as retryable (the request may succeed on retry once the store recovers) and the 500 codes as integrity or configuration faults that will not clear by retrying.

CodeHTTPMeaning
:checkout_unavailable503No database connection could be checked out (pool exhausted or down).
:disconnected503The database connection dropped mid-operation.
:lock_timeout503A row lock could not be acquired within the timeout.
:dispatched_unknown503A statement was dispatched but its outcome is unknown (retryable).
:store_failure503The authoritative store failed for an unenumerated reason.
:missing_prefix500The context-tenant schema prefix is missing or out of the 1..63-byte bound (fail-closed, not truncated).
:not_in_transaction500Admission ran outside the required database transaction.
:unsupported_isolation500The connection's transaction isolation level is unsupported.
:corrupt_payload500A persisted response payload failed its integrity check.
:invalid_request500The store received an internally malformed request.

details and classified data

The details map is part of the error struct and reaches sinks that serialize or inspect the struct — inspect(error) in logs is the present-tense sink, and any future AshJsonApi/AshGraphql integration renders struct fields into HTTP/API responses. (Note: Exception.message/1 and Ash.Error.error_descriptions/1 render only the message field, not details — but details is still on the struct and should be treated as renderable-to-callers.) Never put key material, tokens, payloads, signatures, or PII in details.

The library's own call sites populate details only with non-secret context — field or option names (atoms) and numeric byte limits (e.g. %{field: :key_id, maximum: 128}). One path carries application-supplied data: when an application's key resolver returns {:error, reason}, ash_onetime wraps it as Error.new(:key_resolution_failed, ..., %{reason: reason}) (lib/ash_onetime/token.ex). A resolver that returns key material in its error reason would leak it through the rendered error. Keep resolver error reasons secret-free — return an atom or a generic message, not the key. (Audited at this release; re-audit any future call site that adds details.)