Skip to content

Commit 71368fc

Browse files
committed
Cover full DeepSeek response with timeout
The upstream timeout only guarded fetch until headers arrived, so a slow response body could keep the AI proxy open beyond DEEPSEEK_TIMEOUT_MS. The DeepSeek request now races both the fetch and JSON body read against the timeout, aborting and returning deepseek_timeout for slow bodies as well as slow headers. Constraint: Browser chat fallback depends on bounded server-side upstream latency Rejected: Keep only AbortController fetch timeout | it did not cover response body consumption in the reproduced slow-body case Confidence: high Scope-risk: narrow Directive: Keep the upstream timeout wrapped around the full response lifecycle, including body parsing Tested: npm run verify:ai Tested: npm run verify Tested: npm audit --omit=dev
1 parent a848f16 commit 71368fc

2 files changed

Lines changed: 58 additions & 15 deletions

File tree

lib/ai/deepseek.js

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -110,16 +110,21 @@ function normalizeGeneratedContent(value) {
110110
return content.length <= 160 ? content : "";
111111
}
112112

113+
function createDeepSeekTimeoutError() {
114+
const error = new Error("DeepSeek request timed out");
115+
error.status = 504;
116+
error.code = "deepseek_timeout";
117+
return error;
118+
}
119+
113120
export async function createDeepSeekChatCompletion(context) {
114121
const apiKey = requireApiKey();
115122
const endpoint = getDeepSeekEndpoint();
116123
const controller = new AbortController();
117-
const timeout = setTimeout(() => controller.abort(), getDeepSeekTimeoutMs());
124+
let timeout;
118125

119-
let response;
120-
121-
try {
122-
response = await fetch(endpoint, {
126+
const upstreamRequest = (async () => {
127+
const response = await fetch(endpoint, {
123128
method: "POST",
124129
headers: {
125130
"Content-Type": "application/json",
@@ -139,23 +144,38 @@ export async function createDeepSeekChatCompletion(context) {
139144
cache: "no-store",
140145
signal: controller.signal,
141146
});
147+
148+
if (!response.ok) {
149+
const error = new Error(`DeepSeek request failed with ${response.status}`);
150+
error.status = response.status >= 500 ? 502 : response.status;
151+
error.code = "deepseek_request_failed";
152+
throw error;
153+
}
154+
155+
return response.json();
156+
})();
157+
158+
const timeoutExceeded = new Promise((_, reject) => {
159+
timeout = setTimeout(() => {
160+
controller.abort();
161+
reject(createDeepSeekTimeoutError());
162+
}, getDeepSeekTimeoutMs());
163+
});
164+
165+
let data;
166+
167+
try {
168+
data = await Promise.race([upstreamRequest, timeoutExceeded]);
142169
} catch (error) {
170+
if (error.code) throw error;
171+
143172
const wrappedError = new Error(error.name === "AbortError" ? "DeepSeek request timed out" : "DeepSeek request failed");
144173
wrappedError.status = error.name === "AbortError" ? 504 : 502;
145174
wrappedError.code = error.name === "AbortError" ? "deepseek_timeout" : "deepseek_request_failed";
146175
throw wrappedError;
147176
} finally {
148177
clearTimeout(timeout);
149178
}
150-
151-
if (!response.ok) {
152-
const error = new Error(`DeepSeek request failed with ${response.status}`);
153-
error.status = response.status >= 500 ? 502 : response.status;
154-
error.code = "deepseek_request_failed";
155-
throw error;
156-
}
157-
158-
const data = await response.json();
159179
const rawContent = data?.choices?.[0]?.message?.content;
160180
const content = normalizeGeneratedContent(rawContent);
161181

scripts/verify_ai_integration.mjs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const originalFetch = globalThis.fetch;
1111
let captured;
1212
let nextMockContent = '哈哈,没想到你还挺认真。';
1313

14-
globalThis.fetch = async (url, options) => {
14+
const mockDeepSeekFetch = async (url, options) => {
1515
const body = JSON.parse(options.body);
1616
captured = {
1717
url,
@@ -31,6 +31,8 @@ globalThis.fetch = async (url, options) => {
3131
});
3232
};
3333

34+
globalThis.fetch = mockDeepSeekFetch;
35+
3436
function buildRequest(body, headers = {}) {
3537
return new Request('http://localhost/api/ai/chat', {
3638
method: 'POST',
@@ -135,6 +137,27 @@ if (invalidBaseUrlResponse.status !== 500 || invalidBaseUrlData.error !== 'inval
135137
}
136138

137139
process.env.DEEPSEEK_BASE_URL = 'https://api.deepseek.com';
140+
process.env.DEEPSEEK_TIMEOUT_MS = '5';
141+
globalThis.fetch = async () => new Response(new ReadableStream({
142+
start(controller) {
143+
setTimeout(() => {
144+
controller.enqueue(new TextEncoder().encode(JSON.stringify({ choices: [{ message: { content: '慢响应' } }] })));
145+
controller.close();
146+
}, 50);
147+
},
148+
}), {
149+
status: 200,
150+
headers: { 'Content-Type': 'application/json' },
151+
});
152+
const slowBodyResponse = await POST(buildRequest(validPayload));
153+
const slowBodyData = await slowBodyResponse.json();
154+
155+
if (slowBodyResponse.status !== 504 || slowBodyData.error !== 'deepseek_timeout') {
156+
throw new Error(`slow DeepSeek response bodies should be covered by DEEPSEEK_TIMEOUT_MS: ${JSON.stringify({ status: slowBodyResponse.status, slowBodyData })}`);
157+
}
158+
159+
process.env.DEEPSEEK_TIMEOUT_MS = '8000';
160+
globalThis.fetch = mockDeepSeekFetch;
138161

139162
const badRequest = buildRequest({ targetLine: '' });
140163
const badResponse = await POST(badRequest);

0 commit comments

Comments
 (0)