Skip to content

Commit 73ae1e7

Browse files
authored
fix: enrich RateLimitError message and its property (#672)
* fix: enrich `RateLimitError` message and its property * stamp: fmt
1 parent bdf1893 commit 73ae1e7

8 files changed

Lines changed: 185 additions & 21 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Worker: makes one outbound fetch to itself, catches RateLimitError, and
2+
// returns { retryAfterMs } in JSON so the integration test can verify the field.
3+
//
4+
// Requests with "x-skip: 1" are terminal — they return immediately without
5+
// making an outbound call, so the worker does not recurse indefinitely.
6+
Deno.serve(async (req: Request) => {
7+
const serverUrl = req.headers.get("x-test-server-url");
8+
if (!serverUrl) {
9+
return new Response(
10+
JSON.stringify({ msg: "missing x-test-server-url header" }),
11+
{ status: 400, headers: { "Content-Type": "application/json" } },
12+
);
13+
}
14+
15+
// Terminal hop: just acknowledge, no outbound call.
16+
if (req.headers.get("x-skip") === "1") {
17+
return new Response(JSON.stringify({ ok: true }), {
18+
status: 200,
19+
headers: { "Content-Type": "application/json" },
20+
});
21+
}
22+
23+
try {
24+
await fetch(`${serverUrl}/rate-limit-retry-after`, {
25+
headers: {
26+
"x-test-server-url": serverUrl,
27+
"x-skip": "1",
28+
},
29+
});
30+
return new Response(JSON.stringify({ ok: true }), {
31+
status: 200,
32+
headers: { "Content-Type": "application/json" },
33+
});
34+
} catch (e) {
35+
if (e instanceof Deno.errors.RateLimitError) {
36+
return new Response(
37+
JSON.stringify({ name: e.name, retryAfterMs: e.retryAfterMs }),
38+
{ status: 429, headers: { "Content-Type": "application/json" } },
39+
);
40+
}
41+
return new Response(
42+
JSON.stringify({ msg: String(e) }),
43+
{ status: 500, headers: { "Content-Type": "application/json" } },
44+
);
45+
}
46+
});

crates/base/tests/integration_tests.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4361,6 +4361,83 @@ async fn test_request_trace_id_isolation() {
43614361
);
43624362
}
43634363

4364+
/// Verifies that `RateLimitError.retryAfterMs` is a positive number when the
4365+
/// global (untraced) budget is exhausted.
4366+
#[tokio::test]
4367+
#[serial]
4368+
async fn test_rate_limit_retry_after_ms() {
4369+
init_otel();
4370+
4371+
// The budget in rate-limit-main is 10. Send 11 requests; the last one
4372+
// should come back as 429 with a positive retryAfterMs value.
4373+
const BUDGET: usize = 10;
4374+
4375+
integration_test_with_server_flag!(
4376+
ServerFlags {
4377+
rate_limit_cleanup_interval_sec: 60,
4378+
request_wait_timeout_ms: Some(30_000),
4379+
..Default::default()
4380+
},
4381+
"./test_cases/rate-limit-main",
4382+
NON_SECURE_PORT,
4383+
"rate-limit-retry-after",
4384+
None,
4385+
None::<reqwest::RequestBuilder>,
4386+
None::<Tls>,
4387+
(
4388+
|(port, _url, _req_builder, _event_rx, _metric_src)| async move {
4389+
let client = reqwest::Client::new();
4390+
let url = format!("http://localhost:{}/rate-limit-retry-after", port);
4391+
let server_url = format!("http://localhost:{}", port);
4392+
4393+
// Exhaust the budget.
4394+
for _ in 0..BUDGET {
4395+
let resp = client
4396+
.get(&url)
4397+
.header("x-test-server-url", &server_url)
4398+
.send()
4399+
.await
4400+
.unwrap();
4401+
// Each of these may itself trigger an inner fetch that is counted
4402+
// against the budget; we only care about the final one below.
4403+
let _ = resp;
4404+
}
4405+
4406+
// This request should be rate-limited.
4407+
let resp = client
4408+
.get(&url)
4409+
.header("x-test-server-url", &server_url)
4410+
.send()
4411+
.await;
4412+
4413+
Some(resp)
4414+
},
4415+
|resp| async move {
4416+
let res = resp.unwrap();
4417+
assert_eq!(
4418+
res.status().as_u16(),
4419+
429,
4420+
"expected 429 from rate-limited request"
4421+
);
4422+
let body: serde_json::Value = res.json().await.unwrap();
4423+
assert_eq!(
4424+
body["name"].as_str().unwrap_or(""),
4425+
"RateLimitError",
4426+
"expected RateLimitError in body, got: {body}"
4427+
);
4428+
let retry_after_ms = body["retryAfterMs"]
4429+
.as_u64()
4430+
.expect("retryAfterMs should be a non-null number");
4431+
assert!(
4432+
retry_after_ms > 0,
4433+
"retryAfterMs should be positive, got {retry_after_ms}"
4434+
);
4435+
}
4436+
),
4437+
TerminationToken::new()
4438+
);
4439+
}
4440+
43644441
#[derive(Deserialize)]
43654442
struct ErrorResponsePayload {
43664443
msg: String,

ext/node/polyfills/http.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -497,16 +497,21 @@ class ClientRequest extends OutgoingMessage {
497497
const traceId = internals.getRequestTraceId?.();
498498
const isTraced = traceId !== null && traceId !== undefined;
499499
const rlKey = isTraced ? traceId : "";
500-
const allowed = op_check_outbound_rate_limit(
500+
// op returns u32::MAX when allowed, or retry_after_ms (< 0xFFFFFFFF) when denied.
501+
const rlResult = op_check_outbound_rate_limit(
501502
parsedUrl.href,
502503
rlKey,
503504
isTraced,
504505
);
505-
if (!allowed) {
506+
if (rlResult !== 0xFFFFFFFF) {
507+
const retryAfterMs = rlResult;
508+
const retryHint = retryAfterMs > 0
509+
? ` Retry after ${retryAfterMs}ms.`
510+
: "";
506511
const msg = isTraced
507-
? `Rate limit exceeded for trace ${rlKey}`
508-
: `Rate limit exceeded for function`;
509-
throw new Deno.errors.RateLimitError(msg);
512+
? `Rate limit exceeded for trace ${rlKey}.${retryHint}`
513+
: `Rate limit exceeded for function.${retryHint}`;
514+
throw new Deno.errors.RateLimitError(msg, retryAfterMs);
510515
}
511516

512517
this._req = op_node_http_request(

ext/runtime/js/errors.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,22 @@ const DOMExceptionInvalidCharacterError = buildDomErrorClass(
5656
"InvalidCharacterError",
5757
);
5858
const DOMExceptionDataError = buildDomErrorClass("DOMExceptionDataError");
59-
const RateLimitError = buildErrorClass("RateLimitError");
59+
const RateLimitError = (() => {
60+
const cls = class RateLimitError extends Error {
61+
constructor(msg, retryAfterMs) {
62+
super(msg);
63+
this.name = "RateLimitError";
64+
// Number of milliseconds until the rate-limit window resets.
65+
// May be 0 if the server could not determine the reset time.
66+
this.retryAfterMs = typeof retryAfterMs === "number"
67+
? retryAfterMs
68+
: null;
69+
}
70+
};
71+
cls.getName = () => "RateLimitError";
72+
knownErrors["RateLimitError"] = cls;
73+
return cls;
74+
})();
6075

6176
function registerErrors() {
6277
core.registerErrorClass("InvalidWorkerResponse", InvalidWorkerResponse);

ext/runtime/lib.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -450,16 +450,21 @@ pub fn op_bootstrap_unstable_args(_state: &mut OpState) -> Vec<String> {
450450
}
451451

452452
#[op2(fast)]
453+
/// Returns `u32::MAX` when the request is allowed, or the number of
454+
/// milliseconds until the rate-limit window resets when denied (< u32::MAX).
453455
pub fn op_check_outbound_rate_limit(
454456
state: &mut OpState,
455457
#[string] url: &str,
456458
#[string] key: &str,
457459
is_traced: bool,
458-
) -> bool {
460+
) -> u32 {
459461
let Some(limiter) = state.try_borrow::<TraceRateLimiter>() else {
460-
return true;
462+
return u32::MAX;
461463
};
462-
limiter.check_and_increment(url, key, is_traced)
464+
match limiter.check_and_increment(url, key, is_traced) {
465+
Ok(()) => u32::MAX,
466+
Err(retry_after_ms) => retry_after_ms.min(u32::MAX as u64 - 1) as u32,
467+
}
463468
}
464469

465470
deno_core::extension!(

ext/runtime/rate_limit.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,14 @@ impl SharedRateLimitTable {
6262
});
6363
}
6464

65+
/// Returns `Ok(())` when the request is allowed, or `Err(retry_after_ms)`
66+
/// with the number of milliseconds until the window resets when denied.
6567
pub fn check_and_increment(
6668
&self,
6769
key: &str,
6870
budget: u32,
6971
ttl: Duration,
70-
) -> bool {
72+
) -> Result<(), u64> {
7173
let now = Instant::now();
7274

7375
let mut entry =
@@ -98,11 +100,13 @@ impl SharedRateLimitTable {
98100
);
99101

100102
if !allowed {
101-
return false;
103+
let retry_after_ms =
104+
entry.expires_at.saturating_duration_since(now).as_millis() as u64;
105+
return Err(retry_after_ms);
102106
}
103107

104108
entry.count += 1;
105-
true
109+
Ok(())
106110
}
107111
}
108112

@@ -166,16 +170,18 @@ impl TraceRateLimiter {
166170
})
167171
}
168172

173+
/// Returns `Ok(())` when the request is allowed, or `Err(retry_after_ms)`
174+
/// with the number of milliseconds until the window resets when denied.
169175
pub fn check_and_increment(
170176
&self,
171177
url: &str,
172178
key: &str,
173179
is_traced: bool,
174-
) -> bool {
180+
) -> Result<(), u64> {
175181
let rule = self.rules.iter().find(|r| r.matches.is_match(url));
176182

177183
let Some(rule) = rule else {
178-
return true;
184+
return Ok(());
179185
};
180186

181187
if is_traced {
@@ -187,7 +193,7 @@ impl TraceRateLimiter {
187193
// budget accumulates correctly across worker instances. Deny the request
188194
// if the caller did not supply one.
189195
let Some(fid) = self.global_key.as_deref() else {
190-
return false;
196+
return Err(0);
191197
};
192198
self
193199
.table

types/global.d.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,13 @@ declare namespace Deno {
254254
class WorkerAlreadyRetired extends Error {}
255255

256256
/** Thrown when an outbound HTTP request is blocked by the rate limiter. */
257-
class RateLimitError extends Error {}
257+
class RateLimitError extends Error {
258+
/**
259+
* Number of milliseconds until the rate-limit window resets.
260+
* `null` if the reset time could not be determined.
261+
*/
262+
retryAfterMs: number | null;
263+
constructor(message: string, retryAfterMs?: number);
264+
}
258265
}
259266
}

vendor/deno_fetch/26_fetch.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -399,16 +399,19 @@ function fetch(input, init = { __proto__: null }) {
399399
const traceId = internals.getRequestTraceId?.();
400400
const isTraced = traceId !== null && traceId !== undefined;
401401
const rlKey = isTraced ? traceId : "";
402-
const allowed = op_check_outbound_rate_limit(
402+
// op returns u32::MAX when allowed, or retry_after_ms (< 0xFFFFFFFF) when denied.
403+
const rlResult = op_check_outbound_rate_limit(
403404
requestObject.url,
404405
rlKey,
405406
isTraced,
406407
);
407-
if (!allowed) {
408+
if (rlResult !== 0xFFFFFFFF) {
409+
const retryAfterMs = rlResult;
410+
const retryHint = retryAfterMs > 0 ? ` Retry after ${retryAfterMs}ms.` : "";
408411
const msg = isTraced
409-
? `Rate limit exceeded for trace ${rlKey}`
410-
: `Rate limit exceeded for function`;
411-
reject(new Deno.errors.RateLimitError(msg));
412+
? `Rate limit exceeded for trace ${rlKey}.${retryHint}`
413+
: `Rate limit exceeded for function.${retryHint}`;
414+
reject(new Deno.errors.RateLimitError(msg, retryAfterMs));
412415
return;
413416
}
414417

0 commit comments

Comments
 (0)