bod-cli 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./src/cli.ts"
@@ -138,13 +138,60 @@ function branchToSubdomain(branch: string, domain: string, productionBranch: str
138
138
  return `${sanitized}--${domain}`
139
139
  }
140
140
 
141
+ /**
142
+ * A transport-level failure (socket reset, DNS blip, 5xx/429 from the edge) as opposed to a
143
+ * definitive answer from the server. `BodClient.request` throws `METHOD /path → <status>: body`
144
+ * for HTTP errors and lets fetch's own errors (`socket connection was closed unexpectedly`, …)
145
+ * propagate as-is — so anything without a 4xx status marker is retryable.
146
+ */
147
+ function isTransientError(err: unknown): boolean {
148
+ const msg = err instanceof Error ? err.message : String(err)
149
+ const status = msg.match(/→ (\d{3}):/)?.[1]
150
+ if (!status) return true // fetch threw — never reached the server, or the reply was cut off
151
+ return status === '408' || status === '429' || status.startsWith('5')
152
+ }
153
+
154
+ /**
155
+ * The poll loop only OBSERVES a deployment the server already accepted — a dead poll socket says
156
+ * nothing about whether the deploy succeeded. So transient reads are retried instead of aborting
157
+ * the command. Deliberately NOT in `BodClient`: the deploy upload/POST are non-idempotent and must
158
+ * not be blindly replayed.
159
+ */
160
+ async function getWithRetry<T>(client: BodClient, path: string, attempts = 4): Promise<T> {
161
+ let lastErr: unknown
162
+ for (let i = 0; i < attempts; i++) {
163
+ try {
164
+ return await client.get<T>(path)
165
+ } catch (err) {
166
+ lastErr = err
167
+ if (!isTransientError(err) || i === attempts - 1) throw err
168
+ const backoff = 1000 * 2 ** i // 1s, 2s, 4s
169
+ console.log(chalk.dim(` (poll transport hiccup: ${err instanceof Error ? err.message : err} — retrying in ${backoff / 1000}s)`))
170
+ await Bun.sleep(backoff)
171
+ }
172
+ }
173
+ throw lastErr
174
+ }
175
+
141
176
  async function pollDeploy(client: BodClient, appId: string, since: number, instanceCaps?: { baseDomain?: string | null }, localConfig?: { localDomain?: string; caddyPort?: number } | null) {
142
177
  console.log(chalk.dim('Waiting for deployment...'))
143
178
  let lastStatus = ''
144
179
  let lastLogTs = since
145
180
  for (let i = 0; i < 120; i++) {
146
181
  await Bun.sleep(2000)
147
- const detail = await client.get<any>(`/apps/${appId}`)
182
+ let detail: any
183
+ try {
184
+ detail = await getWithRetry<any>(client, `/apps/${appId}`)
185
+ } catch (err) {
186
+ if (!isTransientError(err)) throw err
187
+ // The deploy was accepted; we simply lost the ability to watch it. Failing here would report
188
+ // a healthy deploy as broken — and, worse, make a genuinely broken one look identical.
189
+ // Verify the deployed app itself (smoke test / `bod apps status`) — that is the real gate.
190
+ console.log(chalk.yellow(`⚠ Lost contact with the API while watching the deployment (${err instanceof Error ? err.message : err}).`))
191
+ console.log(chalk.yellow(' The deployment was accepted and is still running server-side — this is a POLL failure, not a deploy failure.'))
192
+ console.log(chalk.yellow(' Verify with "bod apps status" or by hitting the app.'))
193
+ return
194
+ }
148
195
  const deps = detail.deployments ?? []
149
196
  const latest = deps.find((d: any) => (d.createdAt ?? 0) >= since)
150
197
  if (!latest) continue