diff --git a/src/commands/branch/create.test.ts b/src/commands/branch/create.test.ts index 26edee1..598c181 100644 --- a/src/commands/branch/create.test.ts +++ b/src/commands/branch/create.test.ts @@ -570,6 +570,40 @@ describe('branch create', () => { expect(String(spinnerMock.stop.mock.calls.at(-1)?.[0])).not.toContain('creation failed'); }); + it('adopts a branch that was created despite a 502 response', async () => { + const { getProjectConfig } = await import('../../lib/config.js'); + (getProjectConfig as Mock).mockReturnValue({ + project_id: 'p1', + project_name: 'parent', + org_id: 'o1', + }); + const { createBranchApi, listBranchesApi } = await import('../../lib/api/platform.js'); + (createBranchApi as Mock).mockRejectedValueOnce( + new CLIError('Request failed: 502', 1, undefined, 502), + ); + (listBranchesApi as Mock).mockResolvedValueOnce([ + { + id: 'branch-id', + parent_project_id: 'p1', + organization_id: 'o1', + name: 'feat-x', + appkey: 'p1ky-x9p', + region: 'us-east', + branch_state: 'creating', + branch_created_at: new Date().toISOString(), + branch_metadata: { mode: 'schema-only' }, + }, + ]); + const program = new Command().exitOverride(); + program.option('--json').option('--api-url ').option('-y, --yes'); + registerBranchCreateCommand(program); + await program + .parseAsync(['create', 'feat-x', '--mode', 'schema-only', '--no-switch'], { from: 'user' }) + .catch(() => {}); + expect(listBranchesApi as Mock).toHaveBeenCalledWith('p1', undefined); + expect(String(spinnerMock.stop.mock.calls.at(-1)?.[0])).not.toContain('creation failed'); + }); + it('does NOT adopt a same-name branch created with a DIFFERENT mode', async () => { // A collaborator's same-name branch landing in the skew window — at the same // moment our own request loses its response leg — must not be adopted, or a diff --git a/src/commands/branch/create.ts b/src/commands/branch/create.ts index d5b6be2..5e2c607 100644 --- a/src/commands/branch/create.ts +++ b/src/commands/branch/create.ts @@ -189,8 +189,8 @@ export function registerBranchCreateCommand(branch: Command): void { } /** - * Create the branch, and if the request fails WITHOUT an answer from the API - * itself, check whether it was created anyway before giving up. + * Create the branch, and if the request fails ambiguously at the transport or + * gateway layer, check whether it was created anyway before giving up. * * `createBranchApi` carries no idempotency key, and a reset on the RESPONSE leg * leaves a fully created, billing branch behind while the CLI exits non-zero. @@ -199,15 +199,14 @@ export function registerBranchCreateCommand(branch: Command): void { * authoritative here, and it is a control-plane call, so it still works while * the branch's own host is unreachable. * - * Three guards keep this from adopting something it did not create — a duplicate - * name is a REJECTION, not a lost response, and adopting on it would switch the - * caller into someone else's branch with a different mode and different data: + * Two guards keep this from adopting something it did not create — a duplicate + * name is a REJECTION, not an ambiguous failure, and adopting on it would + * switch the caller into someone else's branch with a different mode and + * different data: * - * 1. only an ambiguous failure is eligible — a transport reset, or one of the - * PROXY-level statuses, which is the edge reporting that IT could not - * complete the round trip and says nothing about what the backend did. - * Every answer the API itself authored — a duplicate name, a quota, auth, - * any other 4xx, and a plain 500 — rethrows untouched; + * 1. only a tagged transport failure or a 502/503/504 gateway failure is + * eligible; other HTTP/API rejections (duplicate name, quota, auth) + * rethrow untouched; * 2. the branch must have been created at or after the moment we sent the * request, so a pre-existing same-name branch is never a candidate; * 3. the branch's mode must match what we asked for. @@ -218,8 +217,9 @@ export function registerBranchCreateCommand(branch: Command): void { * and with the default `--switch` that would silently move local context onto * their branch. Requiring a mode match makes that require an even more specific * coincidence (same name AND same mode AND the same ~60s AND our transport - * failure). The real fix is a server-issued idempotency/request token on - * `createBranchApi`; until that exists, this is the tightest client-side guard. + * or gateway failure). The real fix is a server-issued idempotency/request token + * on `createBranchApi`; until that exists, this is the tightest client-side + * guard. * Reported upstream: InsForge/InsForge#1790. * * When no matching branch turns up, the original error is rethrown unchanged — diff --git a/src/lib/api/platform.ts b/src/lib/api/platform.ts index 61b7e6e..ccb5c89 100644 --- a/src/lib/api/platform.ts +++ b/src/lib/api/platform.ts @@ -120,13 +120,9 @@ export async function platformFetch( return retryRes; } if (!retryRes.ok) { - const err = await retryRes.json().catch(() => ({})) as { error?: string }; - throw new CLIError( - err.error ?? `Request failed: ${retryRes.status}`, - retryRes.status === 403 ? 5 : 1, - undefined, - retryRes.status, - ); + const err = await retryRes.json().catch(() => ({})) as { error?: string; message?: string }; + const message = err.message ? `${err.error ?? retryRes.status}: ${err.message}` : (err.error ?? `Request failed: ${retryRes.status}`); + throw new CLIError(message, retryRes.status === 403 ? 5 : 1, undefined, retryRes.status); } return retryRes; }