Rate Limiting
Cap outgoing notification rate per channel so you don't tip over upstream limits.
Every external provider has rate limits. FCM caps multicasts, Slack caps webhooks, Twilio caps SMS per-second. Rate limiting on the go-notification side protects you from hitting those limits — and from being throttled or banned.
Per-channel rate limit
Rate limiting is configured on the notifier, per channel, with SetRateLimit — there is no global Config.RateLimit and no per-driver RateLimit field:
func (n *Notifier) SetRateLimit(channel string, rate int, interval time.Duration, burst int)channel— the channel name (the driver'sName()).rate— tokens replenished perinterval.interval— the refill window.burst— bucket capacity (max tokens available at once). If<= 0, defaults torate.
notifier.RegisterChannel(slack.New(slack.Config{Name: "slack", WebhookURL: os.Getenv("SLACK_WEBHOOK_URL")}))
notifier.RegisterChannel(mailgun.New(mailgun.Config{Domain: "...", APIKey: "..."}))
notifier.SetRateLimit("slack", 1, time.Second, 1) // ~1/sec, no burst
notifier.SetRateLimit("mail", 10, time.Second, 20) // 10/sec sustained, burst 20Before each delivery on that channel, the notifier acquires a token, blocking until one is available (or the context is canceled). Channels without a configured limit are unthrottled.
Recommended starting points
These are conservative defaults — your provider's actual limits may be higher.
| Driver | Safe ops/sec |
|---|---|
| Mailgun | 10 |
| SendGrid | 50 |
| AWS SES | starts at 1 (request increase) |
| Resend | 10 |
| Postmark | 50 |
| Slack webhook | 1 |
| Slack bot token | 1 per channel |
| Discord webhook | 0.5 (30/min) |
| Twilio SMS | 1 per long-code; higher for short codes |
| FCM | 500 multicast/sec recommended |
Always verify against the provider's documented quota before setting high values.
Burst vs sustained
The burst argument is the bucket capacity, allowing short bursts above the sustained rate:
notifier.SetRateLimit("mail", 10, time.Second, 50) // sustained 10/s, burst up to 50This is useful when notifications cluster (e.g. end-of-day digest fan-out).
What happens at the limit
The limiter blocks, it does not drop. When the bucket is empty:
- Async mode — a worker calling the channel blocks inside
Acquire, holding its slot. As more sends accumulate, the queue fills andSendblocks upstream. This is intentional backpressure. - Sync mode —
Sendblocks until a token is available.
The only way Acquire returns an error is context cancellation. In that case it returns errors.Join(middleware.ErrRateLimitTimeout, ctx.Err()) — surfacing in your OnError for that channel. Use a per-call context with a deadline if "wait up to N seconds, then fail" is the behavior you want:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
notifier.Send(ctx, user, n) // OnError fires with ErrRateLimitTimeout if no token in 2s`notification.ErrRateLimited` vs `middleware.ErrRateLimitTimeout`
The package exports notification.ErrRateLimited for callers that want a
named sentinel, but the built-in token-bucket limiter only ever returns
middleware.ErrRateLimitTimeout (joined with ctx.Err()). If you need
to detect "rate-limited" failures via errors.Is, match against
middleware.ErrRateLimitTimeout.
If you actually want to drop when over-budget, put a real queue (Redis Streams, NATS, SQS) in front and reject at the edge — go-notification itself won't drop.
Per-tenant rate limiting
Multi-tenant apps sometimes need per-customer caps to prevent one customer from exhausting shared quota. The pattern is to register one channel per tenant (distinct Config.Name) and call SetRateLimit for each:
for _, tenant := range tenants {
name := "mail-" + tenant.ID
notifier.RegisterChannel(mailgun.New(mailgun.Config{
Name: name,
// ... tenant-specific config
}))
notifier.SetRateLimit(name, tenant.MailPerSec, time.Second, tenant.MailPerSec)
}See also: Retry & Backoff — the two features pair well; rate limiting prevents you from hitting 429s, retry handles the ones you didn't prevent.