marten-throttle v0.2.0

marten-throttle

Rate limiting for Marten apps. Add the middleware, declare your rules, done.

Marten.settings.throttle.draw do
  rule "login", "/login", limit: 5, per: 1.minute, methods: ["POST"]
  rule "api", "/api/*", limit: 30, per: 1.minute
end

Rules match top to bottom, first one wins. Anything unmatched passes through untouched unless you set a default_policy. Going over the limit gets you a 429:

HTTP/1.1 429 Too Many Requests
Retry-After: 43
RateLimit-Limit: 5
RateLimit-Remaining: 0
RateLimit-Reset: 43

Counters live in Marten.cache, so multi-process deployments need a shared backend — Redis or Memcached. With a per-process memory cache each worker counts separately and your effective limit is multiplied by your worker count.

Tested against Marten 0.6.3 and Crystal 1.19+.

Installation

Add it to shard.yml and run shards install:

dependencies:
  marten_throttle:
    github: treagod/marten-throttle

Require it in src/project.cr:

require "marten_throttle"

Then register the app and the middleware in config/settings/base.cr:

config.installed_apps = [
  # ...
  MartenThrottle::App,
]

config.middleware = [
  MartenThrottle::Middleware,
  # Other middlewares...
]

Put the throttle middleware early so blocked requests short-circuit before the expensive stuff runs. The exception: if your client identifier depends on session or auth state, it has to come after whatever produces that state.

Rules

rule(name, matcher, limit, per, strategy = default_strategy, methods = nil, identifier = nil)
  • name — stable and unique. Letters, digits, hyphens, underscores. It ends up in the cache key, so renaming a rule resets its buckets.
  • matcher — a String or Regex. Strings match exactly; a trailing * makes it a prefix match, so "/api/*" covers /api/users/1, /api/orders/3, and so on.
  • limitInt32, greater than zero.
  • per — a Time::Span of whole seconds. Anything under a second, or fractional like 1.9.seconds, is rejected outright rather than truncated.
  • strategyFixedWindow or SlidingWindow. See below.
  • methods — restricts the rule to those HTTP methods, case-insensitive.
  • identifier — overrides the global client_identifier for this rule only. Handy when /login should key on IP but /api/* should key on API key.

One rule is one bucket, not one bucket per path. /api/users/1 and /api/users/42 share the same /api/* bucket. If you want them counted separately, write separate rules or fold the path into the client identifier.

To throttle everything that doesn't match a rule:

Marten.settings.throttle.default_policy = MartenThrottle::Policy.new(limit: 100, per: 1.minute)
Marten.settings.throttle.default_strategy = MartenThrottle::Strategy::FixedWindow

Rules without an explicit strategy pick up whatever default_strategy was set to at the time the rule is declared.

Strategies

FixedWindow is the default and fine for most things — one cache increment per request, cheap. Its weakness is the window seam: a client can spend its full budget at the end of one window and again at the start of the next.

Use SlidingWindow where that burst matters, typically login and other auth endpoints. It keeps two buckets and weights the previous one by how far into the current window the request landed, at the cost of a bit more cache traffic.

rule "login", "/login",
  limit: 5,
  per: 1.minute,
  strategy: MartenThrottle::Strategy::SlidingWindow,
  methods: ["POST"]

Skipping requests

Skips run before client identification and cache access, and they beat both rules and the default policy.

exclude handles simple paths, using the same matcher syntax as rule:

Marten.settings.throttle.draw do
  exclude "/assets/*"
  exclude "/health"
  rule "api", "/api/*", limit: 30, per: 1.minute
end

skip_if handles everything else:

Marten.settings.throttle.skip_if = ->(request : Marten::HTTP::Request) {
  request.path.starts_with?("/internal/") || request.path == "/health"
}

Identifying clients

Out of the box, every throttled request shares one "global" bucket per rule unless Marten can hand over a trusted remote address. That default is deliberate: trusting a header any client can set would let callers shard themselves into private buckets and walk straight past the limit.

For real per-client throttling, point client_identifier at something the application controls:

Marten.settings.throttle.client_identifier = ->(request : Marten::HTTP::Request) {
  request.headers["X-Verified-Client-ID"]? || "global"
}

Authenticated user IDs, API key IDs, tenant IDs, or an IP a trusted proxy wrote into the header all work well. Empty values fall back to "global".

Behind a proxy or load balancer that overwrites client-supplied forwarding headers, you can opt into them:

Marten.settings.throttle.trust_forwarded_headers = true

The middleware then takes the first valid IP from X-Forwarded-For, then RFC Forwarded, then X-Real-IP, falling back to the remote address.

Individual rules can bring their own identifier, which takes precedence for requests they match. The default policy always uses the global one.

Marten.settings.throttle.draw do
  rule "login", "/login",
    limit: 5,
    per: 1.minute,
    methods: ["POST"],
    identifier: ->(request : Marten::HTTP::Request) { request.headers["X-Real-IP"]? || "global" }

  rule "api", "/api/*",
    limit: 1000,
    per: 1.minute,
    identifier: ->(request : Marten::HTTP::Request) { request.headers["X-Api-Key"]? || "global" }
end

If your Marten version doesn't expose request.remote_address, supply a resolver at Marten.settings.throttle.remote_address that returns the peer address from your server integration.

Response headers

Throttled requests get RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset — plus X--prefixed copies of all three — whether they were allowed or blocked. Skipped, disabled, unmatched, and fail-open pass-through requests get none of them.

RateLimit-Reset is seconds until the budget resets. For fixed windows that's the same number as Retry-After; for sliding windows it's when the current two-bucket contribution expires, which is not the same thing. Blocked responses also carry Retry-After, which is the earliest point another request might actually succeed.

Cache behavior

Counters are incremented before the handler runs, so failed and short-circuited responses still consume quota.

Every counter key is written through Marten.cache.increment(..., expires_in: ...). Fixed-window keys expire after their window, sliding-window keys after two, which is what keeps the cache from growing without bound.

For shared deployments you need a backend whose increment is atomic and supports expiry — Marten documents this for the Redis and Memcached cache shards. The sliding-window strategy performs two independent counter operations per request: it increments the current bucket and reads the previous bucket through the same counter interface with amount: 0. Their results are combined in the application rather than transactionally, so the weighted count remains best-effort under heavy concurrency.

Don't use Marten::Cache::Store::Null with throttling. It always returns 0 from increment, so nothing ever hits a limit. The middleware warns about it but still allows the requests. If you want throttling off, set Marten.settings.throttle.enabled = false instead.

Cache keys look like {cache_namespace}:{scope}:{sha256(client_id)}, where scope is r:{rule_name} for a named rule and default for the default policy. Client identifiers are hashed so emails, API keys, and other raw values never land in the cache. Reordering rule declarations doesn't move clients between buckets; renaming a rule does.

When the cache goes down

fail_open defaults to true: if Marten.cache raises, the middleware logs a warning and lets the request through. A cache outage shouldn't become an application outage.

For endpoints where letting traffic past is worse than rejecting it, flip it:

Marten.settings.throttle.fail_open = false

MartenThrottle::CacheUnavailableError then propagates to the application.

Development

Run the specs against any of the three supported cache backends via MARTEN_THROTTLE_CACHE_STORE (defaults to memory). Redis and Memcached assume localhost:6379 and localhost:11211 unless their connection variables say otherwise.

crystal spec
MARTEN_THROTTLE_CACHE_STORE=redis crystal spec
MARTEN_THROTTLE_CACHE_STORE=memcached crystal spec

Strategies read the current time through Marten.settings.throttle.clock, which exists so specs can freeze exact window boundaries. Reset it between examples if you override it.

Marten.settings.throttle.clock = -> : Time { Time.unix(1_700_000_000) }

Issues and PRs welcome.

License

MIT.

Repository

marten-throttle

Owner
Statistic
  • 2
  • 0
  • 0
  • 0
  • 5
  • 6 days ago
  • May 9, 2026
License

MIT License

Links
Synced at

Tue, 28 Jul 2026 04:53:26 GMT

Languages