Browse technical docs

Errors and Rate Limits

Every RoboSystems endpoint reports failure the same way: a non-2xx status and a small JSON body with a detail and a request_id. This page covers that body, what each status code means for an integrator and what to do about it, the rate-limit headers and 429 response, and the response an AI operation returns when a graph runs out of credits.

Table of Contents

The Error Body

An error response carries two keys:

{
  "detail": "Event already ingested for this source and external_id",
  "request_id": "7f3c9a1e-2b4d-4c6e-8f0a-1b2c3d4e5f60"
}
  • detail is usually a human-readable string. Some errors carry an object instead, with a machine-readable code and fields specific to the failure (see Typed Error Codes). Always read it as body["detail"] and check its type.
  • request_id identifies this request in the platform's logs. The same value is sent as the X-Request-ID response header on every response, success or failure.

Request validation (422) adds a code. When a body or parameter fails schema validation — a missing required field, a wrong type, a value outside its allowed pattern — the response is:

{
  "detail": "external_config.source_name: String should match pattern '^[a-z][a-z0-9_-]{1,63}$'",
  "code": "VALIDATION_ERROR",
  "request_id": "0c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f"
}

detail joins every failing field as field.path: message, separated by ; . A 422 without code: "VALIDATION_ERROR" is a business-rule refusal from the operation itself (a closed period, an unbalanced entry, an invalid status transition) — the request was well-formed, and detail says what to change.

Unexpected server errors (500) never leak internals. The body is always:

{ "detail": "Internal server error", "request_id": "…" }

The request_id is how that error is found — see Quoting a Request ID.

The SCIM provisioning surface (/scim/v2) is the one exception to this shape: it answers in the RFC 7644 error format that identity providers expect. See Enterprise SSO and SCIM.

Status Codes and What to Do

StatusMeaningWhat to do
400The request is well-formed but not acceptable as asked — a required pairing missing, a confirmation that doesn't match, an option the resource doesn't supportFix the request per detail. Do not retry unchanged
401No valid credential — the X-API-Key header is missing, malformed, revoked, or expiredCheck the header name and the key. Do not retry until fixed
402Payment is required for this action — no payment method on the organization, or the graph has no credits for an AI operationSee Insufficient Credits. Not retryable until an owner acts
403Authenticated, but not allowed — no access to this graph, a role too low for a write, a key scoped to a different graph, a feature not provisioned on this graph, or a write to a read-only shared repositoryCheck the graph id, the key's scope, and your role. A graph that does not exist also answers 403, so a typo in graph_id looks like this
404The named resource doesn't exist in this graph — an event, a period, an operation whose records have expiredCheck the id. For extension operations, a 404 can also mean the graph's RoboLedger or RoboInvestor data has not been initialized
409Conflict with current state. Three kinds: an Idempotency-Key in use or reused with a different body; a duplicate (an event with the same source + external_id, a connection that already exists); a row locked by another writer, such as a running sync ("Retry in a moment")Read detail. Retry the lock case after a short delay; treat a duplicate as already done; fix the key for a key conflict
413The work is too large for the graph's tier, e.g. a materialization over its limitsReduce the input or change tier
422Validation (code: "VALIDATION_ERROR") or a domain rule refusal (no code)Fix the request per detail. A retry of the same body fails the same way
429Rate limit exceededWait for Retry-After seconds, then retry. See Rate Limits
500Unexpected server errorRetry once with the same Idempotency-Key; if it persists, report the request_id
503A dependency is temporarily unavailable, or a lock needed for the operation could not be takenRetry with backoff
504The request exceeded a time limit — for extension operations, the per-statement ceilingNarrow the request (filters, a smaller page) or retry after Retry-After

Two patterns cover most integration code:

  • Retry: 409 lock conflicts, 429, 500, 503, 504 — with backoff, and with the same Idempotency-Key so a retry that races a success cannot double-write (see Operations Contract).
  • Stop and fix: 400, 401, 403, 404, 413, 422 — the same request will fail the same way.

Typed Error Codes

Where a client needs to branch programmatically, detail is an object with a code:

{
  "detail": {
    "code": "INSUFFICIENT_CREDITS",
    "message": "Not enough credits to perform AI analysis",
    "required_credits": 42,
    "available_credits": 10
  },
  "request_id": "…"
}

The codes an integrator is most likely to meet:

CodeWhereMeaning
VALIDATION_ERRORAny endpoint, 422Schema validation failed (top-level code, string detail)
INSUFFICIENT_CREDITSAI Operator endpoints, 402The graph's credit pool cannot cover the call
OPERATOR_FAILED, OPERATOR_CANCELLEDAI Operator endpointsThe operator run failed or was cancelled
WRITE_BACK_FAILED, STATEMENT_STAMP_FAILEDclose-period, 422The close could not complete a publishing step; detail explains which

close-period also returns its blocking conditions as structured detail so a client can show what stands in the way of a close. Most other refusals are plain strings — branch on the status code first, then on detail.code when detail is an object.

GraphQL Errors

GraphQL at /extensions/{graph_id}/graphql follows GraphQL conventions rather than the REST body above: a query that reaches the resolver returns HTTP 200 with an errors array, and each error carries a machine-readable code in extensions.code:

{
  "data": { "trialBalance": null },
  "errors": [
    { "message": "…", "path": ["trialBalance"], "extensions": { "code": "LEDGER_NOT_INITIALIZED" } }
  ]
}

Branch on extensions.code (UNAUTHENTICATED, FORBIDDEN, INVALID_PAGINATION, LEDGER_NOT_INITIALIZED, INVESTOR_NOT_INITIALIZED), not on message. An HTTP 401 or 403 from the GraphQL endpoint means the request was refused before any query ran. See GraphQL Reads.

Quoting a Request ID

Every response — success or error — carries an X-Request-ID header, and every error body repeats it as request_id. It is the one value that lets the platform find your exact request in its logs, alongside the operation, graph, and timing.

When you report a problem, include:

  • the request_id (or X-Request-ID header),
  • the method and path (with your graph_id),
  • the approximate time (UTC) and the status code you received,
  • for an async operation, the operationId from the envelope.

Never include your API key. Log request_id on every non-2xx response in your integration so it is on hand when you need it. Report issues through GitHub Issues or Discussions; anything security-sensitive goes through the repository's security policy instead of a public issue.

Rate Limits

Rate limits protect shared infrastructure from bursts. They are separate from credits: a rate limit never consumes credits, and an exhausted credit pool never changes a rate limit.

How they are shaped.

  • Limits are per endpoint category — graph reads, graph writes, Cypher queries, MCP, AI Operators, search, backups, extension GraphQL reads, extension operations, and so on — so a burst of one kind of traffic does not starve another.
  • Windows are short — the configured limits are per minute. They absorb normal bursts rather than cap monthly volume; volume is what credits and tier limits are for.
  • Categories served by the graph's own dedicated instance (queries, reads, writes, MCP, operators, analytics) scale with the graph's tier and are counted per graph. Categories that land on shared infrastructure (search, backups, imports, management, sync) are the same on every tier and are counted per user.
  • Shared repositories such as sec carry their own per-plan limits — see Shared Repositories.

Where the numbers are. The limits are configuration, served live rather than restated here:

  • GET /v1/graphs/{graph_id}/limits — the graph's rate_limits block (what the limiter enforces for its tier), alongside its storage, backup, and credit limits.
  • GET /v1/offering and GET /v1/graphs/tiers — each tier's api_rate_multiplier, and for shared repositories each plan's rate_limits.

Headers on a successful response. Rate-limited endpoints report where you stand:

HeaderMeaning
X-RateLimit-LimitRequests allowed in the current window for this category
X-RateLimit-RemainingRequests left in the current window
X-RateLimit-TierThe tier the limit was taken from (tier-aware categories)
X-RateLimit-CategoryThe endpoint category that was counted (tier-aware categories)

The 429 response.

HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: …
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1790000000
X-RateLimit-Tier: ladybug-standard
X-RateLimit-Category: extensions_write
{
  "detail": "Rate limit exceeded for extensions write operations.",
  "request_id": "…"
}

Retry-After is in seconds; X-RateLimit-Reset is a Unix timestamp in seconds. Limits outside the tier-aware categories (authentication, SSE connections, billing) send X-RateLimit-Type in place of the tier and category headers. Wait at least Retry-After before retrying; spreading a backfill evenly across windows is faster overall than bursting and backing off.

Server-Sent Event streams have their own limit on concurrent connections per user — see Operations Contract.

Insufficient Credits

Only AI operations consume credits (see Credits and Billing). When a graph's pool cannot cover an AI Operator call, the operator endpoints (POST /v1/graphs/{graph_id}/operator and POST /v1/graphs/{graph_id}/operator/{operator_type}) refuse it up front with 402:

{
  "detail": {
    "code": "INSUFFICIENT_CREDITS",
    "message": "Not enough credits to perform AI analysis",
    "required_credits": 42,
    "available_credits": 10
  },
  "request_id": "…"
}

required_credits is an estimate made before the call; the actual charge is the measured token cost afterwards. An AI run that is already queued re-checks the balance when it starts, and reports the same code as an operation failure if the pool was spent in the meantime.

Nothing else is affected: queries, operations, syncs, imports, and backups do not use credits and keep working with an empty pool. Check the balance with GET /v1/graphs/{graph_id}/credits.

A different 402 appears when creating a graph for an organization with no payment method on file — detail says so, and an owner resolves it in the app.

Retry Policy

A sound default for an integration:

  1. Send an Idempotency-Key on every write.
  2. On 429, sleep Retry-After seconds, then retry.
  3. On 409 lock conflicts, 500, 503, and 504, retry with exponential backoff and jitter (for example 1s, 2s, 4s, 8s, capped), reusing the same Idempotency-Key.
  4. On anything else in the 4xx range, stop and surface detail and request_id.
  5. Cap total attempts; a request that keeps failing is a bug to report, not a load to push harder.

Self-hosted deployments

Rate limiting is controlled by RATE_LIMIT_ENABLED, and the limit table lives in robosystems/config/rate_limits.py. A local stack runs with billing off (BILLING_ENABLED=false), so credit checks do not gate AI calls there. Locally, the request_id of an error is searchable in the API logs (just logs api).

Wiki Guides:

API Reference:

Support