Rate Limiting
Understanding rate limits and best practices for the Loopwise Admin API
Note: The Loopwise Admin API is currently under development and not yet available for public use. This documentation is provided for preview purposes only.
Overview
The Loopwise Admin API implements rate limiting to ensure system stability and fair usage across all users. Rate limits are applied per API key and vary based on the type of operation being performed.
Rate Limit Tiers
Rate limits are differentiated by operation type to balance system resources while providing a good developer experience:
| Operation Type | Limit | Window | Typical Use Cases |
|---|---|---|---|
| Mutations | 200 requests | per minute | Creating, updating, or deleting resources |
| Queries | 600 requests | per minute | Reading data, listing resources |
These limits are designed to accommodate:
- Batch operations (e.g., enrolling multiple students)
- Data synchronization workflows
- Dashboard and reporting applications
- Real-time data queries
How Rate Limiting Works
Identification
Rate limits are tracked per API key. Each API key has its own independent rate limit counters for mutations and queries.
Time Windows
Rate limits use a sliding window of 1 minute:
- The counter tracks requests in the past 60 seconds
- Once a request is older than 60 seconds, it no longer counts toward your limit
- This provides smooth, continuous access rather than hard resets at fixed intervals
Operation Detection
The API automatically detects whether a request is a mutation or query:
- Mutation: GraphQL query string starts with
mutation - Query: All other GraphQL operations (including introspection queries)
Example:
# This counts as a MUTATION (200/minute limit)
mutation {
enrollStudentToCourse(input: { ... }) {
enrollment { id }
}
}
# This counts as a QUERY (600/minute limit)
query {
courses(first: 50) {
nodes { id name }
}
}Rate Limit Headers
When you exceed the rate limit, the API returns a 429 status code with headers to help you track your usage:
Response Headers (429 Only)
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 45
X-RateLimit-Limit: 200
X-RateLimit-Reset: 1699012860Retry-After: Number of seconds you should wait before making another requestX-RateLimit-Limit: The maximum number of requests allowed in the current windowX-RateLimit-Reset: Unix timestamp indicating when the rate limit window resets
Error Response Format
When you exceed a rate limit, you'll receive a structured GraphQL error response:
{
"errors": [{
"message": "Rate limit exceeded for mutations. Please retry after 45 seconds.",
"extensions": {
"code": "RATE_LIMIT_EXCEEDED",
"operation_type": "mutations",
"limit": 200,
"retry_after": 45,
"reset_at": 1699012860
}
}]
}Error Response Fields
| Field | Description |
|---|---|
message | Human-readable error message |
code | Error code: RATE_LIMIT_EXCEEDED |
operation_type | Either mutations or queries |
limit | The rate limit that was exceeded |
retry_after | Seconds to wait before retrying |
reset_at | Unix timestamp when the limit resets |
Concurrency and Execution-Time Limits
Request counts do not capture how expensive a GraphQL operation is, so a second layer limits how much work one credential can have in progress. It applies to every credential type (API keys, OAuth applications, vendor apps, installed apps), not only API keys.
| Budget | Limit | Notes |
|---|---|---|
| Operations in flight | 10 at a time | The 11th concurrent operation is rejected until one finishes |
| Execution time | 60 seconds per 60-second window | A token bucket charged with each operation's server-side execution time; it refills continuously, so one 4-second operation costs the same as forty 100 ms ones |
Enforcement is enabled per credential. Until it is enabled for yours, an operation over either budget still runs and the response is 200; the RateLimit headers below are reported either way, so you can bring your usage under the budgets before enforcement reaches you.
Headers (every executed operation)
Unlike the per-minute limits, these budgets are reported on every response whose operation reached execution — admitted or rejected — so you can pace yourself before being rejected. The values describe the budgets after that operation was charged:
RateLimit-Policy: "active-operations";q=10;qu="concurrent-requests"
RateLimit: "active-operations";r=9
RateLimit-Limit: 60000
RateLimit-Remaining: 58240
RateLimit-Reset: 2RateLimit-Policy/RateLimit: the concurrency budget, in the structured fields of the IETF RateLimit header draft —qis the quota,quits registered unit,rthe operations you can still startRateLimit-Limit/RateLimit-Remaining/RateLimit-Reset: the execution-time budget under the older header names — the budget in milliseconds, what is left of it, and the seconds until it is full again. The draft has no quota unit for execution time, which is why this budget is not inRateLimit-Policy
Responses that never reach the limiters — authentication failures, an invalid API-Version, GraphQL syntax or validation errors — carry no budget headers. All of these fields are exposed to cross-origin browser clients.
Rejection
A rejected operation is not executed. The response status is 429, Retry-After says how long to wait (1 second for a concurrency rejection, the refill time for an exhausted execution budget), and the body is a regular GraphQL errors payload so GraphQL clients see the same error:
{
"errors": [{
"message": "Rate limited by runtime: limit 60000",
"extensions": {
"code": "RATE_LIMITED",
"limiter": "runtime",
"retry_after_ms": 1820
}
}]
}| Field | Description |
|---|---|
code | Error code: RATE_LIMITED |
limiter | active_operations or runtime — which budget rejected the operation |
retry_after_ms | Milliseconds until the execution budget admits an operation again; present for runtime only |
Tell the two layers apart by the error code: RATE_LIMIT_EXCEEDED is the per-minute request count, RATE_LIMITED is a concurrency or execution-time budget. Both send Retry-After, so a client that honours that header handles both.
Staying under the execution budget
- Keep operations small: paginate with 50–100 items and avoid deeply nested selections
- Do not run more than a handful of requests in parallel per credential
- Watch
RateLimit-Remaining; when it drops low, slow down instead of waiting for a429 - Expensive mutations (for example enrolling a member into a plan with many courses) can take seconds each — batch them sequentially, not concurrently
Best Practices
To avoid hitting rate limits:
- Respect the
Retry-Afterheader - Always wait the specified time before retrying a 429 response - Implement retry logic - Use exponential backoff with a maximum retry limit for production applications
- Cache query responses - Reduce API calls by caching frequently accessed data
- Batch operations - Use GraphQL aliases to combine multiple mutations into a single request
- Use appropriate pagination - Fetch 50-100 items per page instead of making many small requests
- Spread out non-urgent operations - Add delays between background tasks to stay under limits
Common Scenarios
High-Volume Data Synchronization: Use pagination (50-100 items/page) with 1-2 second delays between requests. Cache data when possible and run during off-peak hours.
Real-Time Dashboards: Cache aggressively (30-60 seconds), use webhooks instead of polling when available, and implement smart polling intervals.
Batch Operations: Group related operations into single requests using GraphQL aliases. Process in batches of 20-50 items with delays between batches.
Increasing Rate Limits
The default rate limits are designed to accommodate the vast majority of use cases. However, if you have a legitimate need for higher limits:
- Contact our support team with details about your use case
- Provide information about:
- Your application's purpose
- Expected request patterns
- Peak usage times
- Number of users/schools affected
- Demonstrate that you've implemented best practices (caching, batching, etc.)
We review rate limit increase requests on a case-by-case basis.
Configuration
For self-hosted deployments, rate limits can be adjusted in config/initializers/rack_attack.rb:
module AdminGraphqlRateLimits
MUTATIONS_LIMIT = 200 # requests per minute (default: 200)
QUERIES_LIMIT = 600 # requests per minute (default: 600)
PERIOD = 1.minute # time window
endAfter modifying these values, restart your Rails server for the changes to take effect.
Testing Rate Limits
When developing your integration, you can test rate limit handling:
# Test script to trigger rate limits (for testing only)
for i in {1..250}; do
curl -X POST https://teachify.io/admin/graphql \
-H "Content-Type: application/json" \
-H "X-Teachify-API-Key: your_test_key" \
-d '{"query":"mutation { enrollStudentToCourse(input: {courseId: \"test\", email: \"test@example.com\"}) { enrollment { id } } }"}' \
&
doneNote: Only test rate limits in a development or staging environment, never in production.
FAQ
Q: Are rate limits shared across multiple API keys?
No. Each API key has its own independent rate limit counters.
Q: Do failed requests count toward my rate limit?
Yes. All requests count toward your rate limit, regardless of whether they succeed or fail.
Q: Can I check my current rate limit usage without making a request?
No. Rate limit information is only provided in response headers after making a request.
Q: What happens to requests made exactly when the limit resets?
The sliding window approach means there's no hard reset. As older requests age out (after 60 seconds), your available quota gradually increases.
Q: Are GraphQL introspection queries counted?
Yes, introspection queries count as queries and use your query rate limit (600/min).
Q: Do retried requests count multiple times?
Yes. Each request attempt counts toward your limit, including retries.
Summary
- Mutations: 200/minute per API key
- Queries: 600/minute per API key
- In flight: 10 operations at a time per credential
- Execution time: 60 seconds per 60-second window per credential, reported on every executed operation via the
RateLimit-*headers - Always respect the
Retry-Afterheader when receiving a 429 response - Implement retry logic with exponential backoff
- Cache responses and batch operations to reduce API calls