---
title: "Handle errors and retries"
description: "Apply Arbol's error and retry rules to three real flows, a booking that timed out, a version conflict on an update and a 429."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.getarbol.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Handle errors and retries

[Responses and errors](/en/developers/concepts/responses-and-errors/) describes what a failure looks like, and [Retries](/en/developers/concepts/retries/) when repeating a call is safe. This guide applies both to three situations every integration meets: a booking that timed out, a version conflict on an update, and a `429`. Read those two pages first; this one doesn't repeat them. The examples use curl and `jq`, with an API key in `ARBOL_API_KEY`, and every id and patient detail in them is an example.

## Keep what each answer says

Each decision below rests on the status, `error.code`, `error.retryable` and, when present, the `Retry-After` header. Keep the headers and the body of each answer apart, and log `metadata.requestId` with every failure:

```bash
status=$(curl --silent --show-error --max-time 30 \
  --dump-header headers.txt --output body.json --write-out "%{http_code}" \
  https://api.getarbol.com/v1/agenda/booking-requirements \
  --header "Authorization: Bearer $ARBOL_API_KEY")
jq --arg status "$status" '{$status, code: .error.code, retryable: .error.retryable, requestId: .metadata.requestId}' body.json
```

When no answer arrives at all, curl exits with an error, such as `28` when `--max-time` runs out, and the status is `000`. For a write, that's an outcome you don't know yet.

## A booking that timed out

You sent [`agenda.bookings.create`](/api/agenda/bookings/create/) and got no answer, a `504 UPSTREAM_TIMEOUT` or a `502 OUTCOME_UNKNOWN`. Whatever `retryable` says, the appointment may exist, and the booking operations take no idempotency key. Find out before you do anything else:

1. **Hold on.** Don't offer the patient another time, and don't tell them it failed: you don't know yet.

2. **Look for the appointment.** [`agenda.bookings.lookup`](/api/agenda/bookings/lookup/) is a read, so you can repeat it until it answers:

   ```bash
   curl https://api.getarbol.com/v1/agenda/bookings/lookup \
     --request POST \
     --header "Content-Type: application/json" \
     --data '{ "documentNumber": "1234567890", "documentType": "CC" }' \
     --header "Authorization: Bearer $ARBOL_API_KEY" \
     | jq '.data.appointments[] | {id, startsAt, offeringId, status}'
   ```

   Look for an appointment of the slot's `offeringId` that starts at the slot's `startsAt` and isn't `cancelled`. Compare instants, not text: the same time can be written with different offsets.

3. **If it's there, the booking went through.** Read it with [`agenda.bookings.get`](/api/agenda/bookings/get/) for its guidance and slip, and confirm it to the patient.

4. **If it isn't, send the same request again**, with the same `slotToken`. `agenda.bookings.create` is idempotent: repeating it with the same input has no further effect. If it now answers `INVALID_SLOT_TOKEN`, `RESOURCE_CAPACITY_EXCEEDED` or `SCHEDULE_CHANGED`, the time is gone: find another slot and ask the patient again.

The other booking writes need the same care, each according to its Effect:

| Operation | Effect | When its outcome is unknown |
| --- | --- | --- |
| [`agenda.bookings.reschedule`](/api/agenda/bookings/reschedule/) | Writes; not idempotent | Read the appointment with `agenda.bookings.get`. If it already starts at the new time, it moved; repeat only if it didn't. |
| [`agenda.bookings.cancel`](/api/agenda/bookings/cancel/) | Deletes; idempotent | Repeat it, or read the appointment and check for `cancelled`. |
| [`agenda.waitlist.join`](/api/agenda/waitlist/join/) | Writes; idempotent | Repeat it: the same patient, service and reason return the same open entry. |

## A version conflict on an update

An update that takes `expectedVersion` applies only if nobody changed the record since you read it. Here a sync job sets a Raíz contact's `summary` with [`raiz.contacts.update`](/api/raiz/contacts/update/), which needs `raiz:contacts:write`, sending the `version` that [`raiz.contacts.get`](/api/raiz/contacts/get/) answered:

```bash
curl https://api.getarbol.com/v1/raiz/contacts/cnt_00000000-0000-4000-8000-000000000009 \
  --request PATCH \
  --header "Authorization: Bearer $ARBOL_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "expectedVersion": 7,
    "summary": "Prefers to be contacted in the morning."
  }'
```

Someone changed the contact in between, so it answers `409` with this `error`:

```json
{
  "code": "VERSION_CONFLICT",
  "message": "The resource changed after it was read. Read it again and retry with the new version.",
  "retryable": false,
  "data": {}
}
```

`retryable` is `false`: the same request would fail the same way. Instead:

1. **Read the record again** with `raiz.contacts.get`, for its current content and `version`.
2. **Check whether your change is already there.** Repeating an update that already applied, such as your own earlier attempt that timed out, also answers `VERSION_CONFLICT`. If the change is there, you're done.
3. **Apply your change to what you just read**, keeping the other writer's changes, and send it with the new `version`.
4. **Stop after a few conflicts in a row**, and report them with their request ids: something else keeps changing the record.

Agenda's updates that take a version, of services, offerings, resources, schedule rules and appointments, accept only people, so they come from staff tools. They answer `409` with a code that names the record, and you handle each the same way:

| `error.code` | Message |
| --- | --- |
| `SERVICE_VERSION_MISMATCH` | The service changed after it was loaded. |
| `OFFERING_VERSION_MISMATCH` | The offering changed after it was loaded. |
| `RESOURCE_VERSION_MISMATCH` | The resource changed after it was loaded. |
| `SCHEDULE_RULE_VERSION_MISMATCH` | The schedule rule changed after it was loaded. |
| `SCHEDULE_RULES_CHANGED` | The schedule changed after it was loaded. |
| `APPOINTMENT_VERSION_MISMATCH` | The appointment changed after it was loaded. |

## A 429

`429 RATE_LIMITED` is `retryable`: wait the seconds in `Retry-After`, then send the same request again. The limit counts the requests of one credential, which is one more reason to [give each system its own key](/en/developers/authentication/#handle-keys-safely). Wrap your calls so every flow waits the same way; in TypeScript, `arbol` is the client from `createClient`, as in the [TypeScript SDK](/en/developers/sdks/typescript/):

### curl

```bash
# Sends one request; while the answer is 429, waits Retry-After seconds and sends it again.
arbol_request() {
  local attempt status delay
  for attempt in 1 2 3 4 5; do
    status=$(curl --silent --show-error --max-time 30 \
      --dump-header headers.txt --output body.json --write-out "%{http_code}" \
      --header "Authorization: Bearer $ARBOL_API_KEY" "$@") || return 1
    [ "$status" = 429 ] || break
    delay=$(awk 'tolower($1) == "retry-after:" { print $2 + 0 }' headers.txt)
    sleep "${delay:-$((2 ** attempt))}"
  done
  cat body.json
}

arbol_request --get https://api.getarbol.com/v1/agenda/nearest-booking-slots \
  --data-urlencode "serviceId=svc_00000000-0000-4000-8000-000000000001" \
  --data-urlencode "timezone=America/Bogota"
```

### TypeScript

```ts
import { ApiError } from "@arbol-ai/sdk";

// Calls the API; while the answer is 429, waits Retry-After seconds and calls again.
async function withRetryAfter<T>(call: () => Promise<T>, attempts = 5): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    try {
      return await call();
    } catch (error) {
      if (!(error instanceof ApiError) || error.status !== 429 || attempt === attempts) throw error;
      const seconds = Number(error.response.headers.get("retry-after")) || 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
    }
  }
}

const { data } = await withRetryAfter(() =>
  arbol.agenda.nearestBookingSlots.list({ serviceId: "svc_00000000-0000-4000-8000-000000000001", timezone: "America/Bogota" }),
);
```

Both stop after five attempts and give up at once when no answer arrives: for a write, that's the unknown outcome above, and waiting doesn't resolve it. Repeat a `503` or `504` the same way only for reads, with exponential backoff and jitter; a write that ended in either goes through the steps of [a booking that timed out](#a-booking-that-timed-out).

Source: https://docs.getarbol.com/en/developers/guides/handle-errors-and-retries/index.mdx
