Handle Errors, Rate Limits and Retries

The HTTP status of every response matches the status_code in its body. On top of that, every error says what kind of error it is, which parameters it is about, and which request it was.

What an error looks like

{"status_code": 400, "status": "Bad Request", "message": "Error retrieving event(s)", "errors": ["Invalid value for the event_id parameter: abc. The event_id parameter must be the numeric id of an event."], "error_code": "invalid_request", "error_params": ["event_id"], "request_id": "3f2c9a7e4b1d4c8e9f0a1b2c3d4e5f60"}
  • error_code is stable: check it in your code instead of reading the message. New codes may be added, so handle a code you don't know by its HTTP status.
  • error_params names the parameters the errors refer to ([] when the API cannot tell).
  • request_id (also in the X-Request-Id header of every response) identifies the call. Quote it when you contact support@exhibitday.com.
error_codeHTTP statusWhat it meansWhat to do
invalid_request400A parameter is missing or has an invalid value (error_params names it when the API can tell).Fix the request; retrying it unchanged gives the same error.
malformed_json400The request body is not valid JSON.Fix the request; retrying it unchanged gives the same error.
not_allowed400The change is not allowed for that record in ExhibitDay (e.g., the event's tasks or its deletion are locked, or its Misc. Expenses & Credits tab is hidden).Fix the request; retrying it unchanged gives the same error.
idempotency_key_reused400The Idempotency-Key was already used with a different request. Use a new key for a new request.Fix the request; retrying it unchanged gives the same error.
missing_api_key401No api_key was sent.Check the api_key and the workspace's API access.
invalid_api_key401The api_key is not valid.Check the api_key and the workspace's API access.
api_access_disabled401API access is not enabled for the workspace.Check the api_key and the workspace's API access.
not_found404No record with that id exists in your workspace.Fix the request; retrying it unchanged gives the same error.
unknown_endpoint404The URL or HTTP method is not part of the API.Fix the request; retrying it unchanged gives the same error.
idempotency_key_in_use409A request with the same Idempotency-Key is still being processed. Retry after a moment.Retry after a moment with the same key.
idempotency_key_indeterminate409A previous request with this Idempotency-Key did not finish and its outcome is unknown, so this retry was not run (to avoid creating a duplicate). Check whether the earlier request succeeded; to try again, use a new Idempotency-Key.Check whether the earlier request succeeded, then retry with a new Idempotency-Key.
rate_limited429Too many requests. Wait the number of seconds in the Retry-After header, then retry.Wait Retry-After seconds, then retry.
internal_error500An unexpected error occurred. Quote the request_id when you contact support.Retry after a short wait (use an Idempotency-Key for creates).

Rate limits

A workspace can make 50 calls per second. Each response says where you stand: X-RateLimit-Limit (calls per second), X-RateLimit-Remaining (calls left this second) and X-RateLimit-Reset (seconds until the count resets). Over the limit, the API answers 429 with a Retry-After header: wait that many seconds and send the call again.

A call helper that retries the right way

It retries rate limits, a busy Idempotency-Key and server errors, waits as long as the API asks, and gives every create an Idempotency-Key so a retry never makes a second record. Errors in the request itself are raised immediately.
import time
import uuid
import requests

BASE = "https://api.exhibitday.com/v1"

def call(method, path, api_key, **kwargs):
    headers = {"api_key": api_key}
    if method == "POST":
        headers["Idempotency-Key"] = str(uuid.uuid4())  # the same key for every retry of this create
    for attempt in range(5):
        response = requests.request(method, BASE + path, headers=headers, timeout=60, **kwargs)
        if response.status_code == 429 or response.status_code >= 500 or (response.status_code == 409 and response.json().get("error_code") == "idempotency_key_in_use"):
            time.sleep(int(response.headers.get("Retry-After", 2 ** attempt)))
            continue
        body = response.json()
        if response.status_code >= 400:
            raise RuntimeError(f"{body['error_code']} {body['error_params']}: {body['errors']} (request {body['request_id']})")
        return body
    raise RuntimeError("Gave up after 5 attempts")

statuses = call("GET", "/references/payment_statuses", "YOUR_API_KEY")
print(statuses["records_found"], "payment statuses")
try:
    call("GET", "/events", "YOUR_API_KEY", params={"event_id": "abc"})
except RuntimeError as error:
    print("Refused as expected:", error)

Poll without downloading the same data again

Every successful GET has an ETag header. Send it back in If-None-Match: while nothing changed, the API answers 304 Not Modified without a body. Pair it with the changed-since filter of Sync Events and Tasks to Another System for the lightest polling.
curl -i "https://api.exhibitday.com/v1/references/payment_statuses" -H "api_key: YOUR_API_KEY" -H 'If-None-Match: "ETAG_FROM_THE_LAST_RESPONSE"'