SurfacedBySurfacedBy Docs

Error Handling

How to classify API errors, when to retry, and how to make write retries safe with idempotency keys.

Ask an AI:Open in ChatGPTOpen in Claude

This guide covers how to classify errors from the SurfacedBy Data API, when to retry, and how to retry writes safely.

Every error from the Data API is an RFC 7807 problem document served as application/problem+json. The body carries type, title, status, detail, and request_id; the same request id is echoed in the X-Request-Id response header. See Errors for the document shape and the slug catalogue.

Classify errors first

The fragment of the type URL (for example https://docs.surfacedby.com/v1/api/errors#rate_limited) is the stable switch point. Combine it with the HTTP status:

StatusSlugs you will seeRetry?
400idempotency_requiredNo. Add an Idempotency-Key header to the write and send it again.
401missing_api_key, invalid_api_keyNo. Fix the X-API-Key header or create a new key. A key is revoked when the workspace leaves Professional or Agency.
402domain_limit_reachedNo. The workspace tracks as many domains as its plan allows. Remove a domain or upgrade first.
403tier_required, scope_required, workspace_access_required, workspace_read_onlyNo. tier_required: the workspace plan does not include API access (Professional or Agency). scope_required: the key lacks the scope the endpoint needs; write endpoints need the write scope, which only Agency keys created with that scope carry. workspace_access_required: the member who created the key no longer has a role that allows the change. workspace_read_only: changes are paused for the workspace.
404domain_not_found, not_foundNo. domain_not_found: the domain id does not exist, was removed, or is not accessible to the key. not_found: the competitor, scan, or prompt id does not exist or is not accessible.
409domain_already_tracked, pin_limit_reached, prompt_limit_reachedNo. The domain is already tracked, the domain has as many pinned prompts as it can hold, or the workspace tracks as many prompts as its plan allows.
409idempotency_in_progressYes, after the Retry-After delay (5 seconds). The original request with this key is still running.
409idempotency_conflictNo. The key was already used with a different body. Resend the original body or use a new key.
422validation_errorNo. detail says what failed; schema failures also carry an errors array with one entry per field.
429rate_limitedYes, after the Retry-After delay.
500internal_error Yes, with backoff.
503service_unavailable, rate_limit_unavailable, service_disabledYes, with backoff. Honour Retry-After when present.

A status without a specific slug falls back to http_<status> (for example #http_404).

Respect Retry-After

Every 429 carries a Retry-After header with the number of seconds to wait, and the problem body's limit_window field says which window you hit (per-minute or per-month). Successful responses carry X-RateLimit-Limit-Minute, X-RateLimit-Remaining-Minute, X-RateLimit-Limit-Month, and X-RateLimit-Remaining-Month, so you can slow down before you reach a limit.

A per-minute limit resets within the minute. A per-month limit resets at the start of the next UTC calendar month, but its Retry-After is a flat 3600 seconds rather than the time until the reset. When limit_window is per-month, stop and alert instead of retrying. Requests sent while you are over the limit still count against the window. See Rate limits for the per-plan quotas.

import random
import time
 
import httpx
 
RETRYABLE = {409, 429, 500, 502, 503, 504}  # codegen-ignore: literal statuses
MAX_WAIT_SECONDS = 90
 
def call_with_retry(method, url, headers, json=None, max_attempts=5):
    for attempt in range(max_attempts):
        resp = httpx.request(method, url, headers=headers, json=json)
        if resp.status_code not in RETRYABLE:
            return resp
        if resp.status_code == 409 and not resp.json().get("type", "").endswith("#idempotency_in_progress"):
            return resp  # idempotency_conflict: retrying will not help
        retry_after = resp.headers.get("Retry-After")
        delay = int(retry_after) if retry_after else min(2 ** attempt, 30) + random.random()
        if resp.status_code == 429 and resp.json().get("limit_window") == "per-month":
            return resp  # monthly quota: surface it instead of retrying
        if delay > MAX_WAIT_SECONDS:
            return resp
        time.sleep(delay)
    return resp

Exponential backoff for 5xx

Retry 5xx responses with exponential backoff, not a tight loop: for example 1, 2, 4, 8, 16 seconds, capped at around 30 seconds, with a small random jitter so many clients do not retry in lockstep. A 503 caused by a slow upstream dependency also carries X-Data-Degraded: true.

Stop after a bounded number of attempts (5 is reasonable). Persistent 5xx errors indicate a real problem; retrying forever hides it. Keep the request_id from each failed response so support can trace it.

Idempotency for writes

Write requests (POST and DELETE) require an Idempotency-Key header; without one the API returns 400 idempotency_required. Set the key to a UUID you generate once per logical operation, and reuse the same key and the same body on every retry of that operation.

curl -X POST https://api.surfacedby.com/api/v1/ext/domains \
  -H "X-API-Key: sk_live_..." \
  -H "Idempotency-Key: 9f8e2b1a-0c4d-4a7e-8b3f-5d1e2a3c6d7f" \
  -H "Content-Type: application/json" \
  -d '{"domain": "yourbrand.com"}'
  • If the original request completed, a retry with the same key and body returns the stored response with X-Idempotent-Replay: true instead of running the write again.
  • If the original is still running, the retry gets 409 idempotency_in_progress with Retry-After: 5.
  • If you reuse a key with a different body while the original succeeded or is still running, you get 409 idempotency_conflict.
  • If the original failed, its key is released, so a corrected retry with the same key is processed as a new request.

See Idempotency for details.

Safe retries checklist

  • Switch on the type slug and the status. Retry only 429, 5xx, and 409 idempotency_in_progress.
  • Honour Retry-After, and do not keep retrying against a monthly quota.
  • Use exponential backoff with jitter for 5xx.
  • Cap retry attempts.
  • Send an Idempotency-Key on every write and keep it stable across retries.
  • Log the request_id (body or X-Request-Id header) so you can correlate retries.

On this page