Skip to content

Commit cb085ec

Browse files
committed
fix(discord): default non-finite REST numeric options
1 parent 5a64727 commit cb085ec

2 files changed

Lines changed: 110 additions & 15 deletions

File tree

extensions/discord/src/internal/rest.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,59 @@ describe("RequestClient", () => {
7171
expect(client.queueSize).toBe(0);
7272
});
7373

74+
it("defaults non-finite REST client numeric options before scheduling requests", async () => {
75+
const fetchSpy = vi.fn(async (input: string | URL | Request) => {
76+
expect(new URL(readRequestUrl(input)).pathname).toBe("/api/v10/guilds/g1/roles");
77+
return createJsonResponse({ ok: true });
78+
});
79+
const client = new RequestClient("test-token", {
80+
fetch: fetchSpy,
81+
apiVersion: Number.NaN,
82+
timeout: Number.NaN,
83+
maxQueueSize: Number.NaN,
84+
scheduler: {
85+
maxConcurrency: Number.NaN,
86+
maxRateLimitRetries: Number.NaN,
87+
lanes: {
88+
background: {
89+
maxQueueSize: Number.NaN,
90+
staleAfterMs: Number.NaN,
91+
weight: Number.NaN,
92+
},
93+
},
94+
},
95+
});
96+
97+
await expect(client.get("/guilds/g1/roles")).resolves.toEqual({ ok: true });
98+
expect(client.getSchedulerMetrics().maxConcurrentWorkers).toBe(4);
99+
});
100+
101+
it("uses the default background stale timeout for non-finite lane overrides", async () => {
102+
vi.useFakeTimers();
103+
vi.setSystemTime(0);
104+
const firstResponse = createDeferred<Response>();
105+
const fetchSpy = vi.fn(async () => await firstResponse.promise);
106+
const client = new RequestClient("test-token", {
107+
fetch: fetchSpy,
108+
scheduler: {
109+
maxConcurrency: 1,
110+
lanes: {
111+
background: { staleAfterMs: Number.NaN },
112+
},
113+
},
114+
});
115+
116+
const first = client.get("/guilds/g1/roles");
117+
const stale = client.get("/guilds/g2/roles");
118+
await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
119+
120+
await vi.advanceTimersByTimeAsync(20_001);
121+
firstResponse.resolve(createJsonResponse({ ok: "first" }));
122+
123+
await expect(first).resolves.toEqual({ ok: "first" });
124+
await expect(stale).rejects.toThrow(/Dropped stale background request/);
125+
});
126+
74127
it("dispatches critical interaction callbacks before older background requests", async () => {
75128
const firstResponse = createDeferred<Response>();
76129
const responses = new Map<string, Promise<Response>>([

extensions/discord/src/internal/rest.ts

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { randomBytes } from "node:crypto";
22
import { inspect } from "node:util";
3+
import { parseFiniteNumber } from "openclaw/plugin-sdk/number-runtime";
34
import { serializeRequestBody } from "./rest-body.js";
45
import {
56
DiscordError,
@@ -41,6 +42,12 @@ export type RequestClientOptions = {
4142
fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
4243
};
4344

45+
type NormalizedRequestClientOptions = RequestClientOptions & {
46+
apiVersion: number;
47+
maxQueueSize: number;
48+
timeout: number;
49+
};
50+
4451
export type RequestData = {
4552
body?: unknown;
4653
multipartStyle?: "message" | "form";
@@ -134,7 +141,7 @@ async function normalizeFetchBody(
134141
}
135142

136143
export class RequestClient {
137-
readonly options: RequestClientOptions;
144+
readonly options: NormalizedRequestClientOptions;
138145
protected token: string;
139146
protected customFetch: RequestClientOptions["fetch"];
140147
protected requestControllers = new Set<AbortController>();
@@ -143,16 +150,23 @@ export class RequestClient {
143150
constructor(token: string, options?: RequestClientOptions) {
144151
this.token = token.replace(/^Bot\s+/i, "");
145152
this.customFetch = options?.fetch;
146-
this.options = { ...defaultOptions, ...options };
153+
this.options = normalizeRequestClientOptions(options);
147154
this.scheduler = new RestScheduler<RequestData>(
148155
{
149-
lanes: normalizeSchedulerLanes(
150-
this.options.maxQueueSize ?? defaultOptions.maxQueueSize,
151-
this.options.scheduler?.lanes,
156+
lanes: normalizeSchedulerLanes(this.options.maxQueueSize, this.options.scheduler?.lanes),
157+
maxConcurrency: normalizeIntegerOption(
158+
this.options.scheduler?.maxConcurrency,
159+
DEFAULT_MAX_CONCURRENT_WORKERS,
160+
{ min: 1 },
161+
),
162+
maxQueueSize: this.options.maxQueueSize,
163+
maxRateLimitRetries: normalizeIntegerOption(
164+
this.options.scheduler?.maxRateLimitRetries,
165+
3,
166+
{
167+
min: 0,
168+
},
152169
),
153-
maxConcurrency: this.options.scheduler?.maxConcurrency ?? DEFAULT_MAX_CONCURRENT_WORKERS,
154-
maxQueueSize: this.options.maxQueueSize ?? defaultOptions.maxQueueSize,
155-
maxRateLimitRetries: this.options.scheduler?.maxRateLimitRetries ?? 3,
156170
},
157171
async (request) =>
158172
await this.executeRequest(
@@ -280,11 +294,36 @@ export class RequestClient {
280294
}
281295
}
282296

297+
function normalizeIntegerOption(
298+
value: number | undefined,
299+
fallback: number,
300+
params: { min: number },
301+
): number {
302+
const candidate = parseFiniteNumber(value) ?? fallback;
303+
return Math.max(params.min, Math.floor(candidate));
304+
}
305+
306+
function normalizeRequestClientOptions(
307+
options?: RequestClientOptions,
308+
): NormalizedRequestClientOptions {
309+
const merged = { ...defaultOptions, ...options };
310+
return {
311+
...merged,
312+
apiVersion: normalizeIntegerOption(merged.apiVersion, defaultOptions.apiVersion, { min: 1 }),
313+
timeout: normalizeIntegerOption(merged.timeout, defaultOptions.timeout, { min: 1 }),
314+
maxQueueSize: normalizeIntegerOption(merged.maxQueueSize, defaultOptions.maxQueueSize, {
315+
min: 1,
316+
}),
317+
};
318+
}
319+
283320
function normalizeSchedulerLanes(
284321
maxQueueSize: number,
285322
lanes?: RequestSchedulerOptions["lanes"],
286323
): Record<RestRequestPriority, { maxQueueSize: number; staleAfterMs?: number; weight: number }> {
287-
const fallbackMaxQueueSize = Math.max(1, Math.floor(maxQueueSize));
324+
const fallbackMaxQueueSize = normalizeIntegerOption(maxQueueSize, defaultOptions.maxQueueSize, {
325+
min: 1,
326+
});
288327
return {
289328
critical: normalizeSchedulerLane("critical", fallbackMaxQueueSize, lanes?.critical),
290329
standard: normalizeSchedulerLane("standard", fallbackMaxQueueSize, lanes?.standard),
@@ -298,17 +337,20 @@ function normalizeSchedulerLane(
298337
options?: { maxQueueSize?: number; staleAfterMs?: number; weight?: number },
299338
): { maxQueueSize: number; staleAfterMs?: number; weight: number } {
300339
const defaults = defaultLaneOptions[lane];
340+
const staleAfterMs =
341+
options?.staleAfterMs !== undefined
342+
? normalizeIntegerOption(options.staleAfterMs, defaults.staleAfterMs ?? 0, { min: 0 })
343+
: defaults.staleAfterMs;
301344
return {
302345
maxQueueSize:
303346
options?.maxQueueSize !== undefined
304-
? Math.max(1, Math.floor(options.maxQueueSize))
347+
? normalizeIntegerOption(options.maxQueueSize, maxQueueSize, { min: 1 })
305348
: maxQueueSize,
306-
staleAfterMs:
307-
options?.staleAfterMs !== undefined
308-
? Math.max(0, Math.floor(options.staleAfterMs))
309-
: defaults.staleAfterMs,
349+
...(staleAfterMs !== undefined ? { staleAfterMs } : {}),
310350
weight:
311-
options?.weight !== undefined ? Math.max(1, Math.floor(options.weight)) : defaults.weight,
351+
options?.weight !== undefined
352+
? normalizeIntegerOption(options.weight, defaults.weight, { min: 1 })
353+
: defaults.weight,
312354
};
313355
}
314356

0 commit comments

Comments
 (0)