Rate Limiting

Token-bucket rate limiting with per-IP tracking, path-based rules, automatic cleanup, and bandwidth limiting.

Overview

ALOS provides a high-performance rate limiting system based on the token bucket algorithm. Rate limits are enforced per client IP with support for path-based rules, automatic blocking, custom limit responses, and dynamic rule management. The system supports two levels of custom responses: per-rule callbacks via OnLimit on individual rules, and a global fallback via s.OnRateLimit().

Token Bucket

Each client gets a bucket that refills at a configurable rate, allowing burst traffic up to the bucket size.

Per-IP Tracking

Limits tracked per source IP with sharded concurrent maps. Integrates with Real IP middleware for proxy-aware resolution.

Auto Cleanup

Expired buckets are automatically pruned. Blocked IPs auto-unblock after the configured duration.

Two-Level Handlers

Per-rule OnLimit callbacks for route-specific responses plus a global s.OnRateLimit() fallback for all rules.

Pattern Matching

Three pattern types: exact paths, prefix with /* wildcard, and regex with ~ prefix. Most specific match wins.

Bandwidth Control

Separate bandwidth limiting with per-connection and global throttling for upload and download.

Basic Setup

Enable rate limiting by adding rules to your server. Each rule specifies a path pattern, a request limit, a time window, and how long to block offending IPs:

go
s := core.New(core.Config{Addr: ":443"})

s.SetRateLimitRules([]core.RateLimitRule{
    {
        Path:     "/api/*",
        MaxReqs:  100,
        Window:   60 * time.Second,
        BlockFor: 5 * time.Minute,
    },
})

When a client at a given IP exceeds 100 requests to any path under /api/ within a 60-second window, they are blocked for 5 minutes. During the block, all requests from that IP matching the rule are rejected with a 429 status.

go
s.SetRateLimitRules([]core.RateLimitRule{
    {
        Path:     "/api/*",
        MaxReqs:  100,
        Window:   60 * time.Second,
        BlockFor: 5 * time.Minute,
    },
    {
        Path:     "/health",
        MaxReqs:  1000,
        Window:   60 * time.Second,
        BlockFor: 30 * time.Second,
    },
    {
        Path:     "/*",
        MaxReqs:  300,
        Window:   60 * time.Second,
        BlockFor: 2 * time.Minute,
    },
})

Rules are matched by specificity — the most specific path wins. An exact match like /health takes priority over a prefix match like /*. This means you can set a generous limit for health checks while keeping a tighter global limit.

Rate Limit Rules

The RateLimitRule struct defines a rate limit:

Field Type Description
Path string Path pattern to match — exact /login, prefix /api/*, or regex ~^/users/\d+$
MaxReqs int64 Maximum number of requests allowed within the time window
Window time.Duration Time window for counting requests (e.g. 60 * time.Second)
BlockFor time.Duration How long to block the IP after exceeding the limit (e.g. 5 * time.Minute)
OnLimit RateLimitFunc Per-rule callback when this specific rule triggers. Signature: func(event RateLimitEvent, req *Request, resp *Response) bool. Return true if handled.
go
type RateLimitRule struct {
    Path     string
    MaxReqs  int64
    Window   time.Duration
    BlockFor time.Duration
    OnLimit  RateLimitFunc
}

type RateLimitFunc func(event RateLimitEvent, req *Request, resp *Response) bool

Path Patterns

Three matching modes are supported. The engine picks the most specific match for each request:

Type Syntax Example Matches
Exact Full path, no wildcard /login Only /login (highest priority)
Exact Full path /health Only /health
Prefix Path with /* /api/* /api/users, /api/posts/1, etc.
Prefix Catch-all /* All paths (lowest priority prefix)
Regex Starts with ~ ~^/users/\d+$ /users/123, /users/456
Regex Starts with ~ ~^/api/v[0-9]+/admin /api/v1/admin, /api/v2/admin/users
go
s.SetRateLimitRules([]core.RateLimitRule{
    {Path: "/login", MaxReqs: 5, Window: 60 * time.Second, BlockFor: 10 * time.Minute},
    {Path: "/api/*", MaxReqs: 100, Window: 60 * time.Second, BlockFor: 5 * time.Minute},
    {Path: "~^/users/\\d+$", MaxReqs: 50, Window: 60 * time.Second, BlockFor: 1 * time.Minute},
    {Path: "/health", MaxReqs: 1000, Window: 60 * time.Second, BlockFor: 30 * time.Second},
    {Path: "/*", MaxReqs: 300, Window: 60 * time.Second, BlockFor: 2 * time.Minute},
})

Specificity order: Exact match > Prefix match (longer path first) > Regex match. A request to /health will always use the exact /health rule, never the /* catch-all.

Per-Rule Handlers (OnLimit)

Each rule can have its own OnLimit callback that fires when that specific rule is triggered. The callback receives a RateLimitEvent with full context about the violation. Return true to indicate the response was handled.

JSON API Response

go
s.SetRateLimitRules([]core.RateLimitRule{
    {
        Path:     "/api/*",
        MaxReqs:  100,
        Window:   60 * time.Second,
        BlockFor: 5 * time.Minute,
        OnLimit: func(event core.RateLimitEvent, req *core.Request, resp *core.Response) bool {
            var buf [64]byte
            b := buf[:0]
            b = append(b, `{"error":"api rate limited","retry_after":`...)
            b = strconv.AppendInt(b, int64(event.RetryAfter.Seconds())+1, 10)
            b = append(b, '}')
            resp.Status(429).JSON(b)
            return true
        },
    },
})

HTML Error Page

go
{
    Path:     "/login",
    MaxReqs:  5,
    Window:   60 * time.Second,
    BlockFor: 10 * time.Minute,
    OnLimit: func(event core.RateLimitEvent, req *core.Request, resp *core.Response) bool {
        var buf [256]byte
        b := buf[:0]
        b = append(b, `<h1>Too Many Login Attempts</h1><p>Try again in `...)
        b = strconv.AppendInt(b, int64(event.RetryAfter.Seconds())+1, 10)
        b = append(b, ` seconds.</p>`...)
        resp.Status(429).HTML(string(b))
        return true
    },
},

Logging Without Custom Body

go
{
    Path:     "~^/users/\\d+$",
    MaxReqs:  50,
    Window:   60 * time.Second,
    BlockFor: 1 * time.Minute,
    OnLimit: func(event core.RateLimitEvent, req *core.Request, resp *core.Response) bool {
        log.Printf("rate limit hit: ip=%s path=%s", event.IP, event.Path)
        return false
    },
},

Returning false from OnLimit means the response was not handled — ALOS will fall through to the global handler, or if none is set, send a default 429 response.

Global Rate Limit Handler (s.OnRateLimit)

The global handler is a catch-all that fires for any rate limit violation that hasn't been handled by a per-rule OnLimit. This is where you put logging, metrics, and default error responses. Set it once on the server:

go
s.OnRateLimit(func(event core.RateLimitEvent, req *core.Request, resp *core.Response) bool {
    log.Printf("[RATE-LIMIT] ip=%s path=%s rule=%s retry_after=%s",
        event.IP, event.Path, event.Rule.Path, event.RetryAfter)
    var buf [256]byte
    b := buf[:0]
    b = append(b, `<html><body><h1>Slow Down</h1><p>Too many requests. Try again in `...)
    b = strconv.AppendInt(b, int64(event.RetryAfter.Seconds())+1, 10)
    b = append(b, ` seconds.</p></body></html>`...)
    resp.Status(429).HTML(string(b))
    return true
})

How the Two Levels Interact

When a rate limit is triggered, ALOS follows this order:

  1. Per-rule OnLimit — If the matched rule has an OnLimit callback, it runs first. If it returns true, the request is fully handled.
  2. Global s.OnRateLimit — If the per-rule handler returned false or there is no per-rule handler, the global handler runs. If it returns true, the request is handled.
  3. Default 429 — If neither handler existed or both returned false, ALOS sends a plain 429 Too Many Requests response.

Global JSON Handler

go
s.OnRateLimit(func(event core.RateLimitEvent, req *core.Request, resp *core.Response) bool {
    j := core.AcquireJSON()
    resp.Status(429).JSON(j.Marshal(map[string]any{
        "error":               "rate_limit_exceeded",
        "rule":                event.Rule.Path,
        "retry_after_seconds": int64(event.RetryAfter.Seconds()) + 1,
    }))
    j.Release()
    return true
})

Combined Example

API routes get a JSON error, everything else falls through to the global HTML handler:

go
s.SetRateLimitRules([]core.RateLimitRule{
    {
        Path: "/api/*", MaxReqs: 100, Window: 60 * time.Second, BlockFor: 5 * time.Minute,
        OnLimit: func(event core.RateLimitEvent, req *core.Request, resp *core.Response) bool {
            var buf [64]byte
            b := buf[:0]
            b = append(b, `{"error":"api rate limited","retry_after":`...)
            b = strconv.AppendInt(b, int64(event.RetryAfter.Seconds())+1, 10)
            b = append(b, '}')
            resp.Status(429).JSON(b)
            return true
        },
    },
    {Path: "/health", MaxReqs: 1000, Window: 60 * time.Second, BlockFor: 30 * time.Second},
    {Path: "/*", MaxReqs: 300, Window: 60 * time.Second, BlockFor: 2 * time.Minute},
})

s.OnRateLimit(func(event core.RateLimitEvent, req *core.Request, resp *core.Response) bool {
    log.Printf("[RATE-LIMIT] ip=%s path=%s rule=%s retry_after=%s",
        event.IP, event.Path, event.Rule.Path, event.RetryAfter)
    var buf [256]byte
    b := buf[:0]
    b = append(b, `<html><body><h1>Slow Down</h1><p>Too many requests. Try again in `...)
    b = strconv.AppendInt(b, int64(event.RetryAfter.Seconds())+1, 10)
    b = append(b, ` seconds.</p></body></html>`...)
    resp.Status(429).HTML(string(b))
    return true
})

RateLimitEvent

Both per-rule and global handlers receive a RateLimitEvent with full context about the violation:

Field Type Description
IP string Client IP address that triggered the limit
Path string The actual request path (e.g. /api/users/123)
Rule RateLimitRule The rule that was matched — access event.Rule.Path, event.Rule.MaxReqs, etc.
RetryAfter time.Duration How long until the block expires — use event.RetryAfter.Seconds() for the numeric value
go
type RateLimitEvent struct {
    IP         string
    Path       string
    Rule       RateLimitRule
    RetryAfter time.Duration
}

Using Event Fields

go
OnLimit: func(event core.RateLimitEvent, req *core.Request, resp *core.Response) bool {
    log.Printf("ip=%s hit rule=%s (max=%d/%s) path=%s retry=%s",
        event.IP,
        event.Rule.Path,
        event.Rule.MaxReqs,
        event.Rule.Window,
        event.Path,
        event.RetryAfter,
    )
    resp.Status(429).SetHeader("Retry-After", strconv.Itoa(int(event.RetryAfter.Seconds())+1))
    resp.JSON([]byte(`{"error":"rate limited"}`))
    return true
},

Engine API

Manage rules dynamically at runtime. Rules can be added, removed, or replaced while the server is running without any downtime:

Method Description
s.SetRateLimitRules([]RateLimitRule) Replace all rules at once (atomic swap)
s.AddRateLimitRule(RateLimitRule) Add a single rule without affecting existing rules
s.RemoveRateLimitRule(path string) Remove a rule by its path pattern
s.OnRateLimit(fn RateLimitFunc) Set the global rate limit handler (called when no per-rule handler handles the event)

Adding Rules at Runtime

go
s.AddRateLimitRule(core.RateLimitRule{
    Path:     "/webhook",
    MaxReqs:  20,
    Window:   60 * time.Second,
    BlockFor: 5 * time.Minute,
})

Removing Rules

go
s.RemoveRateLimitRule("/webhook")

Replacing All Rules

go
s.SetRateLimitRules([]core.RateLimitRule{
    {Path: "/api/*", MaxReqs: 200, Window: 60 * time.Second, BlockFor: 5 * time.Minute},
    {Path: "/*", MaxReqs: 500, Window: 60 * time.Second, BlockFor: 1 * time.Minute},
})

Admin Endpoint Pattern

You can build an admin API to manage rate limits at runtime:

go
admin := s.Router.Group("/admin", authMiddleware)

admin.POST("/rate-limit", func(req *core.Request, resp *core.Response) {
    s.AddRateLimitRule(core.RateLimitRule{
        Path:     "/api/heavy/*",
        MaxReqs:  10,
        Window:   60 * time.Second,
        BlockFor: 10 * time.Minute,
    })
    resp.Status(200).JSONString(`{"status":"rule added"}`)
})

admin.DELETE("/rate-limit/:path", func(req *core.Request, resp *core.Response) {
    s.RemoveRateLimitRule(req.ParamValue("path"))
    resp.Status(200).JSONString(`{"status":"rule removed"}`)
})

Bandwidth Limiting

Separate from request rate limiting, bandwidth limiting controls the actual data transfer speed. ALOS uses BandwidthConfig for per-connection and global throttling:

Field Type Description
MaxUploadRate int64 Max upload speed in Mbps (0 = unlimited)
MaxDownloadRate int64 Max download speed in Mbps (0 = unlimited)
BurstSize int64 Burst allowance in Mbps (token bucket max tokens)
go
s := core.New(core.Config{
    Addr: ":443",

    ConnBandwidth: core.BandwidthConfig{
        MaxUploadRate:   8,
        MaxDownloadRate: 40,
        BurstSize:       16,
    },

    GlobalBandwidth: core.BandwidthConfig{
        MaxUploadRate:   400,
        MaxDownloadRate: 1600,
        BurstSize:       800,
    },
})

This creates a ConnectionLimiter per connection (8 Mbps up, 40 Mbps down with 16 Mbps burst) and a GlobalLimiter for server-wide throttling (400 Mbps up, 1600 Mbps down with 800 Mbps burst). Both use token-bucket algorithms internally.

Connection vs Global: Per-connection limits prevent any single client from hogging bandwidth, while the global limit ensures total server throughput stays within your infrastructure capacity.

File Download Rate Limiting

For file downloads via SendFile, you can set a per-download speed limit in megabits per second (1–250,000 Mbps):

Runtime note: SendFile currently uses the streamed-response compatibility path. On the native Linux io_uring worker runtime, this API is still unavailable.

go
s.Router.GET("/download/:file", func(req *core.Request, resp *core.Response) {
    resp.SendFile(
        "./files/" + req.ParamValue("file"),
        core.WithAttachment("download.zip"),
        core.WithRateLimit(50),
    )
})
Option Description
WithRateLimit(mbps int64) Throttle download speed. Range: 1–250,000 Mbps. Internally converts to bytes/sec (mbps * 125000).
WithRateLimitBurst(mbps, burstMbps int64) Same as WithRateLimit but with a custom burst size in Mbps. Allows initial burst before throttling kicks in.

With Custom Burst

go
resp.SendFile(
    "./files/large-archive.tar.gz",
    core.WithAttachment("large-archive.tar.gz"),
    core.WithRateLimitBurst(100, 400),
)

This allows an initial burst at 400 Mbps, then throttles to 100 Mbps for the remainder.

Production Example

A complete production rate limiting setup with multiple rules, per-rule API handler, and a global HTML fallback:

go
s.SetRateLimitRules([]core.RateLimitRule{
    {
        Path:     "/api/*",
        MaxReqs:  100,
        Window:   60 * time.Second,
        BlockFor: 5 * time.Minute,
        OnLimit: func(event core.RateLimitEvent, req *core.Request, resp *core.Response) bool {
            var buf [64]byte
            b := buf[:0]
            b = append(b, `{"error":"api rate limited","retry_after":`...)
            b = strconv.AppendInt(b, int64(event.RetryAfter.Seconds())+1, 10)
            b = append(b, '}')
            resp.Status(429).JSON(b)
            return true
        },
    },
    {
        Path:     "/health",
        MaxReqs:  1000,
        Window:   60 * time.Second,
        BlockFor: 30 * time.Second,
    },
    {
        Path:     "~^/users/\\d+$",
        MaxReqs:  50,
        Window:   60 * time.Second,
        BlockFor: 1 * time.Minute,
    },
    {
        Path:     "/*",
        MaxReqs:  300,
        Window:   60 * time.Second,
        BlockFor: 2 * time.Minute,
    },
})

s.OnRateLimit(func(event core.RateLimitEvent, req *core.Request, resp *core.Response) bool {
    log.Printf("[RATE-LIMIT] ip=%s path=%s rule=%s retry_after=%s",
        event.IP, event.Path, event.Rule.Path, event.RetryAfter)
    var buf [256]byte
    b := buf[:0]
    b = append(b, `<html><body><h1>Slow Down</h1><p>Too many requests. Try again in `...)
    b = strconv.AppendInt(b, int64(event.RetryAfter.Seconds())+1, 10)
    b = append(b, ` seconds.</p></body></html>`...)
    resp.Status(429).HTML(string(b))
    return true
})
ESC