Rate Limiting
Overview
Altruist ships a configurable per-connection rate limit for portals: the RateLimitInterceptor. It runs before every gate handler (and for packets of unknown events, so a client cannot spam those for free), and it is installed from config.yml — no code needed:
altruist:
server:
transport:
rate-limit:
enabled: true
buckets:
input: { capacity: 300, per-second: 90, gates: ["input"] }
chat: { capacity: 3, per-second: 0.6, gates: ["chat"] }
heavy: { capacity: 4, per-second: 1, gates: ["queue", "profile"] }
default: { capacity: 20, per-second: 20 }
With enabled missing or false (the default) nothing is installed and nothing changes.
These limits are for portal packets. HTTP endpoints have their own named policies, applied with the same [RateLimit("name")] attribute on controllers and actions — see HTTP API › Rate Limits.
How It Works
- Token buckets per connection. Every connection gets one bucket per configured bucket. A bucket starts full with
capacitytokens (the burst) and refills atper-second. Each packet takes one token from the bucket of its gate. - Gate → bucket. A bucket that lists the gate in
gateswins; otherwise the gate handler's[RateLimit("bucket")]attribute; otherwisedefault-bucket. A bucket name that is not configured means unlimited (for example, nodefaultbucket: unlisted gates are only subject to the payload cap). Gates listed in one bucket share it: above,queueandprofiledraw from the same four tokens. - Strikes. A packet over its bucket is dropped and counts as one strike; a payload larger than
max-payload-bytesis dropped and weighsoversize-strikes. When a connection collectsstrikes-to-disconnectstrikes withinstrike-window-seconds, the packet is dropped and the connection is closed (its output side: the read loop then runs the normal disconnect path,OnDisconnectedAsyncincluded). From then on every packet of that connection is dropped.strikes-to-disconnect: 0only drops, never closes. - Scope.
routeslimits the interceptor to some portals (e.g.["/game"]); empty means every portal. Packets without a client id are not limited. - Cleanup. When a connection is gone, the connection manager tells the interceptor to forget its buckets.
Rejected packets never reach the gate handler (see Interceptors).
Assigning Buckets in Code
Instead of listing gates in config.yml, a gate can name its bucket. The bucket's limits still come from the configuration, and a config entry for the gate takes precedence:
[Portal("/game")]
public class GamePortal : Portal
{
[Gate("input"), RateLimit("input")]
public Task OnInput(InputPacket packet, string clientId) { /* ... */ }
[Gate("chat"), RateLimit("chat")]
public Task OnChat(ChatPacket packet, string clientId) { /* ... */ }
}
Configuration Reference
altruist:
server:
transport:
rate-limit:
enabled: false # install the interceptor
max-payload-bytes: 65536 # larger payloads are dropped (0 = no cap)
oversize-strikes: 20 # strikes one oversize payload weighs
strikes-to-disconnect: 60 # strikes in the window that close the connection (0 = never)
strike-window-seconds: 10
default-bucket: default # bucket of gates no bucket lists (and of unknown events)
routes: [] # portal routes to limit, e.g. ["/game"]; empty = all
buckets: # name -> { capacity, per-second, gates }
default: { capacity: 20, per-second: 20 }
Defaults are shown; buckets is empty by default (so an enabled limiter without buckets only enforces the payload cap and its strikes).
Metrics
Register an IRateLimitMetrics to see every verdict (none is registered by default). It is called on the connections' read loops — keep it thread-safe and cheap:
[Service(typeof(IRateLimitMetrics))]
public sealed class InboundCounters : IRateLimitMetrics
{
public static long Checked, Rejected;
public void Checked(InterceptContext context, RateVerdict verdict)
{
Interlocked.Increment(ref Checked);
if (verdict != RateVerdict.Allow) Interlocked.Increment(ref Rejected);
}
}
Replacing the Limiter
The token buckets are the default IRateLimiter. Register your own implementation in DI and the interceptor uses it instead (routes, rejection, closing and metrics stay the same):
[Service(typeof(IRateLimiter))]
public sealed class PerAccountLimiter : IRateLimiter
{
public RateVerdict Check(string clientId, string gate, int payloadLength) { /* ... */ return RateVerdict.Allow; }
public void Forget(string clientId) { /* ... */ }
}
TokenBucketRateLimiter is public, so you can also use it on its own (new TokenBucketRateLimiter(RateLimitOptions.FromConfiguration(configuration))), for example in tests with a manual clock.
Note:
Your own interceptors can keep per-connection state the same way: implement IConnectionStateInterceptor and the connection manager calls Forget(clientId) once the connection is gone.