Skip to content

Commit 861376c

Browse files
feat: enforce per-epoch budget in RateLimitManager.admit
admit now meters each transmission against messagesPerEpoch and rolls the epoch window when epochPeriodSec elapses, instead of admitting every call. This is the client-side rate gate the SendService already calls into. Drop the unused queue field and dequeueReady: the SendService owns the pending work as DeliveryTasks and re-offers parked ones each loop tick, so the manager needs no queue of its own. admit keeps its signature so both call sites stay untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c7f767d commit 861376c

2 files changed

Lines changed: 45 additions & 26 deletions

File tree

Lines changed: 29 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+
## configured budget is exhausted, ensuring RLN compliance on enforcing
5+
## relays.
56
##
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.
7+
## `admit` meters each call against `messagesPerEpoch`, rolling the epoch
8+
## window over once `epochPeriodSec` has elapsed since `currentEpochStart`.
9+
## The caller (SendService) owns the pending work and re-offers parked
10+
## tasks on the next service-loop tick, so the manager keeps no queue.
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,32 @@ 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
41+
42+
proc rollEpochIfElapsed(self: RateLimitManager) =
43+
## Opens a fresh budget window once the configured epoch period has
44+
## passed, so `sentInCurrentEpoch` counts only the current epoch.
45+
if getTime() - self.currentEpochStart >=
46+
initDuration(seconds = self.config.epochPeriodSec):
47+
self.resetEpoch()
3848

3949
proc admit*(
4050
self: RateLimitManager, msg: seq[byte]
4151
): 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()
52+
## Meters one transmission against the per-epoch budget: rolls the epoch
53+
## window if elapsed, then admits and counts the send while under
54+
## `messagesPerEpoch`, or returns `OverBudget` so the caller can park it.
55+
if not self.config.enabled:
56+
return ok()
4657

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
58+
self.rollEpochIfElapsed()
5159

52-
proc resetEpoch*(self: RateLimitManager) =
53-
self.currentEpochStart = getTime()
54-
self.sentInCurrentEpoch = 0
60+
if self.sentInCurrentEpoch >= self.config.messagesPerEpoch:
61+
return err(RateLimitError.OverBudget)
62+
63+
self.sentInCurrentEpoch.inc()
64+
return ok()

tests/messaging/test_rate_limit_manager.nim

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,23 @@ 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 "admit enforces the per-epoch budget when enabled":
2217
let rl = RateLimitManager.new(
2318
RateLimitConfig(enabled: true, epochPeriodSec: 600, messagesPerEpoch: 1)
2419
)
2520
check (await rl.admit("first".toBytes())).isOk()
26-
check (await rl.admit("second".toBytes())).isOk()
21+
22+
let overBudget = await rl.admit("second".toBytes())
23+
check overBudget.isErr()
24+
check overBudget.error == RateLimitError.OverBudget
25+
26+
asyncTest "admit reopens the budget after the epoch rolls over":
27+
let rl = RateLimitManager.new(
28+
RateLimitConfig(enabled: true, epochPeriodSec: 600, messagesPerEpoch: 1)
29+
)
30+
check (await rl.admit("first".toBytes())).isOk()
31+
check (await rl.admit("second".toBytes())).isErr()
32+
33+
# Simulate the epoch window elapsing; the next call must be admitted again.
34+
rl.resetEpoch()
35+
check (await rl.admit("third".toBytes())).isOk()

0 commit comments

Comments
 (0)