Rate and quota limiting
Protect your backend functions from abuse with server-side rate and quota limits.
Why Use Rate and Quota Limiting
Your backend functions are exposed to the internet. Any client can call them, and without limits, a single user or bot can overwhelm your service, exhaust your API quotas, or rack up unexpected costs.
// Without limits: any client can call this as fast as they want
@executable()
async callExternalApi(query: string): Promise<ApiResult> {
const apiKey = this.secrets['API_KEY'];
return await fetch(`https://api.example.com?q=${query}`, {
headers: { Authorization: `Bearer ${apiKey}` },
}).then((r) => r.json() as Promise<ApiResult>);
}
// With limits: 10 requests/second per user, 1000/month per user
@limits({
rateLimit: { value: 10, scope: 'user' },
quotaLimit: { value: 1000, scope: 'user', renewPeriod: 'monthly' },
})
@executable()
async callExternalApi(query: string): Promise<ApiResult> {
const apiKey = this.secrets['API_KEY'];
return await fetch(`https://api.example.com?q=${query}`, {
headers: { Authorization: `Bearer ${apiKey}` },
}).then((r) => r.json() as Promise<ApiResult>);
}
With one decorator, you get server-side enforcement that no client can bypass.
Overview
The @limits decorator lets you define rate limits (requests per second) and quota limits (total calls per time period) on any executable, webhook, or OpenAPI function. Limits are enforced server-side before your function body runs.
When to use
| Scenario | Recommendation |
|---|---|
| Prevent burst abuse (e.g., rapid-fire API calls) | Use a rate limit |
| Cap total usage over a billing period | Use a quota limit |
| Protect an expensive external API | Use both together |
| Limit unauthenticated access | Use global or ip-scoped limits |
| Per-user fair usage | Use user-scoped limits with authentication |
How it works
- You add the
@limitsdecorator to a backend function - Squid registers the limits when you deploy the backend
- On each call, Squid checks all limits before executing the function
- If any limit is exceeded, the call is rejected with an error and your function body does not run
- Rate limit buckets refill gradually; quota limits renew on a fixed period
Quick Start
Prerequisites
- A Squid backend project initialized with
squid init-backend - The
@squidcloud/backendpackage installed
Step 1: Add the @limits decorator
Import limits and apply it to your function:
import { executable, limits, SquidService } from '@squidcloud/backend';
export class ExampleService extends SquidService {
@limits({ rateLimit: 5, quotaLimit: 200 })
@executable()
async greet(name: string): Promise<string> {
return `Hello, ${name}!`;
}
}
This limits greet to 5 calls per second globally and 200 calls per month globally.
Step 2: Start or deploy the backend
For local development, run the backend locally using the Squid CLI:
squid start
To deploy to the cloud, see deploying your backend.
Step 3: Observe limit behavior
Call the function from your client. After exceeding the limit, subsequent calls are rejected:
try {
const result = await squid.executeFunction('greet', 'World');
console.log(result);
} catch (error) {
console.error('Limit exceeded:', error.message);
}
The @limits Decorator
The @limits decorator takes two optional parameters: rateLimit and quotaLimit.
rateLimit
The rateLimit can be defined in three forms:
- A number that represents the number of queries per second you want to limit the function to. This defaults to
globalscope:
@limits({ rateLimit: 5 })
- An object which enables scope customization. The
scopeparameter can beuser,ip, orglobal:
@limits({ rateLimit: { value: 7, scope: 'user' } })
- A list of objects which allows for stacking multiple limits:
@limits({
rateLimit: [
{ value: 5, scope: 'user' },
{ value: 10, scope: 'ip' }
]
})
All limits in this list are consumed for each query. Even if the first consumed limit signals that it's been exceeded, all other limits will be consumed before the exception is returned to the client. If multiple limits are rejecting the same query, the first one to reject will be the one that is returned to the client.
quotaLimit
The quotaLimit can be defined in the same three forms:
- A number that represents the total number of times the function can be queried. This defaults to
globalscope andmonthlyrenewal period:
@limits({ quotaLimit: 5 })
- An object which enables scope and renewal period customization:
@limits({ quotaLimit: { value: 7, scope: 'user', renewPeriod: 'annually' } })
Note: scope and renewPeriod are optional and the global and monthly defaults still apply if not provided.
- A list of objects that allows for stacking multiple limits:
@limits({
quotaLimit: [
{ value: 7, scope: 'user', renewPeriod: 'monthly' },
{ value: 20, scope: 'user', renewPeriod: 'annually' }
]
})
Using both rate and quota limits
You can use the two parameters together to define both rate and quota limits:
@limits({
rateLimit: [
{ value: 5, scope: 'user' },
{ value: 10, scope: 'ip' }
],
quotaLimit: [
{ value: 7, scope: 'user', renewPeriod: 'monthly' },
{ value: 20, scope: 'user', renewPeriod: 'annually' }
]
})
See the enforcement section for more information on how these limits are evaluated.
Understanding Limits
You can define any number of limits. All limits are consumed for each query. Once any limit is exceeded, it will override all other limits and the query will be rejected.
For example, suppose a function has a monthly quota of 5 queries and an annual quota of 10 queries:
@limits({
quotaLimit: [
{ value: 5, renewPeriod: 'monthly' },
{ value: 10, renewPeriod: 'annually' }
]
})
If 5 queries are made in the first week of a month, then no more queries are allowed for the remainder of the month, even though the annual quota has not been reached. Similarly, if 5 queries per month are made in the first two months of the year, totaling 10 queries, then no more queries are allowed for the remainder of the year, even though the monthly quota has not been reached for remaining months.
Enforcement
Rate limits are always evaluated and can be consumed even when a query is rejected. Let's explore what this means with two examples.
Hitting the rate limit
Take this example configuration:
@limits({ rateLimit: 5, quotaLimit: 20 })
Let's timeline the budget:
| Event | Rate Budget Remaining | Quota Budget Remaining | Outcome |
|---|---|---|---|
| Starting values | 5 | 20 | |
| Make 5 queries | 0 | 15 | Queries succeed |
| Make a 6th query | 0 | 15 | Rate limit rejection |
The rate limit will reject the 6th query and the quota limit will not be consumed.
Hitting the quota limit
Exceeding the quota, however, will always involve consuming the rate limit.
Take this example configuration:
@limits({ rateLimit: 10, quotaLimit: 5 })
Let's timeline the budget:
| Event | Rate Budget Remaining | Quota Budget Remaining | Outcome |
|---|---|---|---|
| Starting values | 10 | 5 | |
| Make 5 queries | 5 | 0 | Queries succeed |
| Make a 6th query | 4 | 0 | Quota limit rejection |
The quota limit will reject the 6th query but the rate limit will still experience a consumption down to a budget of 4.
When a limit is exceeded
When a limit is surpassed, the function will return an exception with a message "Rate limit on name exceeded" or "Quota on name exceeded" as appropriate.
The name is a string that contains the name of the function, the scope, and (if it is a quota limit) the renew period. If the scope is user or IP, then the user ID or IP address will be included in the string.
User/IP-based limits when the user or IP is not known
When defining user/IP-based limits, if a given client's user or IP is not known for any reason, then they will be bucketed with all other unknown clients to be a single unknown entity. In other words, all users that are not logged in will be considered a single user and consume from the same rate/quota bucket.
For example, with a given limit:
@limits({ rateLimit: { value: 7, scope: 'user' } })
Each logged-in user will have their own bucket of 7 queries per second, while all unknown users will share a single bucket of 7 queries per second.
Atomicity
In cases where queries are submitted as a batch, and the limit is hit partway through the batch of queries, then the entire batch will be rejected. This ensures that no partial changes are made.
Refills & renewals
Quota renewals
Unused quota does not carry-over from one period to the next. Each quota has a renewal period defined, and their exact durations are as follows:
| Period | Duration |
|---|---|
| hourly | 1 hour |
| daily | 1 day |
| weekly | 7 days |
| monthly | 30 days |
| quarterly | 90 days |
| annually | 365 days |
Squid renews quotas in two ways, whichever comes first:
- Periodically: At the top of the hour (the 0th minute of each hour), each quota limit is checked to see if it is eligible for renewal.
- On demand: If a query exceeds the quota, but the quota is eligible for renewal at that time, then it will be renewed.
The start time of the quota period is the time that specific quota (unique combination of function, scope, renew period, and value) was first introduced in a backend deployment.
Rate limit refills
The consumption bucket refills gradually and allows bursts of up to 3x the given rate.
Gradual refill as an example: If you define the limit @limits({ rateLimit: 5 }) and a client exceeds the limit, then the client only needs to wait 1/5th of a second (0.2s) before they can make another query.
Changes to the limits
You can change limits at any time by deploying a new backend. For quotas, changes to the limit value for a specific "limit combo" (a unique combination of function, scope, and renewPeriod) will reset the active count. For example, if a user has made 10 calls and the limit is changed from 20 to 15, then the user will be able to make 15 more calls (not 5). If a new backend deployment makes no changes to a given "limit combo", then the active count will not be reset.
Error Handling
Error types
When a limit is exceeded, the client receives an error with HTTP status code 429 (Too Many Requests). The error message indicates which limit was exceeded:
- Rate limit:
"Rate limit on <name> exceeded" - Quota limit:
"Quota on <name> exceeded"
The <name> includes the function name, scope, and (for quotas) the renewal period, so you can identify exactly which limit was hit.
Client-side handling
Catch limit errors on the client and handle them gracefully:
try {
const result = await squid.executeFunction('callExternalApi', query);
console.log(result);
} catch (error: any) {
if (error?.statusCode === 429 || error?.message?.includes('limit')) {
console.warn('Rate or quota limit exceeded. Try again later.');
// Show a user-friendly message or implement backoff
} else {
console.error('Unexpected error:', error.message);
}
}
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Getting 429 errors unexpectedly | Unknown users share a single bucket for user-scoped limits | Require authentication so each user gets their own bucket |
| Limit resets after every deploy | Changing the limit value resets the active count | Keep the value stable if you want counts to persist across deploys |
| Quota not renewing when expected | Renewal is checked hourly or on next exceeded call | Wait for the next hourly check, or make another call to trigger on-demand renewal |
| Rate limit too strict for bursts | Default bucket allows 3x burst, but limit may be too low | Increase the rate limit value to accommodate expected burst patterns |
Best Practices
Choose the right scope
global: Use for system-wide protection (e.g., limiting total load on an external API). Every caller shares the same bucket.ip: Use when you need per-client limits but don't have authentication. Good for public endpoints.user: Use for per-user fair usage. Requires authentication to be effective; otherwise, all unauthenticated users share one bucket.
Set appropriate values
- Base rate limits on your external API's limits or your service's capacity.
- Set quota limits based on expected usage patterns per billing period.
- Start with generous limits and tighten them based on observed traffic.
Layer rate and quota limits
Use both together for defense in depth:
- Rate limits protect against burst abuse (e.g., a bot firing 100 requests per second)
- Quota limits protect against sustained abuse (e.g., a user making 10,000 requests over a month)
Pair with authentication
For user-scoped limits to be meaningful, users must be authenticated. Otherwise all anonymous users share a single bucket and one abusive client can exhaust the limit for everyone. Always pair user-scoped limits with an authentication check:
@limits({ rateLimit: { value: 10, scope: 'user' }, quotaLimit: { value: 500, scope: 'user', renewPeriod: 'monthly' } })
@executable()
async protectedAction(data: string): Promise<string> {
this.assertIsAuthenticated();
// ...
return `Processed: ${data}`;
}
For more on securing your backend functions, see using auth in the backend.
Code Examples
API proxy with layered limits
This example shows a realistic service that proxies requests to an external API with both rate and quota limits, authentication, and error handling:
import { executable, limits, SquidService } from '@squidcloud/backend';
interface TranslationResult {
translatedText: string;
detectedLanguage: string;
}
export class TranslationService extends SquidService {
@limits({
rateLimit: [
{ value: 5, scope: 'user' },
{ value: 20, scope: 'global' },
],
quotaLimit: [
{ value: 100, scope: 'user', renewPeriod: 'daily' },
{ value: 2000, scope: 'global', renewPeriod: 'monthly' },
],
})
@executable()
async translate(text: string, targetLang: string): Promise<TranslationResult> {
this.assertIsAuthenticated();
if (!text || text.length > 5000) {
throw new Error('Text must be between 1 and 5000 characters');
}
const apiKey = this.secrets['TRANSLATION_API_KEY'];
const response = await fetch('https://api.translation.example.com/v1/translate', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ text, target: targetLang }),
});
if (!response.ok) {
console.error('Translation API error:', response.status);
throw new Error('Translation service unavailable');
}
return response.json() as Promise<TranslationResult>;
}
}
This configuration provides four layers of protection:
- Per-user rate limit (5/sec): Prevents a single user from hammering the endpoint
- Global rate limit (20/sec): Caps total throughput to protect the external API
- Per-user daily quota (100/day): Fair usage limit per user
- Global monthly quota (2000/month): Budget protection for the external API
Understanding the impacts on your account
When a function call is rejected due to your defined limit being exceeded, it will not count toward your billable usage. However, Squid maintains quotas relevant to your billing plan and will count all of your queries toward your billing plan, regardless of whether they are rejected by your defined limits.
For example, if you define a quota limit:
@limits({ quotaLimit: 5 })
And you make 8 queries, the first 5 will succeed and the last 3 will be rejected. You will only be billed for the 5 successful queries but Squid will have counted 8 queries toward the quota for your account.
For more information on Squid's quotas and billing, view the Quotas and limits documentation.