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 capacity tokens (the burst) and refills at per-second. Each packet takes one token from the bucket of its gate.
  • Gate → bucket. A bucket that lists the gate in gates wins; otherwise the gate handler's [RateLimit("bucket")] attribute; otherwise default-bucket. A bucket name that is not configured means unlimited (for example, no default bucket: unlisted gates are only subject to the payload cap). Gates listed in one bucket share it: above, queue and profile draw from the same four tokens.
  • Strikes. A packet over its bucket is dropped and counts as one strike; a payload larger than max-payload-bytes is dropped and weighs oversize-strikes. When a connection collects strikes-to-disconnect strikes within strike-window-seconds, the packet is dropped and the connection is closed (its output side: the read loop then runs the normal disconnect path, OnDisconnectedAsync included). From then on every packet of that connection is dropped. strikes-to-disconnect: 0 only drops, never closes.
  • Scope. routes limits 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.