Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added

- `glimit.hit(limiter, identifier)` for direct rate limit checks without the `apply` wrapper.
- `glimit.HitError` type with `RateLimited`, `Unavailable`, and `StoreLockFailed` constructors (was a re-export, now standalone with the same variants).
- `bucket.compute_ttl(bucket_state)` — computes a TTL in seconds for bucket state, useful for external store adapters.
- `RateLimiter` record has a `now` field (`fn() -> Int`) for test time overrides — construct a new limiter with a different `now` to control time in tests.

### Changed

- Removed the internal `rate_limiter` actor. Both in-memory and external store hits now go through a unified `Store` interface (`lock_and_get` / `set_and_unlock` / `unlock`). The builder API (`glimit.new() |> glimit.per_second(10) |> ...`) is unchanged.
- `Store` type simplified from 4 operations (`get`, `set`, `lock`, `unlock`) to 3 (`lock_and_get`, `set_and_unlock`, `unlock`). Adapters combine lock+get and set+unlock into single operations.
- `RateLimiter` record fields changed: `rate_limiter_actor` replaced by `per_second`, `burst_limit`, `store`, `memory_store`, and `now`. The builder API is unchanged.

### Removed

- `glimit.sweep()` (was a no-op).
- `glimit.set_now()` — time overrides are now done by constructing a new `RateLimiter` with a different `now` field.


## 1.3.1 - 2026-03-05

Expand Down
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,34 @@ func("🚀") // "OK"
func("🚀") // "Stop!"
```

You can also use `glimit.build` and `glimit.hit` for direct rate limit checks
without wrapping a function:

```gleam
import glimit

let assert Ok(limiter) =
glimit.new()
|> glimit.per_second(10)
|> glimit.identifier(fn(x) { x })
|> glimit.on_limit_exceeded(fn(_) { "Stop!" })
|> glimit.build

case glimit.hit(limiter, "user_123") {
Ok(Nil) -> // allowed
Error(glimit.RateLimited) -> // rejected
Error(_) -> // store unavailable, fails open
}
```

More practical examples can be found in the `examples/` directory, such as Wisp or Mist servers, or a Redis backend.


## Pluggable Store Backend

By default, rate limit state is stored in-memory using an OTP actor. For distributed rate limiting across multiple nodes, you can provide a custom `Store` that persists bucket state in an external service like Redis or Postgres.

All token bucket logic stays in glimit — adapters only implement simple get/set/lock/unlock operations. The `glimit/bucket` module provides `to_pairs`/`from_pairs` helpers for serialization.
All token bucket logic stays in glimit — adapters only implement `lock_and_get` / `set_and_unlock` / `unlock` operations. The `glimit/bucket` module provides `to_pairs`/`from_pairs` helpers for serialization.

See `examples/redis/` for a complete Redis adapter using [valkyrie](https://hexdocs.pm/valkyrie/).

Expand All @@ -60,10 +80,10 @@ When no store is configured, the rate limiter uses the default in-memory backend

## Performance

All rate limiter state is held in a single OTP actor. Each hit is one message to this actor, which performs the token bucket calculation inline.
Every hit goes through the pluggable `Store` interface (`lock_and_get` / `set_and_unlock`). In-memory mode backs this with an OTP actor (two messages per hit); external store mode calls the adapter directly.

* **Memory** (in-memory mode): One dict entry per unique identifier. Idle identifiers (full token buckets) are automatically swept every 10 seconds.
* **Fail-open**: If the rate limiter actor is unavailable or a store lock fails, the request is allowed through rather than rejected.
* **Memory** (in-memory mode): One dict entry per unique identifier. Full and idle buckets are automatically swept every 10 seconds.
* **Fail-open**: If the store is unavailable or a lock cannot be acquired, the request is allowed through rather than rejected.


## Documentation
Expand Down
82 changes: 49 additions & 33 deletions examples/redis/src/app.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -23,51 +23,67 @@ fn resp_to_string(value: resp.Value) -> Result(String, Nil) {
}

fn redis_store(conn: valkyrie.Connection) -> glimit.Store {
let lock = fn(key) {
let opts =
valkyrie.SetOptions(
existence_condition: Some(valkyrie.IfNotExists),
return_old: False,
expiry_option: Some(valkyrie.ExpirySeconds(5)),
)
case valkyrie.set(conn, key <> ":lock", "1", Some(opts), redis_timeout) {
Ok(_) -> Ok(Nil)
Error(_) -> Error(Nil)
}
}

let get = fn(key) {
case valkyrie.hgetall(conn, key, redis_timeout) {
Ok(fields) -> {
let pairs =
dict.to_list(fields)
|> list.filter_map(fn(p) {
use k <- result.try(resp_to_string(p.0))
use v <- result.try(resp_to_string(p.1))
Ok(#(k, v))
})
case bucket.from_pairs(pairs) {
Ok(state) -> Ok(Some(state))
_ -> Ok(None)
}
}
Error(_) -> Error(Nil)
}
}

let unlock = fn(key) {
let _ = valkyrie.del(conn, [key <> ":lock"], redis_timeout)
Ok(Nil)
}

bucket.Store(
get: fn(key) {
case valkyrie.hgetall(conn, key, redis_timeout) {
Ok(fields) -> {
let pairs =
dict.to_list(fields)
|> list.filter_map(fn(p) {
use k <- result.try(resp_to_string(p.0))
use v <- result.try(resp_to_string(p.1))
Ok(#(k, v))
})
case bucket.from_pairs(pairs) {
Ok(state) -> Ok(Some(state))
_ -> Ok(None)
}
lock_and_get: fn(key) {
use _ <- result.try(lock(key))
case get(key) {
Ok(result) -> Ok(result)
Error(_) -> {
let _ = unlock(key)
Error(Nil)
}
Error(_) -> Error(Nil)
}
},
set: fn(key, state, ttl) {
set_and_unlock: fn(key, state, ttl) {
let fields = bucket.to_pairs(state) |> dict.from_list
case valkyrie.hset(conn, key, fields, redis_timeout) {
let result = case valkyrie.hset(conn, key, fields, redis_timeout) {
Ok(_) -> {
let _ = valkyrie.expire(conn, key, ttl, None, redis_timeout)
Ok(Nil)
}
Error(_) -> Error(Nil)
}
let _ = unlock(key)
result
},
lock: fn(key) {
let opts =
valkyrie.SetOptions(
existence_condition: Some(valkyrie.IfNotExists),
return_old: False,
expiry_option: Some(valkyrie.ExpirySeconds(5)),
)
case valkyrie.set(conn, key <> ":lock", "1", Some(opts), redis_timeout) {
Ok(_) -> Ok(Nil)
Error(_) -> Error(Nil)
}
},
unlock: fn(key) {
let _ = valkyrie.del(conn, [key <> ":lock"], redis_timeout)
Ok(Nil)
},
unlock: unlock,
)
}

Expand Down
1 change: 0 additions & 1 deletion gleam.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ licences = ["MIT"]
repository = { type = "github", user = "nootr", repo = "glimit" }
target = "erlang"
internal_modules = [
"glimit/rate_limiter",
"glimit/memory_store",
"glimit/utils",
]
Expand Down
Loading