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.
Before you start
Section titled “Before you start”- The stack from Run your own descriptor, run from its
alvo-help-deskdirectory withCOMPOSE_FILEand that page’s secrets exported in this shell. - The
readerkey from Authentication and API keys (rolesagent,authenticated; read scope only), withALVO_READER_KEY_SECRETexported. - This page’s descriptor: tickets that each caller sees only when they filed them, no
deleterule, a hook that refuses to close a ticket without a resolution, and a hook that writes a slug of at most 40 characters:
{ "$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:
docker compose down --volumescurl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/handle-errors/01-help-desk.alvo.jsondocker compose up --wait --wait-timeout 90The responses below were captured from a real host when the site was built, so your ids will differ.
1. Read a problem document
Section titled “1. Read a problem document”Every refusal is an RFC 9457 problem document, sent as
application/problem+json. Send a field the entity does not declare:
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
HTTP/1.1 422 Unprocessable EntityContent-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." } ]}| Member | What it is | Use it for |
|---|---|---|
type | https://alvo.dev/errors/<slug>, one per kind of refusal | Branching in code. |
status | The HTTP status, repeated | Logging. |
title | The status’s standard phrase | Nothing; it repeats status. |
detail | A sentence for a person | Showing or logging, never parsing. |
violations | One entry per reason, when there are reasons to itemise | Fixing 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.
2. Branch on the slug, never on detail
Section titled “2. Branch on the slug, never on detail”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:
| Slug | Status | What happened | What to do |
|---|---|---|---|
validation | 422 | The body breaks the entity’s declared fields. | Fix each field a violation points at. |
malformed-query | 422 | The query string or query body is malformed. | Fix the parameter the violation names. |
unauthenticated | 401 | A key was sent and cannot be used. | Fix the key; retrying will not help. |
forbidden | 403 | A rule or a before-hook refused. | Change the request, or call with a role the rules grant. |
out-of-scope | 403 | The key’s scopes do not cover this. | Grant the key the scope. |
not-found | 404 | No such row, or your rules hide it. | Check the id and the rules. |
conflict | 409 | A unique value or a restrict reference is in the way. | Send another value, or remove the reference. |
destructive-change | 409 | A Management API apply would discard data. | Resend with allowDestructive: true, or keep what the plan drops. |
idempotency-conflict | 409 | The Idempotency-Key was used for a different request. | Send a new key. |
precondition-failed | 412 | The row changed since you read it. | Read it again, reapply, resend. |
precondition-required | 428 | A Management API write carried no If-Match revision. | Read the current revision and send it. |
unsupported-media-type | 415 | The body was not declared as JSON. | Send Content-Type: application/json. |
unreadable-request | 413, 408, 400 | The web server refused the body. | Send a smaller body, in one go. |
function-failed | 500 | A CEL function failed while the write was checked; nothing was written. | Check the input the function reads. |
internal | 500 | Something 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.
3. Know the four causes of a 403
Section titled “3. Know the four causes of a 403”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:
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
HTTP/1.1 403 ForbiddenContent-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:
curl -sS -X GET http://localhost:8080/api/tickets403 Forbidden
HTTP/1.1 403 ForbiddenContent-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:
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
HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8ETag: "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"}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
HTTP/1.1 403 ForbiddenContent-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:
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
HTTP/1.1 403 ForbiddenContent-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:
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
HTTP/1.1 403 ForbiddenContent-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).
4. An empty page is not a 403
Section titled “4. An empty page is not a 403”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:
curl -sS -X GET http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: reader.$ALVO_READER_KEY_SECRET"200 OK
HTTP/1.1 200 OKContent-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.
5. A 404 can be your rules
Section titled “5. A 404 can be your rules”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:
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
HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8ETag: "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"}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
HTTP/1.1 404 Not FoundContent-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).
6. A 415 is a missing header
Section titled “6. A 415 is a missing header”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:
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
HTTP/1.1 415 Unsupported Media TypeContent-Type: application/problem+jsonAccept-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.
7. A 401 means the key, not the caller
Section titled “7. A 401 means the key, not the caller”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.
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
HTTP/1.1 401 UnauthorizedContent-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."}Errors in an embedded host
Section titled “Errors in an embedded host”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:
builder.Services.AddAlvoProblemDetails();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.
What can go wrong
Section titled “What can go wrong”| Status | Problem type | When | Fix | Returned by |
|---|---|---|---|---|
| 422 | validation | The body names an unknown field, misses a required one or breaks a facet. | Fix each field a violation points at. | every host |
| 403 | forbidden | A rule refused, a reject fired, or a hook’s value broke a facet. | Read the detail; change the request or the descriptor. | every host |
| 403 | out-of-scope | The key’s scopes do not cover the operation. | Grant the key the scope. | every host |
| 404 | not-found | The row does not exist, or the rules hide it. | Check the id, then the rule with policy/simulate. | every host |
| 415 | unsupported-media-type | The body is not declared as JSON. | Send Content-Type: application/json. | every host |
| 401 | unauthenticated | The key cannot be used. | Check the secret and the key’s roles. | every host |
| 413, 408, 400 | unreadable-request | The web server refused the body before Alvo read it. | Send a smaller body, in one go. | standalone; embedded only with AddAlvoProblemDetails() |
| 500 | function-failed | A CEL function failed during a write. | Check the function’s input; the exception is in the host’s log. | standalone; embedded only with AddAlvoProblemDetails() |
| 500 | internal | An invariant inside Alvo broke. | Read the host’s log. | standalone; embedded only with AddAlvoProblemDetails() |
Reference
Section titled “Reference”- Problem types: every slug, its causes, fix and violation codes.
- C# API:
AddAlvoProblemDetails. - Design notes: the status and slug catalogue, the RLS surprise and what Alvo treats as confidential.
Use your own authentication: let your app’s signed-in users reach Alvo’s data under the descriptor’s rules.