Skip to content

Commit 48f0cc3

Browse files
Claudius-Maginificentlklimekclaude
authored
fix(sdk): ban rate-limited node for Envoy-advertised reset window (#3951)
Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 87f6b73 commit 48f0cc3

16 files changed

Lines changed: 1246 additions & 116 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
# Env file
1515
.env
16+
.env.*.bak
1617

1718
# NYC test runner
1819
.nyc_output

packages/dashmate/configs/defaults/getBaseConfigFactory.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,9 @@ export default function getBaseConfigFactory() {
241241
blacklist: [],
242242
whitelist: [],
243243
enabled: true,
244+
responseHeaders: {
245+
enabled: true,
246+
},
244247
},
245248
ssl: {
246249
enabled: false,

packages/dashmate/configs/getConfigFileMigrationsFactory.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,6 +1520,25 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs)
15201520

15211521
return configFile;
15221522
},
1523+
'4.0.0-rc.3': (configFile) => {
1524+
Object.entries(configFile.configs)
1525+
.forEach(([, options]) => {
1526+
// Add responseHeaders toggle to rate limiter (default true so existing
1527+
// deployments keep emitting RateLimit-* headers; rs-dapi-client depends
1528+
// on RateLimit-Reset to apply precise ban windows instead of the
1529+
// exponential health-ban ladder).
1530+
// Keyed at the next release (4.0.0-rc.3), not the already-released
1531+
// rc.2: the runner skips fromVersion===toVersion, so a key equal to
1532+
// an operator's current version never fires. Backfill runs once the
1533+
// package bumps to rc.3 (mirrors the 3.1.0 migration added at 3.1.0-dev.1).
1534+
if (options.platform?.gateway?.rateLimiter
1535+
&& typeof options.platform.gateway.rateLimiter.responseHeaders === 'undefined') {
1536+
options.platform.gateway.rateLimiter.responseHeaders = base.get('platform.gateway.rateLimiter.responseHeaders');
1537+
}
1538+
});
1539+
1540+
return configFile;
1541+
},
15231542
};
15241543
}
15251544

packages/dashmate/docker-compose.rate_limiter.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ services:
4242
- GRPC_MAX_CONNECTION_AGE=1h
4343
- GRPC_MAX_CONNECTION_AGE_GRACE=10m
4444
- GRPC_PORT=8081
45+
# Emit RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset response
46+
# headers so rs-dapi-client can read the exact reset window and ban the
47+
# node for that duration instead of the exponential health-ban ladder.
48+
# Controlled by platform.gateway.rateLimiter.responseHeaders.enabled.
49+
- LIMIT_RESPONSE_HEADERS_ENABLED=${PLATFORM_GATEWAY_RATE_LIMITER_RESPONSE_HEADERS_ENABLED:?err}
4550
expose:
4651
- 8081
4752
profiles:

packages/dashmate/docs/config/gateway.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ The rate limiter protects the Platform from excessive requests:
159159
| `platform.gateway.rateLimiter.unit` | Time unit for rate limiting | `minute` | `hour` |
160160
| `platform.gateway.rateLimiter.whitelist` | IPs exempt from rate limiting | `[]` | `["192.168.1.1"]` |
161161
| `platform.gateway.rateLimiter.blacklist` | IPs blocked from all requests | `[]` | `["10.0.0.1"]` |
162+
| `platform.gateway.rateLimiter.responseHeaders.enabled` | Emit `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` response headers. `rs-dapi-client` reads the Reset header to apply a precise ban window instead of the exponential health-ban ladder. Disable only for privacy reasons. | `true` | `false` |
162163

163164
Available time units:
164165
- `second`: Per-second rate limiting

packages/dashmate/src/config/configJsonSchema.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -690,8 +690,22 @@ export default {
690690
enabled: {
691691
type: 'boolean',
692692
},
693+
responseHeaders: {
694+
type: 'object',
695+
description: 'Control emission of RateLimit-* response headers (RateLimit-Limit, '
696+
+ 'RateLimit-Remaining, RateLimit-Reset). When enabled, rs-dapi-client reads '
697+
+ 'the Reset header to ban the node for the server-advertised window instead '
698+
+ 'of the exponential health-ban ladder. Disable only for privacy reasons.',
699+
properties: {
700+
enabled: {
701+
type: 'boolean',
702+
},
703+
},
704+
additionalProperties: false,
705+
required: ['enabled'],
706+
},
693707
},
694-
required: ['docker', 'enabled', 'unit', 'requestsPerUnit', 'blacklist', 'whitelist', 'metrics'],
708+
required: ['docker', 'enabled', 'unit', 'requestsPerUnit', 'blacklist', 'whitelist', 'metrics', 'responseHeaders'],
695709
additionalProperties: false,
696710
},
697711
ssl: {

packages/dashmate/templates/platform/gateway/envoy.yaml.dot

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,52 @@
3333
max_concurrent_streams: {{= it.platform.gateway.listeners.dapiAndDrive.http2.maxConcurrentStreams }}
3434
# Stream idle timeout
3535
stream_idle_timeout: 15s
36+
{{? it.platform.gateway.rateLimiter.enabled }}
37+
# Make the grpc-web over-limit reply readable as a rate-limit BAN by browser
38+
# (grpc-web/wasm) clients. The native gRPC over-limit local reply is HTTP 200
39+
# + grpc-status:8 (trailers-only) because Envoy detects content-type
40+
# application/grpc; the grpc-web request (application/grpc-web+proto) is NOT
41+
# gRPC-detected, so `rate_limited_as_resource_exhausted` never tags it and the
42+
# browser sees a bare HTTP 429 with no grpc-status -> tonic-web maps it to
43+
# Unavailable, not ResourceExhausted, so rate_limit_ban_duration() returns None.
44+
#
45+
# This mapper rewrites ONLY the grpc-web over-limit local reply (status 429 AND
46+
# request carried `x-grpc-web`, which the native and JSON-RPC paths never send)
47+
# to status 200 + grpc-status:8. Because the reply is headers-only (empty body,
48+
# end_stream on headers) the grpc_web encoder passes it through untouched
49+
# (encodeHeaders returns Continue when end_stream=true), so grpc-status:8 and
50+
# the existing ratelimit-reset header stay CO-LOCATED in the HTTP response
51+
# headers. tonic-web's create_response then builds the Status (and its metadata,
52+
# incl. ratelimit-reset) from those headers via Status::from_header_map without
53+
# reading the body, giving browser clients the same ResourceExhausted +
54+
# ratelimit-reset node-backoff the native client gets. CORS expose_headers
55+
# already lists both grpc-status and ratelimit-reset (see virtual host below).
56+
local_reply_config:
57+
mappers:
58+
- filter:
59+
and_filter:
60+
filters:
61+
- status_code_filter:
62+
comparison:
63+
op: EQ
64+
value:
65+
default_value: 429
66+
runtime_key: ratelimit_grpc_web_local_reply_status
67+
- header_filter:
68+
header:
69+
name: x-grpc-web
70+
present_match: true
71+
status_code: 200
72+
headers_to_add:
73+
- header:
74+
key: grpc-status
75+
value: "8"
76+
append_action: OVERWRITE_IF_EXISTS_OR_ADD
77+
- header:
78+
key: grpc-message
79+
value: "rate limited"
80+
append_action: OVERWRITE_IF_EXISTS_OR_ADD
81+
{{?}}
3682
{{? it.platform.gateway.log.accessLogs }}
3783
access_log:
3884
{{~ it.platform.gateway.log.accessLogs :log }}
@@ -78,6 +124,24 @@
78124
{{?}}
79125
http_filters:
80126
# TODO: Introduce when stable https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/adaptive_concurrency_filter.html
127+
# Filter order matters: cors and grpc_web MUST precede ratelimit so the
128+
# over-limit RESOURCE_EXHAUSTED *local reply* gets CORS headers + grpc-web
129+
# content-type framing on encode, letting browser (grpc-web) clients read
130+
# RateLimit-Reset for the same node-backoff the native client does. A local
131+
# reply only traverses encoder filters positioned ABOVE its generating
132+
# filter (Envoy #11776), so ratelimit must come last (before router).
133+
# Live-verified on the pinned Envoy build (PR #3951): the grpc-web over-limit
134+
# reply is trailers-only (HTTP 200 + grpc-status:8 + ratelimit-reset as HTTP
135+
# headers, empty body — grpc_web leaves it untouched, no body trailer frame).
136+
# The grpc-status:8 itself is synthesized by the local_reply_config mapper
137+
# above (Envoy's gRPC detection ignores application/grpc-web), giving browser
138+
# clients the same ResourceExhausted + ratelimit-reset node-backoff as native.
139+
- name: envoy.filters.http.cors
140+
typed_config:
141+
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
142+
- name: envoy.filters.http.grpc_web
143+
typed_config:
144+
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
81145
{{? it.platform.gateway.rateLimiter.enabled}}
82146
- name: envoy.filters.http.ratelimit
83147
typed_config:
@@ -96,12 +160,6 @@
96160
timeout: 0.5s
97161
transport_api_version: V3
98162
{{?}}
99-
- name: envoy.filters.http.grpc_web
100-
typed_config:
101-
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
102-
- name: envoy.filters.http.cors
103-
typed_config:
104-
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
105163
- name: envoy.filters.http.router
106164
typed_config:
107165
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
@@ -198,7 +256,7 @@
198256
allow_methods: GET, PUT, DELETE, POST, OPTIONS
199257
allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
200258
max_age: "1728000"
201-
expose_headers: custom-header-1,grpc-status,grpc-message,code,drive-error-data-bin,dash-serialized-consensus-error-bin,stack-bin
259+
expose_headers: custom-header-1,grpc-status,grpc-message,code,drive-error-data-bin,dash-serialized-consensus-error-bin,stack-bin,ratelimit-reset,ratelimit-limit,ratelimit-remaining
202260

203261
static_resources:
204262
listeners:

0 commit comments

Comments
 (0)