Errors and retries
Every error uses one envelope with a stable code to branch on, a message that says whether anything changed, and a recovery to try next.
The envelope#
An error response has an HTTP status and a JSON body with a single error object. The shape is the same for every route.
{
"error": {
"code": "validation_failed",
"message": "The request has invalid fields. No changes were applied.",
"details": {
"fields": [
{ "field": "person_id", "problem": "must be a person ID starting with per_" },
{ "field": "text", "problem": "Invalid input: expected string, received number" }
]
},
"recovery": "Correct each field listed in details.fields, then send the request again.",
"request_id": "req_01JC4N9F4X7Z0B3D6F9H2K5M8Q"
}
}| Field | Meaning |
|---|---|
code | A stable snake_case code. Branch on this and the status. |
message | What happened, for a person. It says whether anything changed. |
details | Structured context, such as the invalid fields or the ID that wasn't found. It is an empty object when there is none. |
recovery | What to do next. |
request_id | The request's ID, req_ and 26 characters. |
Wording in message and recovery can improve over time, so don't match on it. Codes don't change. Every response, successful or not, also carries the ID in an X-Request-Id header. Keep it in your logs: it identifies the request in Rigel's own.
Statuses#
| Status | Meaning | Codes |
|---|---|---|
400 | The request itself is wrong. Nothing ran. | validation_failed, bad_request |
401 | No valid session. | unauthenticated |
403 | Your role can't do that, or a sync device ID belongs to someone else. | forbidden_role, forbidden_client |
404 | The path, the household or a record isn't there, or isn't yours to reach. | not_found, and *_not_found from commands and queries |
409 | The household's state doesn't allow it, or an idempotency key was misused. | idempotency_key_reused, and codes from commands and queries |
413, 415 | The body is too large, or isn't JSON. | payload_too_large, unsupported_media_type |
429 | Too many requests. Carries Retry-After, in seconds. | rate_limited |
500 | Rigel failed. You can't tell whether the request took effect. | internal_error |
502, 503 | A service Rigel depends on couldn't be reached or isn't set up. Nothing changed, and trying again later can work. | provider_failed (browsing Google Drive), sending_unavailable and sending_not_configured (setting up a custom email domain) |
A rejected command is 404 when its code ends in _not_found, 403 for a role that can't run it, and 409 for everything else. See rejections. The file routes add their own codes, which the files guide lists.
A wrong path, an unknown command or query name, and the wrong method on a real path are all 404 not_found. There is no 405, so a GET on a command route looks like a missing route.
Validation errors#
400 validation_failed means the request doesn't fit the operation's input, and it never reached the household. details.fields lists the problems, and each has a field and a problem.
fieldis a path into the input:name,ingredients[2].quantity. A problem with the whole body has an emptyfield. A header is named in brackets, such as["idempotency-key"]for a key that isn't a command ID.- Every problem the schema finds is in one response. A rule that compares fields, such as "birth_date and birth_date_precision must be given together", is checked after the fields themselves pass, so fixing the first list can reveal one more.
- A malformed body, one that isn't JSON, is
400 bad_request. A body sent with another content type is415 unsupported_media_type.
A query's parameters are validated the same way. See limits and cursors.
Common codes#
Each command and query lists the codes it can return on its reference page. These are the ones every client meets.
| Code | What it means | What to do |
|---|---|---|
validation_failed | A field is missing, the wrong type or out of range. | Fix every field in details.fields and send it again. |
unauthenticated | The cookie is missing, expired or signed out. | Sign in and send a new cookie. See 401 and 404. |
not_found | The route doesn't exist, or the household doesn't exist or you aren't in it. | Check the path, the method, the household ID and which account the cookie belongs to. |
person_not_found and others ending in _not_found | A record the command names isn't in the household. | Read the household for the record's current ID, then send a command that fits. |
idempotency_key_reused | The key was already used for a different command or input. | Use a new key. If you are retrying, send exactly the original request. |
person_has_account and other command codes | The household's state doesn't allow the command. | Read message and recovery, change the state or the input, and send a new command with a new key. |
forbidden_role | Your role can't run it. Every member is an admin today, so you won't see it yet. A few operations, such as adding sample data, already say admins only. | Ask an admin. |
rate_limited | You sent too many requests. Uploads are the only limited route today, and they answer 429. email.draft.send uses the same code as a 409 rejection when the household has sent too much mail, with the time to try again in details.retry_at and no Retry-After. | Wait for Retry-After seconds, or until retry_at, then try again. |
internal_error | Rigel failed while handling the request. | Send it again with the same key. If it keeps failing, keep the request ID. |
Messages say whether anything changed#
A message says whether the request took effect, and never claims more than the API knows. Rejections and validation failures end with "No changes were applied." Any 4xx on a command or query means the same thing: the request was refused, and the household is as it was.
A 500 is different. The message says only that the API could not complete the request, because the failure may have come before or after the change. Send it again with the same Idempotency-Key. The household then returns the stored outcome if the first try went through and runs the command if it didn't. It never applies it twice. The same holds when a request times out or the connection drops with no response.
Errors raised before the API runs, in the network in front of it, may not have the envelope. Read the body as JSON only when Content-Type is application/json.
What is safe to retry#
| What you got | Send it again? |
|---|---|
| Any query (a GET) | Yes, whenever you like. A query changes nothing. |
| A command, after no response, a timeout or a dropped connection | Yes, with the same Idempotency-Key and body. |
500 | Yes, with the same key, after a pause that grows with each attempt. |
429 | Yes, after Retry-After seconds. |
401 | After you sign in again. The key wasn't used. |
400 or 415 | Not as it is. Fix the request. The key wasn't used. |
404 or 409 from a command | Not unchanged. The household refused it, and the same key returns the same refusal. Read the state, then send a corrected command with a new key. |
| A command with no key | Not safely. Another try can run it twice. |
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function sendCommand(household: string, name: string, input: unknown): Promise<Response> {
const key = commandId(); // one key for every attempt of this command
for (let attempt = 1; ; attempt++) {
let response: Response | undefined;
try {
response = await fetch(`https://rigel.house/api/v1/households/${household}/commands/${name}`, {
method: "POST",
headers: {
Cookie: `rigel_session=${process.env.RIGEL_SESSION}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(input),
});
} catch {
// No response: the request may or may not have arrived. The same key makes sending it again safe.
}
if (response && response.status < 500 && response.status !== 429) return response;
if (attempt === 5) throw new Error(`Gave up after ${attempt} attempts`);
const retryAfter = Number(response?.headers.get("Retry-After")) * 1000;
await sleep(retryAfter || Math.min(30_000, 500 * 2 ** attempt) * (0.5 + Math.random() / 2));
}
}commandId() is the helper from the commands guide. The function returns on any answer below 500 except 429, including a refusal, so the caller still reads the status and body and decides what a 404 or 409 means.