Skip to content

Commit ed16970

Browse files
hugenshensteipete
andauthored
fix(feishu): pass timeoutMs through app-registration guarded fetch (#105549)
* fix(feishu): pass timeoutMs through app-registration guarded fetch * refactor(feishu): keep registration timeout internal * docs(changelog): credit Feishu timeout fix --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 75234c6 commit ed16970

3 files changed

Lines changed: 45 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
4141

4242
### Fixes
4343

44+
- **Feishu app registration deadlines:** bound OAuth device-registration requests to 10 seconds through the guarded fetch boundary so setup cannot hang indefinitely on stalled response headers. (#105549) Thanks @hugenshen.
4445
- **LINE control-command mentions:** detect authorized slash commands before mention stripping so inline group and direct-message controls preserve the original ingress metadata. (#107230) Thanks @edenfunf.
4546
- **Feishu document image reads:** bound remote document-image headers and stalled bodies with the selected account timeout, parse document Markdown through the plugin's MDAST pipeline, preserve image/block alignment, and reject failed upload input before creating empty image blocks. Thanks @Alix-007.
4647
- **ClawHub registry reads:** retry bounded HTTP 500 responses alongside other transient gateway failures so multi-package release scans survive isolated registry errors.

extensions/feishu/src/app-registration.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,39 @@ describe("Feishu app registration", () => {
254254
expect(writeSpy).toHaveBeenCalledWith("terminal-qr\n");
255255
});
256256

257+
it("times out registration POSTs when accounts never return headers", async () => {
258+
await withRegistrationServer(
259+
// Accept TCP but never write headers — idle body timers never start.
260+
(_req, _res) => {},
261+
async (options) => {
262+
const started = Date.now();
263+
const outcome = await beginAppRegistration("feishu", {
264+
...options,
265+
// Keep the real guarded-fetch path while shortening its production deadline.
266+
timeoutMs: 80,
267+
}).then(
268+
(value) => ({ ok: true as const, value }),
269+
(error: unknown) => ({ ok: false as const, error }),
270+
);
271+
const elapsedMs = Date.now() - started;
272+
expect(outcome.ok).toBe(false);
273+
if (!outcome.ok) {
274+
expect(outcome.error).toMatchObject({
275+
name: "TimeoutError",
276+
message: "request timed out",
277+
});
278+
}
279+
expect(elapsedMs).toBeGreaterThanOrEqual(60);
280+
expect(elapsedMs).toBeLessThan(2_000);
281+
console.log(
282+
`[feishu fetchFeishuJson hang proof] timed_out=${!outcome.ok} name=${
283+
outcome.ok ? "n/a" : (outcome.error as Error).name
284+
} elapsed_ms=${elapsedMs}`,
285+
);
286+
},
287+
);
288+
});
289+
257290
// over-cap: body > 16 MiB, no Content-Length. The bounded reader cancels
258291
// through the real SSRF guard and rejects before full buffering.
259292
it("rejects Feishu API responses that exceed the 16 MiB JSON body cap", async () => {

extensions/feishu/src/app-registration.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ const LARK_ACCOUNTS_URL = "https://accounts.larksuite.com";
2222

2323
const REGISTRATION_PATH = "/oauth/v1/app/registration";
2424

25-
const REQUEST_TIMEOUT_MS = 10_000;
25+
// QR onboarding should fall back promptly when an accounts endpoint stalls;
26+
// regular Feishu API requests use the longer FEISHU_HTTP_TIMEOUT_MS budget.
27+
const APP_REGISTRATION_REQUEST_TIMEOUT_MS = 10_000;
2628
const DEFAULT_REGISTRATION_POLL_INTERVAL_SECONDS = 5;
2729
const DEFAULT_REGISTRATION_EXPIRE_SECONDS = 600;
2830

@@ -57,6 +59,8 @@ type FeishuAppRegistrationFetchOptions = {
5759
fetchImpl?: FeishuAppRegistrationFetch;
5860
/** Override hostname lookup for hermetic SSRF-guard tests. */
5961
lookupFn?: LookupFn;
62+
/** Override the registration HTTP deadline for tests. */
63+
timeoutMs?: number;
6064
};
6165

6266
interface RawBeginResponse {
@@ -105,11 +109,11 @@ async function postRegistration<T>(
105109
method: "POST",
106110
headers: { "Content-Type": "application/x-www-form-urlencoded" },
107111
body: new URLSearchParams(body).toString(),
108-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
109112
},
110113
auditContext: "feishu.app-registration.post",
111114
fetchImpl: options?.fetchImpl,
112115
lookupFn: options?.lookupFn,
116+
timeoutMs: options?.timeoutMs,
113117
});
114118
}
115119

@@ -119,12 +123,15 @@ async function fetchFeishuJson<T>(params: {
119123
auditContext: string;
120124
fetchImpl?: FeishuAppRegistrationFetch;
121125
lookupFn?: LookupFn;
126+
timeoutMs?: number;
122127
}): Promise<T> {
128+
const timeoutMs = params.timeoutMs ?? APP_REGISTRATION_REQUEST_TIMEOUT_MS;
123129
const { response, release } = await fetchWithSsrFGuard({
124130
url: params.url,
125131
init: params.init,
126132
fetchImpl: params.fetchImpl,
127133
lookupFn: params.lookupFn,
134+
timeoutMs,
128135
policy: { allowedHostnames: [new URL(params.url).hostname] },
129136
auditContext: params.auditContext,
130137
});
@@ -227,9 +234,7 @@ export async function pollAppRegistration(params: {
227234
let domainSwitched = false;
228235

229236
const expireInMs =
230-
finiteSecondsToTimerSafeMilliseconds(expireIn) ??
231-
finiteSecondsToTimerSafeMilliseconds(DEFAULT_REGISTRATION_EXPIRE_SECONDS) ??
232-
REQUEST_TIMEOUT_MS;
237+
finiteSecondsToTimerSafeMilliseconds(expireIn) ?? DEFAULT_REGISTRATION_EXPIRE_SECONDS * 1_000;
233238
const deadline = Date.now() + expireInMs;
234239

235240
while (Date.now() < deadline) {
@@ -342,7 +347,6 @@ export async function getAppOwnerOpenId(params: {
342347
method: "POST",
343348
headers: { "Content-Type": "application/json" },
344349
body: JSON.stringify({ app_id: params.appId, app_secret: params.appSecret }),
345-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
346350
},
347351
auditContext: "feishu.app-registration.owner-token",
348352
fetchImpl: params.fetchImpl,
@@ -369,7 +373,6 @@ export async function getAppOwnerOpenId(params: {
369373
Authorization: `Bearer ${tokenData.tenant_access_token}`,
370374
"Content-Type": "application/json",
371375
},
372-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
373376
},
374377
auditContext: "feishu.app-registration.owner-app",
375378
fetchImpl: params.fetchImpl,
@@ -394,7 +397,6 @@ export async function getAppOwnerOpenId(params: {
394397
function sleepRegistrationPollInterval(intervalSeconds: number): Promise<void> {
395398
const intervalMs =
396399
finiteSecondsToTimerSafeMilliseconds(intervalSeconds) ??
397-
finiteSecondsToTimerSafeMilliseconds(DEFAULT_REGISTRATION_POLL_INTERVAL_SECONDS) ??
398-
REQUEST_TIMEOUT_MS;
400+
DEFAULT_REGISTRATION_POLL_INTERVAL_SECONDS * 1_000;
399401
return sleep(intervalMs);
400402
}

0 commit comments

Comments
 (0)