Skip to main content

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.

Backend code
// 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

ScenarioRecommendation
Prevent burst abuse (e.g., rapid-fire API calls)Use a rate limit
Cap total usage over a billing periodUse a quota limit
Protect an expensive external APIUse both together
Limit unauthenticated accessUse global or ip-scoped limits
Per-user fair usageUse user-scoped limits with authentication

How it works

  1. You add the @limits decorator to a backend function
  2. Squid registers the limits when you deploy the backend
  3. On each call, Squid checks all limits before executing the function
  4. If any limit is exceeded, the call is rejected with an error and your function body does not run
  5. 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/backend package installed

Step 1: Add the @limits decorator

Import limits and apply it to your function:

Backend code
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:

Client code
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 global scope:
Backend code
@limits({ rateLimit: 5 })
  • An object which enables scope customization. The scope parameter can be user, ip, or global:
Backend code
@limits({ rateLimit: { value: 7, scope: 'user' } })
  • A list of objects which allows for stacking multiple limits:
Backend code
@limits({
rateLimit: [
{ value: 5, scope: 'user' },
{ value: 10, scope: 'ip' }
]
})
Note

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 global scope and monthly renewal period:
Backend code
@limits({ quotaLimit: 5 })
  • An object which enables scope and renewal period customization:
Backend code
@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:
Backend code
@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:

Backend code
@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:

Backend code
@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:

Backend code
@limits({ rateLimit: 5, quotaLimit: 20 })

Let's timeline the budget:

EventRate Budget RemainingQuota Budget RemainingOutcome
Starting values520
Make 5 queries015Queries succeed
Make a 6th query015Rate 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:

Backend code
@limits({ rateLimit: 10, quotaLimit: 5 })

Let's timeline the budget:

EventRate Budget RemainingQuota Budget RemainingOutcome
Starting values105
Make 5 queries50Queries succeed
Make a 6th query40Quota 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:

Backend code
@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:

PeriodDuration
hourly1 hour
daily1 day
weekly7 days
monthly30 days
quarterly90 days
annually365 days

Squid renews quotas in two ways, whichever comes first:

  1. 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.
  2. 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.

Note

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:

Client code
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

SymptomLikely causeFix
Getting 429 errors unexpectedlyUnknown users share a single bucket for user-scoped limitsRequire authentication so each user gets their own bucket
Limit resets after every deployChanging the limit value resets the active countKeep the value stable if you want counts to persist across deploys
Quota not renewing when expectedRenewal is checked hourly or on next exceeded callWait for the next hourly check, or make another call to trigger on-demand renewal
Rate limit too strict for burstsDefault bucket allows 3x burst, but limit may be too lowIncrease 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:

Backend code
@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:

Backend code
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:

Backend code
@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.