Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rate Limiting

Parsec gates three abusable ingress points with configurable budgets:

BucketWhat it gatesDefault key
publishThe Publish RPCmgmt-bearer subject
subscribeCentrifuge subscribe attemptsuserID, when authenticated
token-issueThe RefreshToken RPCremote IP

When a bucket is exhausted the caller receives a PARSEC_RATE_LIMITED coded error, rendered as Twirp’s resource_exhausted and HTTP 429 with an advisory Reset window. Single-node mode uses an in-process token bucket; multi-node mode (when Options.RedisClient is set) shares the budget across nodes via a Redis sorted-set sliding window driven by a single-round-trip Lua script.

Quick start (CLI)

parsec serve \
  --publish-rate 100/s --publish-burst 200 \
  --subscribe-rate 20/s \
  --token-issue-rate 5/s

The shorthand format is <integer>/<window>. The window accepts the unit aliases s, m, h, plus any duration time.ParseDuration understands (500ms, 2m30s). An empty rate string disables that bucket.

Environment-variable equivalents (for container deployments):

PARSEC_PUBLISH_RATE=100/s
PARSEC_PUBLISH_BURST=200
PARSEC_SUBSCRIBE_RATE=20/s
PARSEC_TOKEN_ISSUE_RATE=5/s

Library configuration

import "github.com/frankbardon/parsec/ratelimit"

p, _ := parsec.New(parsec.Options{
    RateLimits: ratelimit.RateLimits{
        Publish:    ratelimit.Limit{Rate: 100, Per: time.Second, Burst: 200},
        Subscribe:  ratelimit.Limit{Rate: 20,  Per: time.Second},
        TokenIssue: ratelimit.Limit{Rate: 5,   Per: time.Second},
    },
})

Pass Options.Limiter to plug in a custom backend (e.g. a token-bucket shared with another service). When Limiter is nil and any bucket has Rate > 0, Parsec constructs:

  • ratelimit.RedisLimiter when Options.RedisClient is set
  • ratelimit.MemoryLimiter otherwise

Cross-node behaviour

Two Parsec nodes pointing at the same Redis (and sharing Options.RedisKeyPrefix) consume one global budget. The Lua script is EVALSHA’d on first use and reloaded automatically on NOSCRIPT. Keys take the form <prefix>:rl:<bucket>:<subject> and inherit PEXPIRE(window + 10s) so abandoned buckets free themselves.

Per-token override

A mgmt token may carry an rl claim that overrides the operator default for that subject. Mint such a token with Issuer.IssuePairWithRateLimit:

override := ratelimit.Limit{Rate: 10, Per: time.Second}
pair, _ := p.Issuer().IssuePairWithRateLimit(sub, channel, ttl, override)

Server-side, the override is automatically picked up from the bearer’s claims on every gated call. The default is still applied to subjects without an override.

Overrides only narrow the budget — they cannot widen it past the operator-configured ceiling for an unauthenticated path (the token-issue bucket keys off IP, not the subject).

Per-channel publish ceilings

Operators can tighten (or loosen) the publish bucket for individual channels or families of channels by mapping a channel-name pattern to a Limit. The library option is PerChannelPublishLimits:

parsec.New(parsec.Options{
    RateLimits: ratelimit.RateLimits{
        Publish: ratelimit.Limit{Rate: 100, Per: time.Second, Burst: 200},
    },
    PerChannelPublishLimits: map[string]ratelimit.Limit{
        // Internal metrics fanout — drown the broker without
        // affecting other publishers.
        "public:hot.metrics.**": {Rate: 500, Per: time.Second},
        // Sensitive admin broadcasts — clamp to a handful per minute.
        "private:admin.alerts.**": {Rate: 5, Per: time.Minute},
    },
})

The pattern grammar is the channel-name grammar from channels.ParsePattern (* matches one segment, trailing ** matches any remaining segments). Invalid patterns abort parsec.New with PARSEC_INVALID_ARGUMENT so a typo never silently disables a rule.

Rule selection: the most specific matching rule wins (literal segments score 4, single-star segments 1, trailing ** 0; ties broken by pattern string ascending). The selected rule’s Limit overrides the default Publish budget for the call, and the bucket key is namespaced publish-channel:<pattern>:<subject> so two rules on disjoint channels never share a budget. Channels that match no rule fall through to the default Publish bucket (publish:<subject>).

Per-token overrides still apply on top: auth.Claims.RateLimitOverride beats both the rule and the default. Use this when a single tenant needs a tighter or looser ceiling on top of a global rule.

Per-channel subscribe ceilings

Subscribe-side limits use the same shape, configured via PerChannelSubscribeLimits:

parsec.New(parsec.Options{
    RateLimits: ratelimit.RateLimits{
        Subscribe: ratelimit.Limit{Rate: 50, Per: time.Second},
    },
    PerChannelSubscribeLimits: map[string]ratelimit.Limit{
        // Tenant-scoped channel — one subscribe per second per user.
        "private:webapp.tenant.**":    {Rate: 1, Per: time.Second},
        // High-frequency presence channel — relax for embed widgets.
        "public:status.heartbeat.**":  {Rate: 200, Per: time.Second},
    },
})

Rule selection matches the publish path (most-specific wins; literal

single-star > trailing **; ties broken by pattern string). The bucket key is namespaced subscribe-channel:<pattern>:<userID>; channels matching no rule fall through to subscribe:<userID> with the global Subscribe budget. Anonymous subscribes (userID empty because the transport never authenticated the connection) are not charged — operators wanting per-IP gating for unauthenticated traffic should enforce it at L7. The subscribe gate runs before token verification so a flood of bad-token attempts cannot exhaust CPU on HMAC verifies.

Per-token overrides are not consulted on the subscribe path: the access token’s subject is the rate-limit key, and a token cannot loosen the operator-configured budget for its own subject. To grant a single user a tighter (or looser) subscribe budget than the rule allows, encode the tenant in the channel name and add a more-specific rule.

Manifest discovery

The Manifest RPC exposes the default budgets:

{
  "rate_limits": {
    "publish":     {"rate": 100, "per": "1s", "burst": 200},
    "subscribe":   {"rate": 20,  "per": "1s", "burst": 20},
    "token_issue": {"rate": 5,   "per": "1s", "burst": 5}
  }
}

Per-channel rules are appended under per_channel_publish and per_channel_subscribe (each omitted when none configured):

{
  "rate_limits": {
    "publish":     {"rate": 100, "per": "1s", "burst": 200},
    "subscribe":   {"rate": 20,  "per": "1s", "burst": 20},
    "token_issue": {"rate": 5,   "per": "1s", "burst": 5},
    "per_channel_publish": [
      {"pattern": "public:hot.metrics.**", "limit": {"rate": 500, "per": "1s", "burst": 500}}
    ],
    "per_channel_subscribe": [
      {"pattern": "private:webapp.tenant.**", "limit": {"rate": 1, "per": "1s", "burst": 1}}
    ]
  }
}

Per-token overrides are private to the issued token and never surface in the manifest.

Metrics

The parsec_rate_limit_decisions_total{bucket,result} counter increments on every decision. bucket is publish, subscribe, or token-issue; result is allowed or denied. Alert on rate(parsec_rate_limit_decisions_total{result="denied"}[1m]) > 0 to catch abusive callers.

Tuning

  • Burst > Rate. Configure burst ~2x rate for bursty human-driven workloads; keep them equal for machine workloads where rate violations indicate misconfiguration.
  • Per-IP token-issue. The refresh endpoint has no mgmt bearer, so the IP is the only stable key. If Parsec sits behind a load balancer, put a complementary L7 rate limit on the LB and keep the parsec budget loose — the LB sees the real client IP, parsec sees only the LB.
  • Single-node fallback. MemoryLimiter is fine for dev and small deployments. Distinct keys live in a shared map; call Sweep periodically (or rely on the implicit drop-on-touch) if your key cardinality is large.

Failure modes

  • Redis unreachable. A failed Allow returns the underlying redis error, which the RPC layer translates to PARSEC_INTERNAL (HTTP 500). The publish/subscribe paths fail closed — operators should monitor parsec_rate_limit_decisions_total divergence from request totals to detect this case.
  • Mixed-version clusters. EVALSHA / NOSCRIPT fallback handles rolling upgrades automatically.