mirror of
https://github.com/logos-messaging/logos-delivery.git
synced 2026-07-20 03:30:22 +00:00
Decomposes logos_delivery/messaging/rate_limit_manager/ into three modules with one responsibility each: - rate_limit_config: the configuration vocabulary (RateLimitConfig, RateLimitError, defaults, isEnforcing). The API conf layer now imports this alone instead of the enforcement engine. - quota_source: the RLN seam. A single QuotaProvider callback returns EpochQuota (epoch index + user message limit) so the two are read atomically and a read cannot straddle an epoch boundary. Returns none when RLN is unavailable, selecting the wall-clock fallback. The callback shape keeps the manager free of any dependency on the Waku kernel; the RLN-backed provider is built one layer up. - rate_limit_manager: enforcement only; re-exports the other two so existing single-import call sites are unchanged. Config field names now mirror RLN exactly, per the requirement that the rate limit share RLN Relay's format: epochSizeSec (was epochPeriodSec) and userMessageLimit (was messagesPerEpoch), both uint64 to match RlnConf. Renaming is contained to this branch: the config type moved into the new rate_limit_config module here. admit() now works in epoch-index terms: the epoch comes from the provider when set (RLN's calcEpoch value), else from an absolute wall-clock window (unixTime div epochSizeSec — absolute rather than anchored at first use, matching RLN's derivation). The effective limit is min(config.userMessageLimit, RLN's) — RLN can only tighten the configured limit, since exceeding it would fail at proof generation once the epoch's message ids are exhausted. With no provider wired (this commit), behaviour is the wall-clock window as before; RLN-backed provider wiring follows separately. Tests rewritten against injected fake providers: limit boundary, epoch rollover without sleeps, RLN-clamps-config, config-tightens-below-RLN, and the wall-clock fallback (7 tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
88 lines
3.7 KiB
Nim
88 lines
3.7 KiB
Nim
{.used.}
|
|
|
|
import std/options
|
|
import chronos, testutils/unittests, stew/byteutils
|
|
|
|
import logos_delivery/messaging/rate_limit_manager/rate_limit_manager
|
|
|
|
proc fixedQuota(epochIndex, userMessageLimit: uint64): QuotaProvider =
|
|
## A quota source pinned to one epoch — the epoch never rolls on its own, so
|
|
## limit-boundary tests are deterministic without touching the wall clock.
|
|
return proc(): Option[EpochQuota] {.gcsafe, raises: [].} =
|
|
some(EpochQuota(epochIndex: epochIndex, userMessageLimit: userMessageLimit))
|
|
|
|
suite "RateLimitManager - admission":
|
|
asyncTest "admit is a pass-through when disabled":
|
|
let rl = RateLimitManager.new(
|
|
RateLimitConfig(enabled: false, epochSizeSec: 600, userMessageLimit: 1)
|
|
)
|
|
for _ in 0 ..< 10:
|
|
check (await rl.admit("payload".toBytes())).isOk()
|
|
|
|
asyncTest "zeroed limit or epoch size is treated as disabled":
|
|
let zeroLimit = RateLimitManager.new(
|
|
RateLimitConfig(enabled: true, epochSizeSec: 600, userMessageLimit: 0)
|
|
)
|
|
check (await zeroLimit.admit("a".toBytes())).isOk()
|
|
|
|
let zeroEpoch = RateLimitManager.new(
|
|
RateLimitConfig(enabled: true, epochSizeSec: 0, userMessageLimit: 1)
|
|
)
|
|
check (await zeroEpoch.admit("a".toBytes())).isOk()
|
|
check (await zeroEpoch.admit("b".toBytes())).isOk()
|
|
|
|
asyncTest "admits up to the user message limit then rejects with OverBudget":
|
|
## Fixed-epoch quota so the window cannot roll mid-test.
|
|
let rl = RateLimitManager.new(
|
|
RateLimitConfig(enabled: true, epochSizeSec: 600, userMessageLimit: 3),
|
|
fixedQuota(epochIndex = 42, userMessageLimit = 100),
|
|
)
|
|
for i in 0 ..< 3:
|
|
check (await rl.admit(("msg" & $i).toBytes())).isOk()
|
|
let res = await rl.admit("over".toBytes())
|
|
check:
|
|
res.isErr()
|
|
res.error == RateLimitError.OverBudget
|
|
|
|
asyncTest "allowance refills when the epoch rolls over":
|
|
## Drive the roll through the provider — no sleeps, no flake.
|
|
var epoch = 1'u64
|
|
let rl = RateLimitManager.new(
|
|
RateLimitConfig(enabled: true, epochSizeSec: 600, userMessageLimit: 1),
|
|
proc(): Option[EpochQuota] {.gcsafe, raises: [].} =
|
|
some(EpochQuota(epochIndex: epoch, userMessageLimit: 100)),
|
|
)
|
|
check (await rl.admit("first".toBytes())).isOk()
|
|
check (await rl.admit("second".toBytes())).isErr()
|
|
epoch = 2
|
|
check (await rl.admit("third".toBytes())).isOk()
|
|
check (await rl.admit("fourth".toBytes())).isErr()
|
|
|
|
asyncTest "RLN user message limit clamps a looser configured limit":
|
|
## config allows 5, RLN grants 2 — the third admission must reject, since
|
|
## exceeding RLN's limit would fail at proof generation anyway.
|
|
let rl = RateLimitManager.new(
|
|
RateLimitConfig(enabled: true, epochSizeSec: 600, userMessageLimit: 5),
|
|
fixedQuota(epochIndex = 7, userMessageLimit = 2),
|
|
)
|
|
check (await rl.admit("a".toBytes())).isOk()
|
|
check (await rl.admit("b".toBytes())).isOk()
|
|
check (await rl.admit("c".toBytes())).isErr()
|
|
|
|
asyncTest "config can tighten below the RLN limit":
|
|
## config allows 1, RLN grants 100 — the config limit wins.
|
|
let rl = RateLimitManager.new(
|
|
RateLimitConfig(enabled: true, epochSizeSec: 600, userMessageLimit: 1),
|
|
fixedQuota(epochIndex = 7, userMessageLimit = 100),
|
|
)
|
|
check (await rl.admit("a".toBytes())).isOk()
|
|
check (await rl.admit("b".toBytes())).isErr()
|
|
|
|
asyncTest "falls back to the wall-clock window when no quota source is set":
|
|
## No provider: rate limiting still enforces within a single wall-clock epoch.
|
|
let rl = RateLimitManager.new(
|
|
RateLimitConfig(enabled: true, epochSizeSec: 600, userMessageLimit: 1)
|
|
)
|
|
check (await rl.admit("first".toBytes())).isOk()
|
|
check (await rl.admit("second".toBytes())).isErr()
|