Skip to content

Commit 894826c

Browse files
committed
fix(minimax): raise undici headers/body timeout past the 300s cliff
Node's built-in fetch (undici 6) caps the headers and body response timeouts at 300_000 ms. The clawpatch AbortController above the fetch never sees a long-running minimax response finish — undici rejects it with a low-level HeadersTimeoutError first, which the user sees as 'minimax provider network error: fetch failed' at ~301s even when CLAWPATCH_MINIMAX_TIMEOUT_MS is set much higher. Install a custom undici Agent whose headersTimeout and bodyTimeout track minimaxTimeoutMs(), and raise the default timeout to 30 minutes so the AbortController is the dominant timeout for typical large code reviews.
1 parent 81597b7 commit 894826c

5 files changed

Lines changed: 121 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## 0.5.1 - Unreleased
44

5+
- Fixed `minimax` provider review timeouts at ~301s for large prompts. Node's built-in fetch uses undici with a hard-coded 300_000 ms headers/body timeout, which silently rejected long minimax responses as `fetch failed` even when the user set `CLAWPATCH_MINIMAX_TIMEOUT_MS` higher. The provider now installs a custom undici `Agent` whose `headersTimeout`/`bodyTimeout` track `minimaxTimeoutMs()`, and the default timeout was raised to 30 minutes so the AbortController is the dominant timeout for typical large reviews.
6+
57
## 0.5.0 - 2026-05-31
68

79
- Added CUDA support to the C/C++ mapper, mapping `.cu` and `.cuh` sources as standalone `main()` files, CMake and autotools targets, legacy `FindCUDA` `cuda_add_executable` / `cuda_add_library` calls, and bounded loose source groups.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"crabbox:warmup": "crabbox warmup"
3131
},
3232
"dependencies": {
33+
"undici": "^6.21.0",
3334
"zod": "^4.4.3"
3435
},
3536
"devDependencies": {

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/provider.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const {
3636
formatZodIssue,
3737
minimaxBaseUrl,
3838
minimaxDefaultModel,
39+
minimaxDispatcher,
3940
minimaxExitCode,
4041
minimaxFailureMessage,
4142
minimaxTimeoutMs,
@@ -86,6 +87,25 @@ function withEnv(name: string, value: string | undefined, fn: () => void): void
8687
}
8788
}
8889

90+
// undici stores Agent construction options on an internal Symbol-keyed slot
91+
// (`Symbol(options)`), not as enumerable properties. Helper for tests that
92+
// need to assert which timeouts were configured on a dispatcher.
93+
function readAgentOptions(agent: unknown): { headersTimeout: number; bodyTimeout: number } {
94+
const sym = Object.getOwnPropertySymbols(agent as object).find(
95+
(s) => s.toString() === "Symbol(options)",
96+
);
97+
if (sym === undefined) {
98+
throw new Error("undici Agent options symbol not found");
99+
}
100+
const options = (agent as Record<symbol, { headersTimeout: number; bodyTimeout: number } | undefined>)[
101+
sym
102+
];
103+
if (options === undefined) {
104+
throw new Error("undici Agent options slot is empty");
105+
}
106+
return options;
107+
}
108+
89109
function updateEnvelope(update: object): string {
90110
return JSON.stringify({
91111
jsonrpc: "2.0",
@@ -1989,7 +2009,50 @@ describe("minimax provider", () => {
19892009

19902010
it("minimaxTimeoutMs falls back to default for invalid values", () => {
19912011
withEnv("CLAWPATCH_MINIMAX_TIMEOUT_MS", "not-a-number", () => {
1992-
expect(minimaxTimeoutMs()).toBe(300_000);
2012+
expect(minimaxTimeoutMs()).toBe(1_800_000);
2013+
});
2014+
});
2015+
2016+
it("minimaxTimeoutMs default is large enough to clear the undici 300s cliff", () => {
2017+
withEnv("CLAWPATCH_MINIMAX_TIMEOUT_MS", undefined, () => {
2018+
withEnv("CLAWPATCH_PROVIDER_TIMEOUT_MS", undefined, () => {
2019+
// Node's built-in fetch has a 300_000 ms headers/body timeout. The
2020+
// AbortController is the user-visible timeout, so it must be larger
2021+
// than 300_000 to ever fire before undici rejects the response.
2022+
expect(minimaxTimeoutMs()).toBeGreaterThan(300_000);
2023+
});
2024+
});
2025+
});
2026+
2027+
it("minimaxDispatcher returns an undici Agent sized to the configured timeout", () => {
2028+
withEnv("CLAWPATCH_MINIMAX_TIMEOUT_MS", "120000", () => {
2029+
// undici stores Agent options on an internal Symbol-keyed slot
2030+
// (`Symbol(options)`), not as enumerable properties. Reach in to verify
2031+
// the timeouts we passed at construction time made it through.
2032+
const dispatcher = minimaxDispatcher();
2033+
const options = readAgentOptions(dispatcher);
2034+
expect(options.headersTimeout).toBe(120000);
2035+
expect(options.bodyTimeout).toBe(120000);
2036+
});
2037+
});
2038+
2039+
it("minimaxDispatcher caches the agent when timeout is unchanged", () => {
2040+
withEnv("CLAWPATCH_MINIMAX_TIMEOUT_MS", "180000", () => {
2041+
const a = minimaxDispatcher();
2042+
const b = minimaxDispatcher();
2043+
expect(a).toBe(b);
2044+
});
2045+
});
2046+
2047+
it("minimaxDispatcher rebuilds the agent when the timeout changes", () => {
2048+
withEnv("CLAWPATCH_MINIMAX_TIMEOUT_MS", "180000", () => {
2049+
const a = minimaxDispatcher();
2050+
withEnv("CLAWPATCH_MINIMAX_TIMEOUT_MS", "240000", () => {
2051+
const b = minimaxDispatcher();
2052+
expect(a).not.toBe(b);
2053+
const options = readAgentOptions(b);
2054+
expect(options.headersTimeout).toBe(240000);
2055+
});
19932056
});
19942057
});
19952058

src/provider.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
22
import { tmpdir } from "node:os";
33
import { join } from "node:path";
4+
import { Agent } from "undici";
45
import { z, type ZodError, type ZodIssue, type ZodType } from "zod";
56
import { runCommandArgs } from "./exec.js";
67
import { ClawpatchError } from "./errors.js";
@@ -456,7 +457,15 @@ const grokProvider: Provider = {
456457
const MINIMAX_PROVIDER_NAME = "minimax";
457458
const MINIMAX_DEFAULT_BASE_URL = "https://api.minimax.io/v1";
458459
const MINIMAX_DEFAULT_MODEL = "MiniMax-M3";
459-
const MINIMAX_DEFAULT_TIMEOUT_MS = 300_000;
460+
// Node's built-in fetch (undici) caps the headers/body response timeout at
461+
// 300_000 ms by default. The clawpatch `setTimeout`/AbortController above the
462+
// fetch never sees that — undici rejects with a low-level HeadersTimeoutError
463+
// first, which surfaces to the user as "minimax provider network error: fetch
464+
// failed" after exactly ~301s. Raising the AbortController value alone is not
465+
// enough; we also have to install a custom undici dispatcher whose
466+
// headers/body timeouts match the AbortController. Bump the default to 30 min
467+
// so the AbortController is the dominant timeout for typical large reviews.
468+
const MINIMAX_DEFAULT_TIMEOUT_MS = 1_800_000;
460469
const MINIMAX_CHECK_TIMEOUT_MS = 30_000;
461470

462471
function minimaxTimeoutMs(): number {
@@ -469,6 +478,33 @@ function minimaxTimeoutMs(): number {
469478
return Number.isFinite(parsed) && parsed > 0 ? parsed : MINIMAX_DEFAULT_TIMEOUT_MS;
470479
}
471480

481+
// Cached undici dispatcher sized to the current `minimaxTimeoutMs()` value.
482+
// The dispatcher controls the underlying socket-level timeouts (headers and
483+
// body) that the global `fetch` does not expose. We rebuild the agent when
484+
// the configured timeout changes so behaviour stays in sync with env-var
485+
// overrides (typically set once at process start, but a test may flip them).
486+
// The Agent return type is structurally compatible with the Dispatcher type
487+
// expected by the global `fetch` `dispatcher` option, even when the type
488+
// packages between undici 6 and @types/node differ.
489+
let minimaxDispatcherCache: { timeoutMs: number; dispatcher: InstanceType<typeof Agent> } | null =
490+
null;
491+
function minimaxDispatcher(): InstanceType<typeof Agent> {
492+
const timeoutMs = minimaxTimeoutMs();
493+
if (minimaxDispatcherCache !== null && minimaxDispatcherCache.timeoutMs === timeoutMs) {
494+
return minimaxDispatcherCache.dispatcher;
495+
}
496+
if (minimaxDispatcherCache !== null) {
497+
void minimaxDispatcherCache.dispatcher.close();
498+
}
499+
const dispatcher = new Agent({
500+
headersTimeout: timeoutMs,
501+
bodyTimeout: timeoutMs,
502+
connectTimeout: 10_000,
503+
});
504+
minimaxDispatcherCache = { timeoutMs, dispatcher };
505+
return dispatcher;
506+
}
507+
472508
function minimaxBaseUrl(): string {
473509
const raw = process.env["MINIMAX_BASE_URL"];
474510
return raw !== undefined && raw.length > 0 ? raw : MINIMAX_DEFAULT_BASE_URL;
@@ -560,6 +596,13 @@ async function runMinimaxJson(
560596
},
561597
body: JSON.stringify(body),
562598
signal: controller.signal,
599+
// undici dispatcher with headers/body timeouts matching the
600+
// AbortController. Without this, Node's global fetch uses a 300s
601+
// hard-coded headers/body timeout and the AbortController never fires.
602+
// The undici 6 Agent type is structurally compatible with the global
603+
// fetch Dispatcher type, but the two type packages disagree, so we
604+
// cast through `unknown` at the call site.
605+
dispatcher: minimaxDispatcher() as unknown as NonNullable<RequestInit["dispatcher"]>,
563606
});
564607
} catch (err) {
565608
clearTimeout(timer);
@@ -2572,6 +2615,7 @@ export const __testing = {
25722615
parseOrThrow,
25732616
minimaxBaseUrl,
25742617
minimaxDefaultModel,
2618+
minimaxDispatcher,
25752619
minimaxExitCode,
25762620
minimaxFailureMessage,
25772621
minimaxTimeoutMs,

0 commit comments

Comments
 (0)