Skip to content

Handle errors and retries

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.

Updated

Responses and errors describes what a failure looks like, and 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:

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 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 is a read, so you can repeat it until it answers:

    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 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 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 Deletes; idempotent Repeat it, or read the appointment and check for cancelled.
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, which needs raiz:contacts:write, sending the version that raiz.contacts.get answered:

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:

{
  "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. Wrap your calls so every flow waits the same way; in TypeScript, arbol is the client from createClient, as in the TypeScript SDK:

# 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"
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.

Type to search…

↑↓ navigate↵ selectEsc close