Skip to content

Commit d9aec62

Browse files
stubbstaclaude
andcommitted
feat: enforce per-epoch budget in RateLimitManager.admit
Replaces the pass-through skeleton with a lazily rolled fixed window: admit() charges one message against the current epoch, resets the counter once epochPeriodSec has elapsed, and rejects with OverBudget when messagesPerEpoch is exhausted. Disabled or non-positive configurations admit everything, so the default-constructed MessagingClientConf (enabled = false) keeps today's behaviour. Parking of over-budget messages stays with the SendService scheduler (NextRoundRetry); the manager only answers whether one more transmission fits. The queue / dequeueReady stubs that anticipated manager-side parking are removed accordingly. Extends tests/messaging/test_rate_limit_manager.nim with budget boundary, epoch rollover, resetEpoch, and degenerate-config cases, replacing the enabled-pass-through placeholder test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c1d4040 commit d9aec62

2 files changed

Lines changed: 62 additions & 26 deletions

File tree

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
## Rate Limit Manager for the Messaging API.
22
##
33
## Tracks messages sent per RLN epoch and rejects admission when the
4-
## limit is approached, ensuring RLN compliance on enforcing relays.
4+
## budget is exhausted, ensuring RLN compliance on enforcing relays.
55
##
6-
## For the skeleton this is a pass-through: every call is admitted.
7-
## Real per-epoch budgeting will use `queue`, `currentEpochStart`,
8-
## `sentInCurrentEpoch`, and `resetEpoch` to park messages and admit
9-
## them as the epoch rolls over.
6+
## Budgeting is a lazily rolled fixed window: the first admission after
7+
## `epochPeriodSec` has elapsed resets the counter. Parking and retrying
8+
## of over-budget messages is owned by the send service scheduler; this
9+
## module only answers whether one more transmission fits the current
10+
## epoch's budget.
1011
##
1112
## See: https://lip.logos.co/messaging/raw/reliable-channel-api.html
1213

@@ -24,7 +25,6 @@ type
2425

2526
RateLimitManager* = ref object
2627
config*: RateLimitConfig
27-
queue*: seq[seq[byte]]
2828
currentEpochStart*: Time
2929
sentInCurrentEpoch*: int
3030

@@ -33,22 +33,28 @@ const
3333
DefaultMessagesPerEpoch* = 1
3434

3535
proc new*(T: type RateLimitManager, config: RateLimitConfig): T =
36-
return
37-
T(config: config, queue: @[], currentEpochStart: getTime(), sentInCurrentEpoch: 0)
36+
return T(config: config, currentEpochStart: getTime(), sentInCurrentEpoch: 0)
37+
38+
proc resetEpoch*(self: RateLimitManager) =
39+
self.currentEpochStart = getTime()
40+
self.sentInCurrentEpoch = 0
3841

3942
proc admit*(
4043
self: RateLimitManager, msg: seq[byte]
4144
): Future[Result[void, RateLimitError]] {.async: (raises: []).} =
42-
## Skeleton behaviour: admits immediately. Real per-epoch budgeting
43-
## will consult `config`, `sentInCurrentEpoch`, and the elapsed
44-
## `epochPeriodSec` window before admitting or parking `msg`.
45-
return ok()
45+
## Charges one message against the current epoch's budget, rolling the
46+
## window first when `epochPeriodSec` has elapsed. A disabled or
47+
## non-positive configuration admits everything.
48+
if not self.config.enabled or self.config.epochPeriodSec <= 0 or
49+
self.config.messagesPerEpoch <= 0:
50+
return ok()
4651

47-
proc dequeueReady*(self: RateLimitManager): seq[seq[byte]] =
48-
## Returns the set of queued messages that may be dispatched now
49-
## without exceeding the configured rate limit.
50-
discard
52+
if getTime() - self.currentEpochStart >=
53+
initDuration(seconds = self.config.epochPeriodSec):
54+
self.resetEpoch()
5155

52-
proc resetEpoch*(self: RateLimitManager) =
53-
self.currentEpochStart = getTime()
54-
self.sentInCurrentEpoch = 0
56+
if self.sentInCurrentEpoch >= self.config.messagesPerEpoch:
57+
return err(RateLimitError.OverBudget)
58+
59+
inc self.sentInCurrentEpoch
60+
return ok()

tests/messaging/test_rate_limit_manager.nim

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,44 @@ suite "RateLimitManager - admission":
1313
let res = await rl.admit("payload".toBytes())
1414
check res.isOk()
1515

16-
asyncTest "admit is a pass-through in the skeleton even when enabled":
17-
## Documents the current skeleton behaviour: per-epoch enforcement is
18-
## not wired yet, so every call is admitted regardless of the
19-
## configured budget. This test flips to red as soon as real
20-
## enforcement lands, at which point it should be replaced with
21-
## budget-boundary assertions.
16+
asyncTest "admits up to the budget then rejects with OverBudget":
17+
let rl = RateLimitManager.new(
18+
RateLimitConfig(enabled: true, epochPeriodSec: 600, messagesPerEpoch: 3)
19+
)
20+
for i in 0 ..< 3:
21+
check (await rl.admit(("msg" & $i).toBytes())).isOk()
22+
let res = await rl.admit("over".toBytes())
23+
check:
24+
res.isErr()
25+
res.error == RateLimitError.OverBudget
26+
27+
asyncTest "budget frees when the epoch rolls over":
28+
let rl = RateLimitManager.new(
29+
RateLimitConfig(enabled: true, epochPeriodSec: 1, messagesPerEpoch: 1)
30+
)
31+
check (await rl.admit("first".toBytes())).isOk()
32+
check (await rl.admit("second".toBytes())).isErr()
33+
await sleepAsync(1100.milliseconds)
34+
check (await rl.admit("third".toBytes())).isOk()
35+
check (await rl.admit("fourth".toBytes())).isErr()
36+
37+
asyncTest "resetEpoch forces a fresh budget":
2238
let rl = RateLimitManager.new(
2339
RateLimitConfig(enabled: true, epochPeriodSec: 600, messagesPerEpoch: 1)
2440
)
2541
check (await rl.admit("first".toBytes())).isOk()
26-
check (await rl.admit("second".toBytes())).isOk()
42+
check (await rl.admit("second".toBytes())).isErr()
43+
rl.resetEpoch()
44+
check (await rl.admit("third".toBytes())).isOk()
45+
46+
asyncTest "non-positive budget or period is treated as disabled":
47+
let zeroBudget = RateLimitManager.new(
48+
RateLimitConfig(enabled: true, epochPeriodSec: 600, messagesPerEpoch: 0)
49+
)
50+
check (await zeroBudget.admit("a".toBytes())).isOk()
51+
52+
let zeroPeriod = RateLimitManager.new(
53+
RateLimitConfig(enabled: true, epochPeriodSec: 0, messagesPerEpoch: 1)
54+
)
55+
check (await zeroPeriod.admit("a".toBytes())).isOk()
56+
check (await zeroPeriod.admit("b".toBytes())).isOk()

0 commit comments

Comments
 (0)