Skip to content

Commit b247970

Browse files
committed
proxy: introduce per-path request rate limiter
This commit introduces basic configurable rate limits per pathregex.
1 parent 7e5d505 commit b247970

File tree

4 files changed

+180
-2
lines changed

4 files changed

+180
-2
lines changed

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,52 @@ services and APIs.
5050
compare with `sample-conf.yaml`.
5151
* Start aperture without any command line parameters (`./aperture`), all configuration
5252
is done in the `~/.aperture/aperture.yaml` file.
53+
54+
## Per-endpoint rate limiting
55+
56+
Aperture supports per-endpoint rate limiting using a token bucket based on golang.org/x/time/rate.
57+
Limits are configured per service using regular expressions that match request paths.
58+
59+
Key properties:
60+
- Scope: per service, per endpoint (path regex).
61+
- Process local: state is in-memory per Aperture process. In clustered deployments, each instance enforces its own limits.
62+
- Evaluation: all matching rules are enforced; if any matching rule denies a request, the request is rejected.
63+
- Protocols: applies to both REST and gRPC requests.
64+
65+
Behavior on limit exceed:
66+
- HTTP/REST: returns 429 Too Many Requests and sets a Retry-After header (in seconds). Sub-second delays are rounded up to 1 second.
67+
- gRPC: response uses HTTP/2 headers/trailers with Grpc-Status and Grpc-Message indicating the error (message: "rate limit exceeded").
68+
- CORS headers are included consistently.
69+
70+
Configuration fields (under a service):
71+
- pathregex: regular expression matched against the URL path (e.g., "/package.Service/Method").
72+
- requests: allowed number of requests per window.
73+
- per: size of the time window (e.g., 1s, 1m). Default: 1s.
74+
- burst: additional burst capacity. Default: equal to requests.
75+
76+
Example (see sample-conf.yaml for a full example):
77+
78+
```yaml
79+
services:
80+
- name: "service1"
81+
hostregexp: '^service1.com$'
82+
pathregexp: '^/.*$'
83+
address: "127.0.0.1:10009"
84+
protocol: https
85+
86+
# Optional per-endpoint rate limits using a token bucket.
87+
ratelimits:
88+
- pathregex: '^/looprpc.SwapServer/LoopOutTerms.*$'
89+
requests: 5
90+
per: 1s
91+
burst: 5
92+
- pathregex: '^/looprpc.SwapServer/LoopOutQuote.*$'
93+
requests: 2
94+
per: 1s
95+
burst: 2
96+
```
97+
98+
Notes:
99+
- If multiple ratelimits match a request path, all must allow the request; the strictest rule will effectively apply.
100+
- If requests or burst are set to 0 or negative, safe defaults are used (requests defaults to 1; burst defaults to requests).
101+
- If per is omitted or 0, it defaults to 1s.

proxy/proxy.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"os"
1111
"strconv"
1212
"strings"
13+
"time"
1314

1415
"github.com/lightninglabs/aperture/auth"
1516
"github.com/lightninglabs/aperture/l402"
@@ -167,10 +168,47 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
167168
return
168169
}
169170

171+
// Apply per-endpoint rate limits, if configured.
172+
for _, rl := range target.compiledRateLimits {
173+
if !rl.re.MatchString(r.URL.Path) {
174+
continue
175+
}
176+
177+
// Fast path: allow if a token is available now.
178+
if rl.allow() {
179+
continue
180+
}
181+
182+
// Otherwise, compute suggested retry delay without consuming
183+
// tokens.
184+
res := rl.limiter.Reserve()
185+
if res.OK() {
186+
delay := res.Delay()
187+
res.CancelAt(time.Now())
188+
if delay > 0 {
189+
// As seconds; for sub-second delays we still
190+
// send 1 second.
191+
secs := int(delay.Seconds())
192+
if secs == 0 {
193+
secs = 1
194+
}
195+
w.Header().Set(
196+
"Retry-After", strconv.Itoa(secs),
197+
)
198+
}
199+
}
200+
addCorsHeaders(w.Header())
201+
sendDirectResponse(
202+
w, r, http.StatusTooManyRequests, "rate limit exceeded",
203+
)
204+
205+
return
206+
}
207+
170208
resourceName := target.ResourceName(r.URL.Path)
171209

172-
// Determine auth level required to access service and dispatch request
173-
// accordingly.
210+
// Determine the auth level required to access service and dispatch the
211+
// request accordingly.
174212
authLevel := target.AuthRequired(r)
175213
skipInvoiceCreation := target.SkipInvoiceCreation(r)
176214
switch {

proxy/ratelimiter.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package proxy
2+
3+
import (
4+
"regexp"
5+
"time"
6+
7+
"golang.org/x/time/rate"
8+
)
9+
10+
// RateLimit defines a per-endpoint rate limit using a token bucket.
11+
// Requests allowed per time window with optional burst.
12+
// Example YAML:
13+
//
14+
// ratelimits:
15+
// - pathregex: '^/looprpc.SwapServer/LoopOutQuote.*$'
16+
// requests: 5
17+
// per: 1s
18+
// burst: 5
19+
//
20+
// If burst is 0, it defaults to requests.
21+
// If per is 0, it defaults to 1s.
22+
// Note: All limits are in-memory and per-process.
23+
type RateLimit struct {
24+
PathRegexp string `long:"pathregex" description:"Regular expression to match the path of the URL against for rate limiting" yaml:"pathregex"`
25+
Requests int `long:"requests" description:"Number of requests allowed per time window" yaml:"requests"`
26+
Per time.Duration `long:"per" description:"Size of the time window (e.g., 1s, 1m)" yaml:"per"`
27+
Burst int `long:"burst" description:"Burst size allowed in addition to steady rate" yaml:"burst"`
28+
29+
// compiled is internal state prepared at startup.
30+
compiled *compiledRateLimit
31+
}
32+
33+
type compiledRateLimit struct {
34+
re *regexp.Regexp
35+
limiter *rate.Limiter
36+
}
37+
38+
// compile prepares the regular expression and the limiter.
39+
func (r *RateLimit) compile() error {
40+
per := r.Per
41+
if per == 0 {
42+
per = time.Second
43+
}
44+
requests := r.Requests
45+
if requests <= 0 {
46+
requests = 1
47+
}
48+
burst := r.Burst
49+
if burst <= 0 {
50+
burst = requests
51+
}
52+
53+
re, err := regexp.Compile(r.PathRegexp)
54+
if err != nil {
55+
return err
56+
}
57+
58+
// rate.Every(per/requests) creates an average rate of requests
59+
// per 'per'.
60+
lim := rate.NewLimiter(rate.Every(per/time.Duration(requests)), burst)
61+
r.compiled = &compiledRateLimit{re: re, limiter: lim}
62+
63+
return nil
64+
}
65+
66+
// allow returns true if the rate limit permits an event now.
67+
func (c *compiledRateLimit) allow() bool {
68+
return c.limiter.Allow()
69+
}

proxy/service.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,12 @@ type Service struct {
123123
// invoice creation paths.
124124
compiledAuthSkipInvoiceCreationPaths []*regexp.Regexp
125125

126+
// RateLimits configures per-endpoint rate limits for this service.
127+
RateLimits []RateLimit `long:"ratelimits" description:"Per-endpoint rate limits" yaml:"ratelimits"`
128+
129+
// compiledRateLimits holds compiled regexes and limiter instances.
130+
compiledRateLimits []*compiledRateLimit
131+
126132
freebieDB freebie.DB
127133
pricer pricer.Pricer
128134
}
@@ -236,6 +242,22 @@ func prepareServices(services []*Service) error {
236242
service.compiledPathRegexp = compiledPathRegexp
237243
}
238244

245+
// Compile rate limiters.
246+
service.compiledRateLimits = make(
247+
[]*compiledRateLimit, 0, len(service.RateLimits),
248+
)
249+
for i := range service.RateLimits {
250+
rl := &service.RateLimits[i]
251+
if err := rl.compile(); err != nil {
252+
return fmt.Errorf("error compiling rate "+
253+
"limit for path %s: %w",
254+
rl.PathRegexp, err)
255+
}
256+
service.compiledRateLimits = append(
257+
service.compiledRateLimits, rl.compiled,
258+
)
259+
}
260+
239261
service.compiledAuthWhitelistPaths = make(
240262
[]*regexp.Regexp, 0, len(service.AuthWhitelistPaths),
241263
)

0 commit comments

Comments
 (0)