Skip to Content
API ReferenceErrors & Rate Limits

Errors and Rate Limits

This page covers the error format, common error codes, rate limiting, and query complexity limits for the Foir API.

Error Format

Foir returns errors in the standard GraphQL error format. Errors are included in the errors array alongside any partial data:

{ "errors": [ { "message": "Record not found", "path": ["page"], "extensions": { "code": "NOT_FOUND" } } ], "data": { "page": null } }

Each error object contains:

FieldTypeDescription
messageStringHuman-readable error description
path[String]GraphQL path to the field that caused the error
extensions.codeStringMachine-readable error code

The full set of machine-readable extensions.code values is:

CodeMeaning
INVALID_ARGUMENTInput was malformed or failed validation
NOT_FOUNDThe requested resource does not exist
ALREADY_EXISTSA conflicting resource already exists (e.g. natural key clash)
FAILED_PRECONDITIONThe request is valid but not allowed in the current state
PERMISSION_DENIEDThe API key lacks permission for the operation
UNAUTHENTICATEDThe request is missing valid authentication
RATE_LIMITEDThe rate limit has been exceeded
TIMEOUTThe request timed out
UNAVAILABLEThe service is temporarily unavailable
CANCELEDThe request was canceled
INTERNALAn unexpected server error occurred

Common Error Codes

UNAUTHENTICATED

The request is missing a valid API key or the key is invalid.

{ "errors": [{ "message": "Authentication required", "extensions": { "code": "UNAUTHENTICATED" } }] }

Common causes:

  • Missing x-api-key header
  • API key is invalid, expired, or deleted
  • API key has been disabled in the dashboard

Resolution: Verify your API key is correct and active. Check that the x-api-key header is included in your request.

PERMISSION_DENIED

The API key does not have permission for the requested operation.

{ "errors": [{ "message": "Insufficient permissions", "extensions": { "code": "PERMISSION_DENIED" } }] }

Common causes:

  • API key lacks the required scope (e.g., attempting records:write with a read-only key)
  • Public key attempting a write operation
  • Domain restriction mismatch
  • Customer JWT missing or invalid for customer-scoped resources

Resolution: Check the scopes assigned to your API key in the dashboard. Use a secret key for write operations.

NOT_FOUND

The requested resource does not exist.

{ "errors": [{ "message": "Record not found", "extensions": { "code": "NOT_FOUND" } }] }

Common causes:

  • Record ID or natural key does not exist
  • Record has been deleted
  • Model key is incorrect
  • Record is unpublished and was read without preview (request drafts with preview: true using a key that holds the drafts:read scope)

INVALID_ARGUMENT

The input data was malformed or failed validation.

{ "errors": [{ "message": "Validation failed: 'title' is required", "extensions": { "code": "INVALID_ARGUMENT" } }] }

Common causes:

  • Required fields are missing
  • Field values do not match the expected type
  • Field values violate validation rules (e.g., max length, pattern)
  • Natural key conflicts with an existing record

RATE_LIMITED

The API key has exceeded its rate limit. Rate-limited requests are rejected with HTTP status 429 and a plain error body:

{ "errors": [{ "message": "Rate limit exceeded" }] }

The response also carries a Retry-After header (seconds until the window resets) alongside the standard X-RateLimit-Reset header.

Resolution: Wait for the rate limit window to reset. Check the Retry-After or X-RateLimit-Reset header for when to retry. Consider optimizing your request patterns or requesting a higher limit.

Rate Limiting

API requests are rate-limited per API key. Rate limits protect the platform from abuse and ensure fair usage across all projects.

Default Limits

The default rate limit is 10,000 requests per hour per API key. This limit is configurable per key in the dashboard, and enterprise plans support custom limits.

Rate Limit Headers

Every API response includes rate limit headers:

HeaderDescriptionExample
X-RateLimit-LimitMaximum requests allowed per window10000
X-RateLimit-RemainingRequests remaining in the current window9847
X-RateLimit-ResetUnix timestamp when the window resets1743523200

Handling Rate Limits

Check the headers proactively to avoid hitting limits:

async function foirQuery(query, variables) { const response = await fetch("https://api.foir.dev/graphql", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.FOIR_API_KEY, }, body: JSON.stringify({ query, variables }), }); const remaining = parseInt(response.headers.get("X-RateLimit-Remaining"), 10); const resetAt = parseInt(response.headers.get("X-RateLimit-Reset"), 10); if (remaining < 100) { console.warn( `Rate limit low: ${remaining} requests remaining. Resets at ${new Date(resetAt * 1000).toISOString()}` ); } if (response.status === 429) { const retryAfter = resetAt - Math.floor(Date.now() / 1000); throw new Error(`Rate limited. Retry after ${retryAfter} seconds.`); } return response.json(); }

Best Practices

  • Batch operations where possible using batchRecordOperations instead of individual mutations
  • Cache responses on the client side to reduce redundant requests
  • Use webhooks or subscriptions for real-time updates instead of polling
  • Distribute requests across multiple API keys if needed for different services

Query Complexity Limits

To prevent expensive queries from degrading performance, Foir enforces limits on query complexity.

Limits

LimitDefaultDescription
Max depth20Maximum nesting depth of a query
Max complexity10000Maximum number of selected fields in a query
Max aliases1000Maximum number of field aliases in a single query

How Complexity Is Calculated

Complexity is simply the total number of fields selected across the entire query. Every field counts as one, regardless of whether it returns a single value, a reference, or a list. The complexity limit is a flat cap on that total field count — there is no per-field weighting and list fields are not multiplied. The defaults are sized for content reads, where a single page can legitimately select hundreds of fields across its sections.

A simple query like this selects only a few fields, so its complexity is low:

query { page(naturalKey: "home") { _id } }

A query that selects many fields across many sections counts toward the cap one field at a time:

query { products(first: 50) { edges { node { _id title category { _id name # each selected field adds 1 to the total } } } } }

Complexity Headers

Responses include headers showing the computed complexity:

HeaderDescription
X-GraphQL-Max-ComplexityMaximum number of selected fields allowed per query
X-GraphQL-Max-DepthMaximum nesting depth allowed per query
X-GraphQL-Max-AliasesMaximum number of field aliases allowed per query

Reducing Complexity

If your query exceeds complexity limits:

  1. Remove unnecessary fields — only request the fields you need, since every selected field counts toward the total
  2. Avoid deep nesting — flatten your query or split into multiple requests to stay under the depth limit
  3. Split large queries — fetch in multiple smaller requests rather than one query that selects everything
  4. Use pagination — fetch smaller pages of results rather than large batches
  5. Select referenced records sparingly — only pull the fields you actually use from nested references

HTTP Status Codes

The GraphQL endpoint returns these HTTP status codes:

StatusDescription
200Success (including GraphQL-level errors in the response body)
400Malformed request (invalid JSON, missing query)
401Missing or invalid authentication
403Insufficient permissions
429Rate limit exceeded
500Internal server error

Note that GraphQL typically returns 200 even when there are field-level errors. Always check the errors array in the response body in addition to the HTTP status code.

Error Handling Example

A robust error-handling pattern for API clients:

async function foirRequest(query, variables) { const response = await fetch("https://api.foir.dev/graphql", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.FOIR_API_KEY, }, body: JSON.stringify({ query, variables }), }); // Handle HTTP-level errors if (response.status === 429) { const resetAt = response.headers.get("X-RateLimit-Reset"); throw new RateLimitError(resetAt); } if (response.status === 401) { throw new AuthenticationError("Invalid API key"); } if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } const result = await response.json(); // Handle GraphQL-level errors if (result.errors?.length > 0) { const error = result.errors[0]; const code = error.extensions?.code; switch (code) { case "NOT_FOUND": return null; case "INVALID_ARGUMENT": throw new ValidationError(error.message); case "PERMISSION_DENIED": throw new ForbiddenError(error.message); default: throw new Error(error.message); } } return result.data; }
Last updated on