Skip to content

Commit 188220c

Browse files
author
Hayim.Shaul@ibm.com
committed
implement an example rate limiter
Signed-off-by: Hayim.Shaul@ibm.com <hayimsha@fhe03.vpc.cloud9.ibm.com>
1 parent 05e391a commit 188220c

16 files changed

Lines changed: 1875 additions & 71 deletions

docs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Welcome to Panurus documentation.
1616
## Security
1717

1818
* [**HTLC Deadlines and Clock Synchronisation**](security/htlc_deadline_clock_assumptions.md): The clock-synchronisation assumption that the HTLC claim/reclaim deadline rests on, and the deadline margin it requires of a deployment.
19-
* [**Selector Resource Limits**](security/selector_resource_limits.md): How to throttle token selection by supplying a custom `Locker`.
19+
* [**Selector Resource Limits**](security/selector_resource_limits.md): How to throttle token selection, either with the opt-in built-in per-wallet rate limiter or by supplying your own limiter or `Locker`.
2020

2121
## Command-Line Tools
2222

docs/configuration.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,24 @@ token:
3939
# fetcherCacheMaxQueries is the number of queries after which a soft refresh (non-blocking background update) is triggered.
4040
# This helps keep the cache fresh without blocking queries. If not specified or set to 0, defaults to 5 queries.
4141
fetcherCacheMaxQueries: 5
42+
# Built-in per-wallet rate limiter for token selection (both drivers).
43+
# It is disabled by default: without these keys, selection requests are not metered.
44+
# One selection request (a Selector.Select call) costs one unit, no matter how many tokens it
45+
# locks or how often it retries internally. Unlocking tokens is never throttled.
46+
# A throttled request fails fast with an error wrapping token.SelectorRateLimited.
47+
# See docs/security/selector_resource_limits.md.
48+
# rateLimitEnabled turns the limiter on with the default rate and burst below.
49+
rateLimitEnabled: true
50+
# rateLimit is the maximum number of selection requests per second a single wallet may issue.
51+
# A positive value implies rateLimitEnabled: true. If not specified or set to 0, defaults to 100.
52+
rateLimit: 100
53+
# rateLimitBurst is the maximum number of selection requests a single wallet may issue
54+
# back-to-back. If not specified or set to 0, defaults to twice rateLimit.
55+
rateLimitBurst: 200
56+
# rateLimitMaxBuckets caps the number of per-wallet buckets kept in memory. When the cap is
57+
# reached, idle buckets are pruned first and, if that is not enough, the least recently used
58+
# ones are dropped. If not specified or set to 0, defaults to 4096.
59+
rateLimitMaxBuckets: 4096
4260

4361
# When we are interested in knowing when a transaction reaches finality, we subscribe to the Finality Listener Manager for the finality event of that transaction.
4462
# This configuration specifies the way the manager is instantiated (i.e., how it gets notified about the finality events, how often it checks).
@@ -471,6 +489,15 @@ Default values:
471489
- numRetries: 3
472490
- leaseExpiry: 3m
473491
- leaseCleanupTickPeriod: 90s
492+
- rateLimitEnabled: false (the built-in per-wallet selection rate limiter is opt-in)
493+
- rateLimit: 100 requests/s per wallet, when rate limiting is enabled
494+
- rateLimitBurst: 2 × rateLimit, so 200 requests, when rate limiting is enabled
495+
- rateLimitMaxBuckets: 4096
496+
497+
Setting a positive `rateLimit` is enough to enable the limiter; `rateLimitEnabled: true` alone
498+
enables it with the defaults above. See
499+
[docs/security/selector_resource_limits.md](security/selector_resource_limits.md) for what is
500+
metered and how to plug in your own limiter instead.
474501

475502
---
476503

docs/security/selector_resource_limits.md

Lines changed: 148 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,150 @@
44

55
Token selection acquires a short-lived *lock* on each candidate token so that two
66
concurrent transactions do not try to spend the same token. Under load, a single
7-
wallet can drive a large number of selection/lock requests. Applications that need to
8-
throttle this — to protect the lock store, to enforce fairness between wallets, or to
9-
integrate with an existing quota system — can do so by supplying their own `Locker`
10-
implementation.
7+
wallet can drive a large number of selection/lock requests. To protect the lock store,
8+
to enforce fairness between wallets, or to integrate with an existing quota system,
9+
Panurus offers two ways to throttle this:
10+
11+
1. A **built-in per-wallet rate limiter**, activated purely from configuration or from
12+
code. It is a token bucket per wallet, in process, and it is **disabled by default**.
13+
2. A **fail-fast contract**, `token.SelectorRateLimited`, plus a **wallet-id-aware lock
14+
function**. Both selector drivers (simple and sherdlock) pass the wallet id the tokens
15+
are being selected for into the `Locker`'s lock function, and abort the selection
16+
immediately when a lock is denied with an error wrapping that sentinel. This is the
17+
integration point for applications that would rather reuse the rate-limiting
18+
infrastructure they already run (for example a Redis-backed limiter shared across
19+
processes).
20+
21+
## The built-in limiter
22+
23+
Package `token/services/selector/ratelimit`. One **selection request** — one
24+
`Selector.Select` call — costs one unit from the bucket of the wallet it selects for,
25+
regardless of how many tokens the request ends up locking or how many times the selector
26+
retries internally on contention. Deliberately *not* per token lock attempt: charging
27+
there would make a large transfer cost more than a small one and would let the selector's
28+
own contention retries drain a wallet's allowance.
29+
30+
Properties worth knowing:
31+
32+
- **Per wallet, per TMS.** Buckets are keyed by the wallet id *and* the TMS id, so the
33+
same wallet id in two networks or namespaces gets two independent allowances.
34+
- **Bounded memory.** Buckets are created on first use and pruned without any background
35+
goroutine: idle ones are swept during ordinary access, and a hard cap
36+
(`rateLimitMaxBuckets`) evicts the least recently used ones if the sweep is not enough.
37+
- **Unlocking is never throttled.** Only `Select` is metered; `Unlock` and `Close` pass
38+
through, so a throttled wallet can always clean up after itself.
39+
- **Empty wallet ids are never throttled.**
40+
- **Nothing is leaked on a denial.** The request is rejected before the selector runs, so
41+
no token is locked and there is nothing to release.
42+
43+
### Enabling it from configuration
44+
45+
Under `token.selector` (see [../configuration.md](../configuration.md)):
46+
47+
```yaml
48+
token:
49+
selector:
50+
driver: sherdlock
51+
# Enables the limiter with the defaults below.
52+
rateLimitEnabled: true
53+
# Selection requests per second per wallet. A positive value implies rateLimitEnabled: true.
54+
# Defaults to 100.
55+
rateLimit: 100
56+
# Back-to-back requests allowed to one wallet. Defaults to 2 × rateLimit, so 200.
57+
rateLimitBurst: 200
58+
# Maximum number of per-wallet buckets held in memory. Defaults to 4096.
59+
rateLimitMaxBuckets: 4096
60+
```
61+
62+
Omitting all four keys, which is the default, leaves selection unmetered.
63+
64+
### Enabling it from code
65+
66+
Both selector services accept `ratelimit.Option` values, which take precedence over the
67+
configuration:
68+
69+
```go
70+
import (
71+
"github.com/LFDT-Panurus/panurus/token/services/selector/ratelimit"
72+
"github.com/LFDT-Panurus/panurus/token/services/selector/sherdlock"
73+
)
74+
75+
// The built-in limiter with its defaults (100 requests/s per wallet, burst 200).
76+
svc := sherdlock.NewService(fetcherProvider, lockStoreManager, configProvider, metricsProvider,
77+
ratelimit.WithDefaultLimiter())
78+
79+
// Explicit rate and burst.
80+
svc = sherdlock.NewService(fetcherProvider, lockStoreManager, configProvider, metricsProvider,
81+
ratelimit.WithLimiter(ratelimit.New(ratelimit.Config{Rate: 20, Burst: 40})))
82+
83+
// Explicitly off, whatever the configuration says.
84+
svc = sherdlock.NewService(fetcherProvider, lockStoreManager, configProvider, metricsProvider,
85+
ratelimit.WithLimiter(nil))
86+
```
87+
88+
`simple.NewService(lockerProvider, configProvider, opts ...ratelimit.Option)` takes the
89+
same options.
90+
91+
A limiter passed with `WithLimiter` belongs to the caller: the service never stops it, not
92+
even from `Shutdown`. That matters because `Shutdown` also runs on routine public-parameter
93+
reloads, and resetting every wallet's bucket there would let a throttled client wash out
94+
its debt. `BucketLimiter.Stop()` exists for callers that own a limiter and want its memory
95+
back.
1196

12-
Panurus deliberately ships **no built-in rate limiter or quota**. Instead it
13-
gives you two things:
97+
### Supplying your own limiter
1498

15-
1. A **wallet-id-aware lock function**. Both selector drivers (simple and sherdlock)
16-
pass the wallet id the tokens are being selected for into the `Locker`'s lock
17-
function, so a custom `Locker` can apply per-wallet policies.
18-
2. A **fail-fast contract**, `token.SelectorRateLimited`. When a `Locker` denies a
19-
lock by returning an error that wraps this sentinel, the selector aborts the
20-
selection immediately and returns the error to the caller instead of retrying.
99+
`WithLimiter` accepts any implementation of:
21100

22-
This keeps the Panurus minimal and lets applications reuse whatever rate-limiting
23-
infrastructure they already run (for example a Redis-backed limiter shared across
24-
processes).
101+
```go
102+
// Limiter meters token selection requests per wallet.
103+
type Limiter interface {
104+
// Allow returns nil when a selection request for walletID within scope may proceed,
105+
// and an error wrapping token.SelectorRateLimited when it must be denied.
106+
// scope is the TMS id. An empty walletID is never throttled.
107+
Allow(ctx context.Context, scope string, walletID string) error
108+
}
109+
```
110+
111+
This is the simplest way to plug in a shared, cluster-wide limiter (Redis, a quota table,
112+
a sidecar) while keeping the metering point — one unit per selection request — and the
113+
fail-fast behaviour that Panurus already implements.
114+
115+
## The fail-fast contract
116+
117+
`token/selector.go` defines:
118+
119+
```go
120+
// SelectorRateLimited is the contract error returned (directly or wrapped) to deny a
121+
// selection for policy reasons such as rate limiting or quota.
122+
var SelectorRateLimited = errors.New("selection rate limit exceeded")
123+
```
124+
125+
When a `Locker` returns an error `e` with `errors.Is(e, token.SelectorRateLimited)`, the
126+
selector:
127+
128+
- stops iterating candidate tokens,
129+
- releases any tokens it already locked for this request, and
130+
- returns `e` to the caller.
131+
132+
Any *other* error from the lock function keeps the existing semantics: the token is
133+
treated as unavailable (e.g. already locked by another transaction) and selection
134+
continues / retries as before.
25135

26136
## The lock function
27137

28138
Both selector drivers route through a `Locker` whose lock function receives the
29-
wallet id.
139+
wallet id, so a custom `Locker` can apply per-wallet policies of its own — per token
140+
lock rather than per selection request.
30141

31142
**Simple selector** — `token/services/selector/simple/selector.go`:
32143

33144
```go
34145
type Locker interface {
35-
// Lock locks the token id for the consumer transaction txID on behalf of walletID
146+
// Lock locks the token id for the consumer transaction txID on behalf of owner
36147
// (ownerFilter.ID()). Return an error wrapping token.SelectorRateLimited to deny
37148
// the lock and make the selection fail fast.
38-
Lock(ctx context.Context, id *token.ID, txID string, walletID string, reclaim bool) (string, error)
39-
UnlockIDs(ctx context.Context, ids ...*token.ID) []*token.ID
149+
Lock(ctx context.Context, owner string, id *token.ID, txID string, reclaim bool) (string, error)
150+
UnlockIDs(ctx context.Context, owner string, ids ...*token.ID) []*token.ID
40151
UnlockByTxID(ctx context.Context, txID string)
41152
IsLocked(id *token.ID) bool
42153
}
@@ -60,34 +171,12 @@ type TokenLockStore interface {
60171
The built-in in-memory locker and the SQL-backed `TokenLockStore` accept `walletID`
61172
but do not act on it — they apply no rate limiting or quota.
62173

63-
## The fail-fast contract
64-
65-
`token/selector.go` defines:
66-
67-
```go
68-
// SelectorRateLimited is the contract error a Locker implementation returns (directly
69-
// or wrapped) to deny a lock for policy reasons such as rate limiting or quota.
70-
var SelectorRateLimited = errors.New("selection rate limit exceeded")
71-
```
72-
73-
When your `Locker` returns an error `e` with `errors.Is(e, token.SelectorRateLimited)`,
74-
the selector:
75-
76-
- stops iterating candidate tokens,
77-
- releases any tokens it already locked for this request, and
78-
- returns `e` to the caller.
79-
80-
Any *other* error from the lock function keeps the existing semantics: the token is
81-
treated as unavailable (e.g. already locked by another transaction) and selection
82-
continues / retries as before.
83-
84-
## Integrating your own rate limiting
174+
### Integrating your own rate limiting in a Locker
85175

86176
Provide a `Locker` that wraps the SDK's default locker and enforces your policy before
87177
delegating. Below, a Redis-backed limiter throttles per wallet; the same shape works
88-
for an in-process limiter, a quota table, etc.
89-
90-
### Simple selector
178+
for an in-process limiter, a quota table, etc. Note that this is charged **per token lock
179+
attempt**, unlike the built-in limiter above.
91180

92181
```go
93182
import (
@@ -105,24 +194,21 @@ type rateLimitedLocker struct {
105194
limiter RedisLimiter // your existing infrastructure
106195
}
107196
108-
func (l *rateLimitedLocker) Lock(ctx context.Context, id *tokenapi.ID, txID string, walletID string, reclaim bool) (string, error) {
109-
if !l.limiter.Allow(ctx, walletID) {
110-
return "", errors.Wrapf(token.SelectorRateLimited, "wallet %s throttled", walletID)
197+
func (l *rateLimitedLocker) Lock(ctx context.Context, owner string, id *tokenapi.ID, txID string, reclaim bool) (string, error) {
198+
if !l.limiter.Allow(ctx, owner) {
199+
return "", errors.Wrapf(token.SelectorRateLimited, "wallet %s throttled", owner)
111200
}
112201
113-
return l.Locker.Lock(ctx, id, txID, walletID, reclaim)
202+
return l.Locker.Lock(ctx, owner, id, txID, reclaim)
114203
}
115204
```
116205

117206
Wire it in by providing a `simple.LockerProvider` whose `New` returns your decorator
118207
instead of the default `inmemory.NewLocker`.
119208

120-
### Sherdlock selector
121-
122-
Provide a `TokenLockStore` (via the `tokenlockdb.StoreServiceManager` used by
123-
`sherdlock.NewService`) whose `Lock` enforces the limit before delegating to the
124-
SQL-backed store, returning an error wrapping `token.SelectorRateLimited` when a wallet
125-
is throttled.
209+
For the sherdlock selector, provide a `TokenLockStore` (via the
210+
`tokenlockdb.StoreServiceManager` used by `sherdlock.NewService`) whose `Lock` enforces the
211+
limit before delegating to the SQL-backed store.
126212

127213
## Handling the error
128214

@@ -136,9 +222,14 @@ if errors.Is(err, token.SelectorRateLimited) {
136222
}
137223
```
138224

225+
The built-in limiter's error message states how long to wait before the wallet has a
226+
request available again.
227+
139228
## Notes
140229

141230
- Passing an empty `walletID` is valid; a `Locker` that keys its policy on wallet id
142-
should treat empty as "no throttling" (the default lockers ignore it entirely).
143-
- Because the policy lives in your `Locker`, its scope (per process vs shared across a
144-
cluster), persistence, and lifecycle are entirely under your control.
231+
should treat empty as "no throttling" (the default lockers ignore it entirely, and so
232+
does the built-in limiter).
233+
- The built-in limiter is per process. If a wallet's traffic is spread over several nodes,
234+
each node enforces its own allowance; supply a shared `Limiter` if you need a
235+
cluster-wide budget.

token/selector.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@ var (
2525
// SelectorSufficientFundsButConcurrencyIssue is returned when funds are sufficient to cover the request, but
2626
// concurrency issues does not make some of the selected tokens available.
2727
SelectorSufficientFundsButConcurrencyIssue = errors.New("sufficient funds but concurrency issue")
28-
// SelectorRateLimited is the contract error a Locker implementation returns (directly
29-
// or wrapped) to deny a lock for policy reasons such as rate limiting or quota.
28+
// SelectorRateLimited is the contract error returned (directly or wrapped) to deny a
29+
// selection for policy reasons such as rate limiting or quota.
3030
// Both the simple and sherdlock selectors detect it via errors.Is and abort the
3131
// selection immediately, returning the error to the caller instead of retrying.
32-
// Panurus ships no built-in limiter: applications integrate their own
33-
// (e.g. a Redis-backed limiter) by providing a Locker implementation that returns
34-
// this error when a request must be throttled.
32+
// Panurus ships an opt-in per-wallet limiter that returns it, see
33+
// token/services/selector/ratelimit and the token.selector.rateLimit* configuration
34+
// keys; it is disabled by default. Applications that would rather reuse their own
35+
// infrastructure (e.g. a Redis-backed limiter) can either supply a Limiter to that
36+
// package or return this error from a custom Locker implementation.
3537
SelectorRateLimited = errors.New("selection rate limit exceeded")
3638
)
3739

0 commit comments

Comments
 (0)