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:
| Field | Type | Description |
|---|---|---|
message | String | Human-readable error description |
path | [String] | GraphQL path to the field that caused the error |
extensions.code | String | Machine-readable error code |
The full set of machine-readable extensions.code values is:
| Code | Meaning |
|---|---|
INVALID_ARGUMENT | Input was malformed or failed validation |
NOT_FOUND | The requested resource does not exist |
ALREADY_EXISTS | A conflicting resource already exists (e.g. natural key clash) |
FAILED_PRECONDITION | The request is valid but not allowed in the current state |
PERMISSION_DENIED | The API key lacks permission for the operation |
UNAUTHENTICATED | The request is missing valid authentication |
RATE_LIMITED | The rate limit has been exceeded |
TIMEOUT | The request timed out |
UNAVAILABLE | The service is temporarily unavailable |
CANCELED | The request was canceled |
INTERNAL | An 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-keyheader - 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:writewith 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: trueusing a key that holds thedrafts:readscope)
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:
| Header | Description | Example |
|---|---|---|
X-RateLimit-Limit | Maximum requests allowed per window | 10000 |
X-RateLimit-Remaining | Requests remaining in the current window | 9847 |
X-RateLimit-Reset | Unix timestamp when the window resets | 1743523200 |
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
batchRecordOperationsinstead 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
| Limit | Default | Description |
|---|---|---|
| Max depth | 20 | Maximum nesting depth of a query |
| Max complexity | 10000 | Maximum number of selected fields in a query |
| Max aliases | 1000 | Maximum 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:
| Header | Description |
|---|---|
X-GraphQL-Max-Complexity | Maximum number of selected fields allowed per query |
X-GraphQL-Max-Depth | Maximum nesting depth allowed per query |
X-GraphQL-Max-Aliases | Maximum number of field aliases allowed per query |
Reducing Complexity
If your query exceeds complexity limits:
- Remove unnecessary fields — only request the fields you need, since every selected field counts toward the total
- Avoid deep nesting — flatten your query or split into multiple requests to stay under the depth limit
- Split large queries — fetch in multiple smaller requests rather than one query that selects everything
- Use pagination — fetch smaller pages of results rather than large batches
- 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:
| Status | Description |
|---|---|
200 | Success (including GraphQL-level errors in the response body) |
400 | Malformed request (invalid JSON, missing query) |
401 | Missing or invalid authentication |
403 | Insufficient permissions |
429 | Rate limit exceeded |
500 | Internal 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;
}