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": ["recordByKey"],
"extensions": {
"code": "NOT_FOUND"
}
}
],
"data": {
"recordByKey": 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 |
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.
FORBIDDEN
The API key does not have permission for the requested operation.
{
"errors": [{
"message": "Insufficient permissions",
"extensions": { "code": "FORBIDDEN" }
}]
}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 accessed with a live key (use a test key or
preview: true)
VALIDATION_ERROR
The input data failed validation.
{
"errors": [{
"message": "Validation failed: 'title' is required",
"extensions": {
"code": "VALIDATION_ERROR",
"validationErrors": [
{ "field": "title", "message": "This field is required" }
]
}
}]
}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.
{
"errors": [{
"message": "Rate limit exceeded. Try again in 42 seconds.",
"extensions": {
"code": "RATE_LIMITED",
"retryAfter": 42
}
}]
}Resolution: Wait for the rate limit window to reset. Check the X-RateLimit-Reset header for the reset timestamp. 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.io/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 | 25 | Maximum nesting depth of a query |
| Max complexity | 1000 | Maximum computed complexity score |
| Max aliases | 10 | Maximum number of field aliases in a single query |
How Complexity Is Calculated
Each field in a query contributes to the complexity score. List fields (like records) have higher complexity because they can return many items. Nested references multiply the complexity of their children.
A simple query like this has low complexity:
query {
recordByKey(modelKey: "page", naturalKey: "home") {
id
data
}
}A deeply nested query with large lists has high complexity:
# This query may exceed complexity limits
query {
records(modelKey: "category", limit: 50) {
items {
data
resolved {
content # Resolved content adds complexity
}
}
}
}Complexity Headers
Responses include headers showing the computed complexity:
| Header | Description |
|---|---|
X-GraphQL-Max-Complexity | Computed complexity score for the query |
X-GraphQL-Max-Depth | Nesting depth of the query |
Reducing Complexity
If your query exceeds complexity limits:
- Reduce list sizes — lower the
limitargument on list queries - Remove unnecessary fields — only request the fields you need
- Avoid deep nesting — flatten your query or split into multiple requests
- Use pagination — fetch smaller pages of results rather than large batches
- Skip resolution when unnecessary — omit the
resolvedfield if you only need rawdata
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.io/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 "VALIDATION_ERROR":
throw new ValidationError(error.message, error.extensions.validationErrors);
case "FORBIDDEN":
throw new ForbiddenError(error.message);
default:
throw new Error(error.message);
}
}
return result.data;
}