From 423b564fe0a4bd6372672e0c2e360b6e510d122e Mon Sep 17 00:00:00 2001 From: shaomingbo Date: Mon, 3 Aug 2026 11:12:27 +0800 Subject: [PATCH] fix(boss): retry detached read once --- clis/boss/utils.js | 24 +++++++++++++++++++---- clis/boss/utils.test.js | 42 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/clis/boss/utils.js b/clis/boss/utils.js index 123630603..3d4ba520f 100644 --- a/clis/boss/utils.js +++ b/clis/boss/utils.js @@ -95,6 +95,13 @@ export function assertOk(data, errorPrefix) { const prefix = errorPrefix ? `${errorPrefix}: ` : ''; throw new CommandExecutionError(`${prefix}${data.message || 'Unknown error'} (code=${data.code})`); } +function throwBossFetchError(error) { + if (error instanceof AuthRequiredError || error instanceof CommandExecutionError) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + throw new CommandExecutionError(`Boss API request failed: ${message}`); +} /** * Make a credentialed XHR request via page.evaluate(). * @@ -132,11 +139,20 @@ export async function bossFetch(page, url, opts = {}) { try { data = await page.evaluate(script); } catch (error) { - if (error instanceof AuthRequiredError || error instanceof CommandExecutionError) { - throw error; + const detachedMidCommand = error && typeof error === 'object' + && error.code === 'detached_mid_command'; + if (method === 'GET' && detachedMidCommand) { + // The browser bridge cannot retry a mid-command detach generically because + // evaluate() may contain a write. This adapter knows the request is a GET, + // so one fresh evaluation is safe even if the first read completed. + try { + data = await page.evaluate(script); + } catch (retryError) { + throwBossFetchError(retryError); + } + } else { + throwBossFetchError(error); } - const message = error instanceof Error ? error.message : String(error); - throw new CommandExecutionError(`Boss API request failed: ${message}`); } if (!data || typeof data !== 'object') { throw new CommandExecutionError('Boss API returned malformed response'); diff --git a/clis/boss/utils.test.js b/clis/boss/utils.test.js index b8d6a348b..a632f343c 100644 --- a/clis/boss/utils.test.js +++ b/clis/boss/utils.test.js @@ -1,6 +1,44 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; -import { assertOk } from './utils.js'; +import { assertOk, bossFetch } from './utils.js'; + +function detachedMidCommandError() { + return Object.assign(new Error('Detached while handling command.'), { + code: 'detached_mid_command', + }); +} + +describe('bossFetch', () => { + it('retries a detached GET once because replaying a read is safe', async () => { + const page = { + evaluate: vi.fn() + .mockRejectedValueOnce(detachedMidCommandError()) + .mockResolvedValueOnce({ code: 0, zpData: { jobList: [] } }), + }; + + await expect(bossFetch(page, 'https://www.zhipin.com/wapi/zpgeek/search/joblist.json')) + .resolves.toEqual({ code: 0, zpData: { jobList: [] } }); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); + + it('propagates a second detached GET without entering a retry loop', async () => { + const page = { evaluate: vi.fn().mockRejectedValue(detachedMidCommandError()) }; + + await expect(bossFetch(page, 'https://www.zhipin.com/wapi/zpgeek/search/joblist.json')) + .rejects.toThrow('Detached while handling command'); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); + + it('does not retry a detached POST because its outcome may be a completed write', async () => { + const page = { evaluate: vi.fn().mockRejectedValueOnce(detachedMidCommandError()) }; + + await expect(bossFetch(page, 'https://www.zhipin.com/wapi/write', { + method: 'POST', + body: 'value=1', + })).rejects.toThrow('Detached while handling command'); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); +}); describe('assertOk', () => { it('returns silently on code 0', () => {