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.
Prerequisites
Section titled “Prerequisites”- A gisila project. For step 5,
gisila_redisand a Redis server.
1. App-wide limit
Section titled “1. App-wide limit”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 Requestsretry-after: 1content-type: application/json; charset=utf-8
{"error":{"status":429,"code":"too_many_requests","message":"Rate limit exceeded"}}2. Strict per-route limits
Section titled “2. Strict per-route limits”@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).
3. Custom keys
Section titled “3. Custom keys”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 ?? '');4. Real client IPs behind a proxy
Section titled “4. Real client IPs behind a proxy”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.
5. Share limits across instances
Section titled “5. Share limits across instances”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.
Verify it works
Section titled “Verify it works”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 429In 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);