From 97a92f3d4f996925bccf8785667df003c18e027b Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:47:00 -0800 Subject: [PATCH] fix(digitalocean): throw on non-2xx in doApi() wrapper (#2112) * fix(digitalocean): throw on non-2xx in doApi() wrapper Make doApi() throw on non-2xx responses, matching hetznerApi and daytonaApi. 5/7 call sites were silently swallowing 401/403/404/422 errors by only destructuring text and ignoring status. Agent: code-health Co-Authored-By: Claude Sonnet 4.6 * style: fix biome formatting in doApi() signature Function signature needed multi-line format to match biome expectations. Agent: code-health Co-Authored-By: Claude Sonnet 4.5 --------- Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 --- packages/cli/src/digitalocean/digitalocean.ts | 68 ++++++++----------- 1 file changed, 27 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/digitalocean/digitalocean.ts b/packages/cli/src/digitalocean/digitalocean.ts index 6efd2b92..f57376ff 100644 --- a/packages/cli/src/digitalocean/digitalocean.ts +++ b/packages/cli/src/digitalocean/digitalocean.ts @@ -92,15 +92,7 @@ let doServerIp = ""; // ─── API Client ────────────────────────────────────────────────────────────── -async function doApi( - method: string, - endpoint: string, - body?: string, - maxRetries = 3, -): Promise<{ - status: number; - text: string; -}> { +async function doApi(method: string, endpoint: string, body?: string, maxRetries = 3): Promise { const url = `${DO_API_BASE}${endpoint}`; let interval = 2; @@ -129,10 +121,10 @@ async function doApi( interval = Math.min(interval * 2, 30); continue; } - return { - status: resp.status, - text, - }; + if (!resp.ok) { + throw new Error(`DigitalOcean API error ${resp.status} for ${method} ${endpoint}: ${text.slice(0, 200)}`); + } + return text; } catch (err) { if (attempt >= maxRetries) { throw err; @@ -234,7 +226,7 @@ async function testDoToken(): Promise { return false; } try { - const { text } = await doApi("GET", "/account", undefined, 1); + const text = await doApi("GET", "/account", undefined, 1); return text.includes('"uuid"'); } catch { return false; @@ -601,8 +593,8 @@ export async function ensureSshKey(): Promise { } // Check if key is registered with DigitalOcean - const { text } = await doApi("GET", "/account/keys"); - const data = parseJsonObj(text); + const keysText = await doApi("GET", "/account/keys"); + const data = parseJsonObj(keysText); const keys = toObjectArray(data?.ssh_keys); const found = keys.some((k: Record) => { @@ -622,16 +614,22 @@ export async function ensureSshKey(): Promise { name: `spawn-${key.name}`, public_key: pubKey, }); - const { text: regText } = await doApi("POST", "/account/keys", body); - - if (regText.includes('"id"')) { - logInfo(`SSH key '${key.name}' registered with DigitalOcean`); + let regText: string; + try { + regText = await doApi("POST", "/account/keys", body); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + // Key may already exist under a different name — non-fatal + if (msg.includes("already been taken") || msg.includes("already in use")) { + logInfo(`SSH key '${key.name}' already registered (under a different name)`); + continue; + } + logWarn(`SSH key '${key.name}' registration may have failed, continuing...`); continue; } - // Key may already exist under a different name — non-fatal - if (regText.includes("already been taken") || regText.includes("already in use")) { - logInfo(`SSH key '${key.name}' already registered (under a different name)`); + if (regText.includes('"id"')) { + logInfo(`SSH key '${key.name}' registered with DigitalOcean`); continue; } @@ -813,7 +811,7 @@ export async function createServer( logStep(`Creating DigitalOcean droplet '${name}' (size: ${size}, region: ${effectiveRegion})...`); // Get all SSH key IDs - const { text: keysText } = await doApi("GET", "/account/keys"); + const keysText = await doApi("GET", "/account/keys"); const keysData = parseJsonObj(keysText); const sshKeyIds: number[] = toObjectArray(keysData?.ssh_keys) .map((k) => (isNumber(k.id) ? k.id : 0)) @@ -831,7 +829,7 @@ export async function createServer( monitoring: false, }); - const { text: createText } = await doApi("POST", "/droplets", body); + const createText = await doApi("POST", "/droplets", body); const createData = parseJsonObj(createText); if (!createData?.droplet?.id) { @@ -858,7 +856,7 @@ async function waitForDropletActive(dropletId: string, maxAttempts = 60): Promis logStep("Waiting for droplet to become active..."); for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const { text } = await doApi("GET", `/droplets/${dropletId}`); + const text = await doApi("GET", `/droplets/${dropletId}`); const data = parseJsonObj(text); const status = data?.droplet?.status; @@ -1157,19 +1155,7 @@ export async function destroyServer(dropletId?: string): Promise { } logStep(`Destroying DigitalOcean droplet ${id}...`); - const { status, text } = await doApi("DELETE", `/droplets/${id}`); - - // DELETE returns 204 No Content on success (empty body) - if (status === 204) { - logInfo(`Droplet ${id} destroyed`); - return; - } - - // Any non-204 status is a failure — extract the best error message available - const data = parseJsonObj(text); - const errMsg = isString(data?.message) ? data.message : text.slice(0, 200) || `HTTP ${status}`; - logError(`Failed to destroy droplet ${id}: ${errMsg}`); - logWarn("The droplet may still be running and incurring charges."); - logWarn(`Delete it manually at: ${DO_DASHBOARD_URL}`); - throw new Error("Droplet deletion failed"); + // doApi throws on non-2xx; DELETE returns 204 No Content on success + await doApi("DELETE", `/droplets/${id}`); + logInfo(`Droplet ${id} destroyed`); }