Rate Limiting
Parsec gates three abusable ingress points with configurable budgets:
| Bucket | What it gates | Default key |
|---|---|---|
publish | The Publish RPC | mgmt-bearer subject |
subscribe | Centrifuge subscribe attempts | userID, when authenticated |
token-issue | The RefreshToken RPC | remote 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.RedisLimiterwhenOptions.RedisClientis setratelimit.MemoryLimiterotherwise
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 namespacedsubscribe-channel:<pattern>:<userID>; channels matching no rule fall through tosubscribe:<userID>with the globalSubscribebudget. 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~2xratefor 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.
MemoryLimiteris fine for dev and small deployments. Distinct keys live in a shared map; callSweepperiodically (or rely on the implicit drop-on-touch) if your key cardinality is large.
Failure modes
- Redis unreachable. A failed
Allowreturns the underlying redis error, which the RPC layer translates toPARSEC_INTERNAL(HTTP 500). The publish/subscribe paths fail closed — operators should monitorparsec_rate_limit_decisions_totaldivergence from request totals to detect this case. - Mixed-version clusters. EVALSHA / NOSCRIPT fallback handles rolling upgrades automatically.