Skip to content

Rate limiting

You’ll put a coarse limit on the whole API, a strict limit on login, key a search endpoint by API key, make sure the limiter sees real client IPs behind your proxy, and share buckets between instances.

  • A gisila project. For step 5, gisila_redis and a Redis server.
import 'package:gisila/gisila.dart';
final app = GisilaApp(
config: AppConfig(
rateLimit: const RateLimitConfig(requestsPerMinute: 600, burstSize: 100),
),
);

Each client (by IP) gets a bucket of 100 tokens refilled at 10 per second. An empty bucket answers:

HTTP/1.1 429 Too Many Requests
retry-after: 1
content-type: application/json; charset=utf-8
{"error":{"status":429,"code":"too_many_requests","message":"Rate limit exceeded"}}
@Post('/auth/login', config: RouteConfig(
rateLimit: RateLimitConfig(requestsPerMinute: 10, burstSize: 5),
))
@Public()
Future<TokenResponse> login(LoginForm form, AuthService auth) => auth.login(form);

A route limit applies in addition to the app limit, with its own buckets per route. For a controller-wide limit use @UseRouteConfig(RouteConfig(rateLimit: ...)); for a default on every route, AppConfig.defaultRouteConfig (each route still gets separate buckets).

Limit by API key when there is one, otherwise by IP. In an annotation the extractor must be a top-level function or static method:

String apiKeyOrIp(Request request) =>
request.headers['x-api-key'] ?? clientAddress(request) ?? 'unknown';
@Get('/search', config: RouteConfig(
rateLimit: RateLimitConfig(requestsPerMinute: 60, keyExtractor: apiKeyOrIp),
))
Future<List<Result>> search(SearchService search, {String? q}) => search.run(q ?? '');

The default key is the socket’s peer address. Behind a reverse proxy, every request comes from the proxy, so all clients would share one bucket. gisila trusts X-Forwarded-For only from AppConfig.trustedProxies, which defaults to loopback (a proxy on the same host). If your load balancer is elsewhere, add its range:

AppConfig(
trustedProxies: {'127.0.0.1', '::1', '10.0.0.0/8'},
rateLimit: const RateLimitConfig(requestsPerMinute: 600),
)

The header is walked right to left and the first address that isn’t a trusted proxy is the client. Never trust everything (0.0.0.0/0): clients could then pick their own key.

By default each isolate and process keeps its own buckets, so three instances allow three times the rate. Use a shared store:

import 'package:gisila_redis/gisila_redis.dart';
final redis = GisilaRedis.fromUrl(Platform.environment['REDIS_URL']!);
final app = GisilaApp(
config: AppConfig(
rateLimit: const RateLimitConfig(requestsPerMinute: 600),
rateLimitStore: RedisRateLimitStore(redis),
),
);

RedisRateLimitStore fails open by default (requests pass if Redis is down); pass failOpen: false for limits that guard against abuse, such as login. Per-route limiters are named after their route (GET /search), so they line up across instances.

Terminal window
for i in $(seq 1 7); do
curl -s -o /dev/null -w '%{http_code} ' -X POST localhost:8080/auth/login \
-H 'content-type: application/json' -d '{"email":"a@b.co","password":"x"}'
done; echo
# 401 401 401 401 401 429 429

In tests all requests share one key (unknown, since there is no socket), which makes limits easy to trigger:

for (var i = 0; i < 5; i++) {
await client.post('/auth/login', json: {'email': 'a@b.co', 'password': 'x'});
}
final res = await client.post('/auth/login', json: {'email': 'a@b.co', 'password': 'x'});
expect(res.statusCode, 429);
expect(res.headers['retry-after'], isNotNull);