Skip to content
Rigel docs
Open Rigel

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.

Response, 400
{
  "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"
  }
}
FieldMeaning
codeA stable snake_case code. Branch on this and the status.
messageWhat happened, for a person. It says whether anything changed.
detailsStructured context, such as the invalid fields or the ID that wasn't found. It is an empty object when there is none.
recoveryWhat to do next.
request_idThe 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#

StatusMeaningCodes
400The request itself is wrong. Nothing ran.validation_failed, bad_request
401No valid session.unauthenticated
403Your role can't do that, or a sync device ID belongs to someone else.forbidden_role, forbidden_client
404The path, the household or a record isn't there, or isn't yours to reach.not_found, and *_not_found from commands and queries
409The household's state doesn't allow it, or an idempotency key was misused.idempotency_key_reused, and codes from commands and queries
413, 415The body is too large, or isn't JSON.payload_too_large, unsupported_media_type
429Too many requests. Carries Retry-After, in seconds.rate_limited
500Rigel failed. You can't tell whether the request took effect.internal_error
502, 503A 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.

  • field is a path into the input: name, ingredients[2].quantity. A problem with the whole body has an empty field. 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 is 415 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.

CodeWhat it meansWhat to do
validation_failedA field is missing, the wrong type or out of range.Fix every field in details.fields and send it again.
unauthenticatedThe cookie is missing, expired or signed out.Sign in and send a new cookie. See 401 and 404.
not_foundThe 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_foundA 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_reusedThe 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 codesThe 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_roleYour 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_limitedYou 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_errorRigel 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 gotSend it again?
Any query (a GET)Yes, whenever you like. A query changes nothing.
A command, after no response, a timeout or a dropped connectionYes, with the same Idempotency-Key and body.
500Yes, with the same key, after a pause that grows with each attempt.
429Yes, after Retry-After seconds.
401After you sign in again. The key wasn't used.
400 or 415Not as it is. Fix the request. The key wasn't used.
404 or 409 from a commandNot 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 keyNot safely. Another try can run it twice.
TypeScript
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.

Rigel is in early access, and sign-up is by invitation. Open Rigel