Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/commands/branch/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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
Expand Down
24 changes: 12 additions & 12 deletions src/commands/branch/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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 —
Expand Down
10 changes: 3 additions & 7 deletions src/lib/api/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading