Skip to content

implement in-memory limiter - #820

Open
Erik-Bard wants to merge 2 commits into
actix:mainfrom
Erik-Bard:in-memory-store
Open

implement in-memory limiter#820
Erik-Bard wants to merge 2 commits into
actix:mainfrom
Erik-Bard:in-memory-store

Conversation

@Erik-Bard

Copy link
Copy Markdown

PR Type

Feature

PR Checklist

  • Tests for the changes have been added / updated.
  • Documentation comments have been added / updated.
  • A changelog entry has been made for the appropriate packages.
  • Format code with the nightly rustfmt (cargo +nightly fmt).

Overview

actix-limitation currently hard-wires Redis: Limiter owns a redis::Client, Builder holds a
redis_url: String, and there is no way to rate limit without a running server. That cost shows up
in this repo — actix-limitation is excluded from the macOS and Windows CI jobs entirely, because
its tests need a Redis service container.

This adds an opt-in memory-store feature providing a process-local fixed-window counter with the
same semantics as the Redis path, so local development, examples and tests need no Redis.

This is fully non-breaking. redis stays a required dependency and the default, unchanged path.
No existing public API is removed, gated, or altered.

New public API

// off-by-default `memory-store` feature
Limiter::memory_builder(MemoryStore::new())                              // defaults
Limiter::memory_builder(MemoryStore::builder().max_keys(10_000).build()) // bounded

MemoryStore is Clone + Default + Debug; clones share one set of counters. Counters expire on a
lazy sweep that runs on a write at most once a minute — there is no background task. The map is
unbounded by default; with max_keys set the store fails open once full (new keys pass
untracked, with a rate-limited warning) rather than evicting counters out from under clients already
being tracked.

Constructing a store logs one WARN at startup, so an in-memory store shipped to production by
accident is visible in the logs without per-request noise.

Internal refactor

Limiter::track's Redis pipeline moved into src/store/redis.rs, unchanged apart from dropping
trailing comments that restated the Redis command next to them. Limiter now holds a private
backend: Backend enum and track is a two-arm dispatch. Builder holds a private BackendSpec,
so Client::open still happens in build() and a malformed URL is still an Err there.

Notes for review

Three decisions worth flagging up front, since each was made against a real alternative:

  • Private enum, no public Store trait. Keeps Limiter non-generic, so the middleware's
    type-erased app_data::<web::Data<Limiter>>() lookup is untouched. Third parties can't add
    backends; that seemed the right trade for not changing the middleware's shape.

  • The two backends are deliberately co-compilable — no compile_error! on the combination.
    Using two backends on one Limiter is already unrepresentable (one enum variant, one constructor
    each, no Builder method to swap it), so an exclusion would guard nothing. It would, however,
    break [package.metadata.docs.rs] all-features = true, force actix-limitation onto the
    --all-features exclusion lists alongside tracing-actix-web and actix-settings, and — worst —
    make downstream graphs unbuildable through feature unification when two unrelated crates each pick
    a different backend. The reasoning is recorded in the MemoryStore rustdoc so it isn't later
    mistaken for an oversight.

  • redis stays a required dependency. Gating it behind a default-on redis-store feature would
    slim memory-only builds, but it is breaking for default-features = false consumers and
    additionally requires cfg-gating Error::Client, the middleware match arm and the
    static_assertions block. Happy to do it as a follow-up on its own major bump if you'd prefer
    that direction — say the word and I'll fold it in here instead.

Tests, CI and docs

  • tests/memory.rs — 6 integration tests, no external service: counting,
    Error::LimitExceeded, two limiters sharing one store, and three end-to-end middleware runs
    through test::init_service (limiting, unkeyed passthrough, window reset). The store's own
    semantics — fixed non-sliding window, key independence, sweep eviction, fail-open at max_keys
    are unit tested in src/store/memory.rs.

  • .github/workflows/ci.ymlbuild_and_test_other gains a step running just those tests:

    - name: tests (actix-limitation, memory store)
      timeout-minutes: 10
      run: cargo test -p actix-limitation --features memory-store --test memory

    This is the crate's first cross-platform test coverage; the Redis exclusion stays as-is.

  • tests/tests.rstests/redis.rs, to name what it actually needs. Its one bare #[test] on an
    async fn is now an explicit #[actix_web::test] — behaviour is unchanged (the bare attribute
    was already resolving to actix_web::test via the use on line 4), but the implicit shadowing is
    easy to misread and would break confusingly if test were dropped from that import.

  • Two examples: memory (runs with nothing installed) and redis (same app, one line different at
    the builder). Only memory gets an [[example]] stanza, since only it has required-features.

  • The self-referencing dev-dependency follows the pattern at actix-session/Cargo.toml:44, so the
    integration tests need no #[cfg] juggling:

    [dev-dependencies]
    actix-limitation = { path = ".", features = ["memory-store"] }
  • Drive-by doc fix: Builder::build's comment claimed it "will connect to the Redis server to test
    its connection which is a synchronous operation". It doesn't — Client::open only parses the
    URL.

  • README gains a Backends section, and the stale actix-limitation = "0.5" snippet is corrected to
    0.6. The crate description and keywords now mention the in-memory backend.

Verification

cargo check -p actix-limitation                                        # default, unchanged
cargo hack check -p actix-limitation --no-default-features
cargo check -p actix-limitation --features memory-store
cargo check -p actix-limitation --all-features
cargo hack check -p actix-limitation --no-default-features --examples
cargo check -p actix-limitation --all-features --examples
cargo test  -p actix-limitation --all-features                         # 21 + 6 + 4 + 4 doctests
cargo test  -p actix-limitation --features memory-store --test memory  # passes with Redis stopped
just doc && just clippy && cargo +nightly fmt --all -- --check

Also confirmed by hand with Redis stopped:

$ RUST_LOG=warn cargo run --example memory --features memory-store
$ for i in $(seq 6); do curl -s -o /dev/null -w "%{http_code} " localhost:8080; done
200 200 200 200 200 429

The process-local warning fires exactly once at startup.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant