Skip to content
Alvo is pre-v0.1: the image runs from its edge tag, and no NuGet package or release is published yet.Pre-v0.1: no release yet.Roadmap and status

Handle errors

Read Alvo's problem documents, branch on the problem type rather than the message, and tell a refusal from an empty page or a missing row.

  • The stack from Run your own descriptor, run from its alvo-help-desk directory with COMPOSE_FILE and that page’s secrets exported in this shell.
  • The reader key from Authentication and API keys (roles agent, authenticated; read scope only), with ALVO_READER_KEY_SECRET exported.
  • This page’s descriptor: tickets that each caller sees only when they filed them, no delete rule, a hook that refuses to close a ticket without a resolution, and a hook that writes a slug of at most 40 characters:
help-desk.alvo.json
{
"$schema": "https://alvo.dev/schema/v1/project.json",
"apiVersion": "alvo.dev/v1",
"name": "help-desk",
"auth": {
"roles": ["admin", "agent"]
},
"entities": {
"tickets": {
"audit": true,
"fields": {
"title": { "type": "string", "required": true, "maxLength": 120 },
"status": { "type": "enum", "values": ["open", "closed"], "default": "open" },
"resolution": { "type": "text" },
"slug": { "type": "string", "maxLength": 40 }
},
"rules": {
"list": "created_by == @user.id || 'admin' in @user.roles",
"get": "created_by == @user.id || 'admin' in @user.roles",
"create": "'agent' in @user.roles || 'admin' in @user.roles",
"update": "created_by == @user.id || 'admin' in @user.roles"
},
"hooks": {
"beforeCreate": [
{
"action": {
"mutate": {
"slug": { "$cel": "lowerAscii(replace(trim(new.title), ' ', '-'))" }
}
}
}
],
"beforeUpdate": [
{
"condition": "new.status == 'closed' && !has(new.resolution)",
"action": { "reject": "Close a ticket with a resolution: say how it was solved." }
}
]
}
}
}
}

Start the stack over it. The first command deletes the stack’s database:

Terminal
docker compose down --volumes
curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/handle-errors/01-help-desk.alvo.json
docker compose up --wait --wait-timeout 90

The responses below were captured from a real host when the site was built, so your ids will differ.

Every refusal is an RFC 9457 problem document, sent as application/problem+json. Send a field the entity does not declare:

Request
curl -sS -X POST http://localhost:8080/api/tickets \
-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \
-H "Content-Type: application/json" \
-d '{"title":"Printer on fire","priority":"high"}'

422 Unprocessable Entity

Response
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://alvo.dev/errors/validation",
"title": "Unprocessable Entity",
"status": 422,
"detail": "The request body names a field that is not writable on this entity. Send only the fields the entity declares.",
"violations": [
{
"pointer": "/priority",
"code": "unknown-field",
"message": "The request body names a field that is not writable on this entity. Send only the fields the entity declares.",
"fixSuggestion": "Remove the field, or check its spelling against the entity's declared fields."
}
]
}
MemberWhat it isUse it for
typehttps://alvo.dev/errors/<slug>, one per kind of refusalBranching in code.
statusThe HTTP status, repeatedLogging.
titleThe status’s standard phraseNothing; it repeats status.
detailA sentence for a personShowing or logging, never parsing.
violationsOne entry per reason, when there are reasons to itemiseFixing the request.

Each violation has a pointer, a stable kebab-case code, a message and a fixSuggestion. A pointer that is empty or starts with / is a JSON pointer into the request body (/priority, /rows/1/year); any other value names the query parameter it is about (filter, order, limit, offset, after, select). No violation ever repeats a value you sent. A standalone host also adds traceId, the request’s trace identifier; the responses on this site leave it out.

The slug at the end of type is the contract. detail is written for people and may change in any release; a violation’s code is stable. Take the part after the last / and switch on it:

SlugStatusWhat happenedWhat to do
validation422The body breaks the entity’s declared fields.Fix each field a violation points at.
malformed-query422The query string or query body is malformed.Fix the parameter the violation names.
unauthenticated401A key was sent and cannot be used.Fix the key; retrying will not help.
forbidden403A rule or a before-hook refused.Change the request, or call with a role the rules grant.
out-of-scope403The key’s scopes do not cover this.Grant the key the scope.
not-found404No such row, or your rules hide it.Check the id and the rules.
conflict409A unique value or a restrict reference is in the way.Send another value, or remove the reference.
destructive-change409A Management API apply would discard data.Resend with allowDestructive: true, or keep what the plan drops.
idempotency-conflict409The Idempotency-Key was used for a different request.Send a new key.
precondition-failed412The row changed since you read it.Read it again, reapply, resend.
precondition-required428A Management API write carried no If-Match revision.Read the current revision and send it.
unsupported-media-type415The body was not declared as JSON.Send Content-Type: application/json.
unreadable-request413, 408, 400The web server refused the body.Send a smaller body, in one go.
function-failed500A CEL function failed while the write was checked; nothing was written.Check the input the function reads.
internal500Something inside Alvo broke.Nothing in the request is at fault; the host’s log has the details.

The type URIs do not resolve yet. Each slug is an anchor on the Problem types page: https://alvo.dev/errors/forbidden is /reference/problem-types/#forbidden. An embedded host can compare against the public constants in AlvoProblemTypes instead of copying strings.

The Data API’s status catalogue names four causes of a 403, and three of them share the slug forbidden.

A rule refused. The operation has no rule, the row a write would store fails the rule, or the rule reads @user.id or @tenant.id and the caller has neither, or the entity is tenant-scoped and the caller has no tenant (refused before any rule runs). The detail never says which rule. This descriptor has no delete rule for tickets, so even an administrator is refused:

Request
curl -sS -X DELETE http://localhost:8080/api/tickets/44db3945-cd18-434d-8ab3-6a39ffbe79b3 \
-H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET"

403 Forbidden

Response
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://alvo.dev/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "No policy allows 'delete' on this entity."
}

A request without a key is judged by the rules too. These rules read @user.id, which such a caller lacks:

Request
curl -sS -X GET http://localhost:8080/api/tickets

403 Forbidden

Response
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://alvo.dev/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "The caller has no identity, and the policy for this operation reads one."
}

A before-hook’s reject fired. Its text is the detail, followed by the hook’s place in the descriptor. Create a ticket, then close it without a resolution:

Request
curl -sS -X POST http://localhost:8080/api/tickets \
-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \
-H "Content-Type: application/json" \
-d '{"title":"Printer on fire"}'

201 Created

Response
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
ETag: "639273155228806970"
Location: /api/tickets/44db3945-cd18-434d-8ab3-6a39ffbe79b3
{
"id": "44db3945-cd18-434d-8ab3-6a39ffbe79b3",
"title": "Printer on fire",
"status": "open",
"slug": "printer-on-fire"
}
Request
curl -sS -X PATCH http://localhost:8080/api/tickets/44db3945-cd18-434d-8ab3-6a39ffbe79b3 \
-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \
-H "Content-Type: application/json" \
-d '{"status":"closed"}'

403 Forbidden

Response
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://alvo.dev/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "Close a ticket with a resolution: say how it was solved. (refused by the before-hook at '/entities/tickets/hooks/beforeUpdate/0')"
}

A before-hook computed a value its field cannot hold. The slug of a long title is longer than 40 characters. The caller never sent slug, so this is not a 422: the detail names the hook, the field and the facet, never the value:

Request
curl -sS -X POST http://localhost:8080/api/tickets \
-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \
-H "Content-Type: application/json" \
-d '{"title":"The third-floor printer prints every page twice since Monday"}'

403 Forbidden

Response
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://alvo.dev/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "The before-hook at '/entities/tickets/hooks/beforeCreate/0' computed a value for 'slug' that breaks the 'max-length' facet the field declares: A value is longer than the 40 characters the field declares. Nothing was written."
}

The key’s scopes do not cover the operation. This is the one 403 with its own slug, because its fix is different: change the key, not a rule. The reader key may read and not write:

Request
curl -sS -X POST http://localhost:8080/api/tickets \
-H "X-Alvo-Api-Key: reader.$ALVO_READER_KEY_SECRET" \
-H "Content-Type: application/json" \
-d '{"title":"Printer on fire"}'

403 Forbidden

Response
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://alvo.dev/errors/out-of-scope",
"title": "Forbidden",
"status": 403,
"detail": "The presented API key's scopes do not permit this operation. Grant the key the scope it needs."
}

The Management API also answers forbidden when a caller lacks the project access level a route needs; see Apply and evolve your descriptor. Writing a computed field is also a 403 forbidden, while writing a readOnly field is a 422 validation (#344).

A list rule is a filter, not a gate. A caller whose rule matches no row gets 200 and an empty page, the way PostgreSQL row-level security behaves. The reader has filed no tickets, so its list is empty even though one exists:

Request
curl -sS -X GET http://localhost:8080/api/tickets \
-H "X-Alvo-Api-Key: reader.$ALVO_READER_KEY_SECRET"

200 OK

Response
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"items": [],
"next": null,
"count": null
}

So: if you wrote a rule and a caller gets an empty page, it is your rule. If a caller gets a 403 on a read, it is one of the causes in step 3, not the condition you wrote. Access rules has the full table.

The get, update and delete rules filter rows the same way, so a row the caller may not see is answered exactly like a row that does not exist. The administrator files a ticket; the agent asks for it by id:

Request
curl -sS -X POST http://localhost:8080/api/tickets \
-H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \
-H "Content-Type: application/json" \
-d '{"title":"Renew the TLS certificate"}'

201 Created

Response
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
ETag: "639273155229340680"
Location: /api/tickets/36458b27-bd1a-4f91-ae53-b9e7d55ad9e8
{
"id": "36458b27-bd1a-4f91-ae53-b9e7d55ad9e8",
"title": "Renew the TLS certificate",
"created_by": "8d4e2a6c-1b3f-4c5d-8e7a-9f0b1c2d3e02"
}
Request
curl -sS -X GET http://localhost:8080/api/tickets/36458b27-bd1a-4f91-ae53-b9e7d55ad9e8 \
-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET"

404 Not Found

Response
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "https://alvo.dev/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "The requested record was not found."
}

The two cases are one answer on purpose: a different answer would tell anyone holding an id that the row exists. To check a rule without a request as that caller, ask the Management API’s policy/simulate (Access rules).

A body that is not declared as application/json, or a request with no Content-Type at all, is refused before the body is read. Nothing in the body is wrong, so there are no violations; the header in the answer names the accepted type:

Request
curl -sS -X POST http://localhost:8080/api/tickets \
-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \
-H "Content-Type: text/plain" \
-d '{"title":"Printer on fire"}'

415 Unsupported Media Type

Response
HTTP/1.1 415 Unsupported Media Type
Content-Type: application/problem+json
Accept-Post: application/json
{
"type": "https://alvo.dev/errors/unsupported-media-type",
"title": "Unsupported Media Type",
"status": 415,
"detail": "This endpoint reads a JSON request body. Send it with 'Content-Type: application/json'."
}

The fix is the header. Many HTTP clients send text/plain when you pass a string as the body, or no type at all; set the header yourself. Write data safely explains why the requirement exists.

unauthenticated means a key was sent and cannot be used: a wrong secret, a revoked or expired key, a key not issued for the requested tenant, or a key with a role the descriptor does not declare. A request without any key is not a 401; the rules judge it, as step 3 showed.

Request
curl -sS -X GET http://localhost:8080/api/tickets \
-H "X-Alvo-Api-Key: agent.this-is-not-the-secret-of-this-key"

401 Unauthorized

Response
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
{
"type": "https://alvo.dev/errors/unauthenticated",
"title": "Unauthorized",
"status": 401,
"detail": "The presented API key could not be used. Check the key, whether it has been revoked or has expired, and whether it was issued for the tenant you requested."
}

Three problem types come from Alvo’s exception handler rather than from an endpoint: unreadable-request, internal and function-failed. The standalone host registers that handler:

AlvoHost.cs (registration)
builder.Services.AddAlvoProblemDetails();
AlvoHost.cs (pipeline)
app.UseExceptionHandler();

An embedded host decides for itself. With both calls, those three failures on Alvo’s own routes get the documents above, and a failure on the host’s own routes is left to the host’s handlers. Without them, which is the default, Alvo writes nothing for them: the exception reaches your own error handling and logging, and your app renders the 500 or 400 it always would. Every other problem type on this page comes from the endpoints themselves and is the same in both modes.

Your own endpoints that call IAlvoData get exceptions rather than problem documents, one exception type per family above; Call Alvo from your endpoints shows how to render them.

StatusProblem typeWhenFixReturned by
422validationThe body names an unknown field, misses a required one or breaks a facet.Fix each field a violation points at.every host
403forbiddenA rule refused, a reject fired, or a hook’s value broke a facet.Read the detail; change the request or the descriptor.every host
403out-of-scopeThe key’s scopes do not cover the operation.Grant the key the scope.every host
404not-foundThe row does not exist, or the rules hide it.Check the id, then the rule with policy/simulate.every host
415unsupported-media-typeThe body is not declared as JSON.Send Content-Type: application/json.every host
401unauthenticatedThe key cannot be used.Check the secret and the key’s roles.every host
413, 408, 400unreadable-requestThe web server refused the body before Alvo read it.Send a smaller body, in one go.standalone; embedded only with AddAlvoProblemDetails()
500function-failedA CEL function failed during a write.Check the function’s input; the exception is in the host’s log.standalone; embedded only with AddAlvoProblemDetails()
500internalAn invariant inside Alvo broke.Read the host’s log.standalone; embedded only with AddAlvoProblemDetails()

Use your own authentication: let your app’s signed-in users reach Alvo’s data under the descriptor’s rules.