From 85f01eb2a88654c4e8cb09612385907230c2efc2 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:25:36 +0530 Subject: [PATCH 1/4] feat(cli): add `SUPABASE_API_MAX_RETRIES` to cap api retries (CLI-2607) --- apps/cli/docs/supabase/login.md | 2 + apps/cli/src/SIDE_EFFECTS_TEMPLATE.md | 9 +- .../src/auth/command-platform-api.layer.ts | 1 + .../command-platform-api.layer.unit.test.ts | 39 ++++- .../src/command-internal/db-config.service.ts | 6 +- ...e-runtime-script.layer.integration.test.ts | 1 + .../login-api.layer.integration.test.ts | 30 +++- .../src/command-internal/login-api.layer.ts | 9 +- .../resolve-token.unit.test.ts | 1 + .../start-export-pointer.integration.test.ts | 1 + .../commands/functions/deploy/SIDE_EFFECTS.md | 1 + .../functions/deploy/deploy.handler.ts | 3 +- .../deploy/deploy.integration.test.ts | 140 +++++++++++++++++- .../src/commands/services/services.handler.ts | 2 + .../services/services.integration.test.ts | 1 + apps/cli/src/config/command-settings.layer.ts | 47 +++++- .../command-settings.layer.unit.test.ts | 36 ++++- .../src/config/command-settings.service.ts | 2 + .../config/remote-target.layer.unit.test.ts | 1 + apps/cli/src/shared/functions/deploy.ts | 32 ++-- .../src/shared/services/services.shared.ts | 16 +- .../telemetry/__fixtures__/error-tags.txt | 1 + apps/cli/tests/helpers/command-mocks.ts | 2 + apps/cli/tests/helpers/compute.ts | 1 + 24 files changed, 349 insertions(+), 35 deletions(-) diff --git a/apps/cli/docs/supabase/login.md b/apps/cli/docs/supabase/login.md index 0387dbd3f6..a17cef5a4f 100644 --- a/apps/cli/docs/supabase/login.md +++ b/apps/cli/docs/supabase/login.md @@ -7,3 +7,5 @@ Your access token is stored securely in [native credentials storage](https://git > If this behavior is not desired, such as in a CI environment, you may skip login by specifying the `SUPABASE_ACCESS_TOKEN` environment variable in other commands. The Supabase CLI uses the stored token to access Management APIs for projects, functions, secrets, etc. + +> The Management API client retries retryable failures up to 5 times by default. Set `SUPABASE_API_MAX_RETRIES` to change that, such as `0` to turn retries off in automation. diff --git a/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md b/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md index 4bc1f85932..584e124b8e 100644 --- a/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md +++ b/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md @@ -58,10 +58,11 @@ -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| Variable | Purpose | Required? | +| -------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_API_MAX_RETRIES` | Management API retries after the first attempt (`0` = one attempt) | no (falls back to 5) | ## Exit Codes diff --git a/apps/cli/src/auth/command-platform-api.layer.ts b/apps/cli/src/auth/command-platform-api.layer.ts index 89f1e1a039..f5a6eef8eb 100644 --- a/apps/cli/src/auth/command-platform-api.layer.ts +++ b/apps/cli/src/auth/command-platform-api.layer.ts @@ -67,6 +67,7 @@ export const makeCommandPlatformApi = Effect.gen(function* () { }, { transformClient, + retry: { maxRetries: Option.getOrUndefined(cliSettings.apiMaxRetries) }, }, ); }).pipe(Effect.withSpan("CommandPlatformApi.build")); diff --git a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts index 1203e1aba1..18c1b0bbcc 100644 --- a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts +++ b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts @@ -38,6 +38,7 @@ function mockCliSettings(opts: { userAgent?: string; profile?: string; projectHost?: string; + apiMaxRetries?: number; }) { return Layer.succeed(CommandSettings, { dbPassword: Option.none(), @@ -56,6 +57,7 @@ function mockCliSettings(opts: { workdir: "/tmp", explicitWorkdir: false, userAgent: opts.userAgent ?? "SupabaseCLI/0.0.0-dev", + apiMaxRetries: Option.fromUndefinedOr(opts.apiMaxRetries), }); } @@ -185,7 +187,7 @@ function withBaseDeps( ); } -function captureRequests(responseHeaders: Record = {}) { +function captureRequests(responseHeaders: Record = {}, status = 200) { const requests: Array<{ url: string; headers: Readonly>; @@ -196,7 +198,7 @@ function captureRequests(responseHeaders: Record = {}) { HttpClientResponse.fromWeb( request, new Response(JSON.stringify([]), { - status: 200, + status, headers: { "content-type": "application/json", ...responseHeaders }, }), ), @@ -310,6 +312,39 @@ describe("commandPlatformApiLayer", () => { }).pipe(Effect.provide(layer)); }); + it.effect("sends a failing request once when SUPABASE_API_MAX_RETRIES is 0", () => { + /* `Retry-After: 0` keeps any retry from waiting on the test clock. */ + const http = captureRequests({ "Retry-After": "0" }, 500); + const layer = commandPlatformApiLayer.pipe( + Layer.provide(mockCliSettings({ accessToken: VALID_TOKEN, apiMaxRetries: 0 })), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + withBaseDeps(), + ); + return Effect.gen(function* () { + const api = yield* CommandPlatformApi; + const exit = yield* Effect.exit(api.v1.listAllProjects()); + expect(Exit.isFailure(exit)).toBe(true); + expect(http.requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps the client's default retries when SUPABASE_API_MAX_RETRIES is unset", () => { + const http = captureRequests({ "Retry-After": "0" }, 500); + const layer = commandPlatformApiLayer.pipe( + Layer.provide(mockCliSettings({ accessToken: VALID_TOKEN })), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + withBaseDeps(), + ); + return Effect.gen(function* () { + const api = yield* CommandPlatformApi; + const exit = yield* Effect.exit(api.v1.listAllProjects()); + expect(Exit.isFailure(exit)).toBe(true); + expect(http.requests).toHaveLength(6); + }).pipe(Effect.provide(layer)); + }); + it.effect("targets the configured apiUrl rather than SUPABASE_API_URL env", () => { const http = captureRequests(); const layer = commandPlatformApiLayer.pipe( diff --git a/apps/cli/src/command-internal/db-config.service.ts b/apps/cli/src/command-internal/db-config.service.ts index d4377428d7..a73f9eec09 100644 --- a/apps/cli/src/command-internal/db-config.service.ts +++ b/apps/cli/src/command-internal/db-config.service.ts @@ -8,6 +8,7 @@ import type { ProjectRefNotLinkedError, } from "../config/project-ref.errors.ts"; import type { ProfileLoadError } from "./profile-load.ts"; +import type { ApiMaxRetriesError } from "../config/command-settings.layer.ts"; import type { LinkedProjectRefReadError } from "./project-link.ts"; import type { DbConnectError } from "./db-connection.errors.ts"; import type { LocalDbRunningError } from "./db-bootstrap/local-db-running.ts"; @@ -57,8 +58,9 @@ export type DbConfigError = // layer-build channel. `--linked --password` skips `make` entirely and never raises these. | CommandPlatformApiFactoryError // The lazy linked runtime rebuilds `commandSettingsLayer`, whose strict - // profile resolution can fail inside the resolver effect the same way. - | ProfileLoadError; + // profile and `SUPABASE_API_MAX_RETRIES` checks can fail inside the resolver effect the same way. + | ProfileLoadError + | ApiMaxRetriesError; // The `--linked` path builds a lazy Management API runtime (so `--local`/`--db-url` never // resolve an access token) and provides every one of its own requirements from the resolver's diff --git a/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts b/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts index 1ac039779d..7b6448af42 100644 --- a/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts +++ b/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts @@ -58,6 +58,7 @@ function makeCliSettings(workdir = "/nonexistent-workdir") { workdir, explicitWorkdir: false, userAgent: "test", + apiMaxRetries: Option.none(), }); } diff --git a/apps/cli/src/command-internal/login-api.layer.integration.test.ts b/apps/cli/src/command-internal/login-api.layer.integration.test.ts index 6e7da75165..684d9bf57d 100644 --- a/apps/cli/src/command-internal/login-api.layer.integration.test.ts +++ b/apps/cli/src/command-internal/login-api.layer.integration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { LoginApi } from "../commands/login/login-api.service.ts"; import { @@ -13,11 +14,14 @@ import { loginApiLayer, loginProfile } from "./login-api.layer.ts"; const PROFILE = { gotrue_id: "gotrue-user-123", primary_email: "a@b.co", username: "ab" }; -function setup(opts: Parameters[0]) { +function setup( + opts: Parameters[0], + apiMaxRetries = Option.none(), +) { const api = mockCommandPlatformApi(opts); const layer = loginApiLayer.pipe( Layer.provide(api.httpClientLayer), - Layer.provide(mockCommandSettings({ workdir: "/unused-workdir" })), + Layer.provide(mockCommandSettings({ workdir: "/unused-workdir", apiMaxRetries })), ); return { api, layer }; } @@ -59,4 +63,26 @@ describe("loginApiLayer", () => { expect(api.requests).toHaveLength(1); }).pipe(Effect.provide(layer)); }); + + it.effect("looks the profile up once when SUPABASE_API_MAX_RETRIES is 0", () => { + const { api, layer } = setup( + { + /* `Retry-After: 0` keeps any retry from waiting on the test clock. */ + handler: (request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 500, headers: { "Retry-After": "0" } }), + ), + ), + }, + Option.some(0), + ); + return Effect.gen(function* () { + const loginApi = yield* LoginApi; + const gotrueId = yield* loginApi.fetchGotrueId(DEFAULT_API_URL, VALID_TOKEN); + expect(gotrueId).toEqual(Option.none()); + expect(api.requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/command-internal/login-api.layer.ts b/apps/cli/src/command-internal/login-api.layer.ts index 14c78dbe13..f94ae30aee 100644 --- a/apps/cli/src/command-internal/login-api.layer.ts +++ b/apps/cli/src/command-internal/login-api.layer.ts @@ -88,11 +88,10 @@ export const loginApiLayer = Layer.effect( // host and token. fetchGotrueId: (apiHost: string, token: string) => Effect.gen(function* () { - const api = yield* makeApiClient({ - baseUrl: apiHost, - accessToken: token, - userAgent: cliSettings.userAgent, - }); + const api = yield* makeApiClient( + { baseUrl: apiHost, accessToken: token, userAgent: cliSettings.userAgent }, + { retry: { maxRetries: Option.getOrUndefined(cliSettings.apiMaxRetries) } }, + ); const profile = yield* api.v1.getProfile(); return profile.gotrue_id.length > 0 ? Option.some(profile.gotrue_id) diff --git a/apps/cli/src/command-internal/resolve-token.unit.test.ts b/apps/cli/src/command-internal/resolve-token.unit.test.ts index 8e2cb29fd1..ed427d194a 100644 --- a/apps/cli/src/command-internal/resolve-token.unit.test.ts +++ b/apps/cli/src/command-internal/resolve-token.unit.test.ts @@ -23,6 +23,7 @@ const settings = (accessToken: Option.Option>) => dbPassword: Option.none(), githubToken: Option.none(), userAgent: "SupabaseCLI/test", + apiMaxRetries: Option.none(), }); const credentials = ( diff --git a/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts b/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts index 706f606743..8e6bfba846 100644 --- a/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start-export-pointer.integration.test.ts @@ -209,6 +209,7 @@ const runStatusEnv = (input: { explicitWorkdir: Option.isSome(workdirFlag), workdirEnvValue: Option.none(), userAgent: "SupabaseCLI/pointer-test", + apiMaxRetries: Option.none(), }); }), ); diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 7ef3b49ae0..f13d604243 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -61,6 +61,7 @@ Docker bundling may pull or run the configured edge-runtime image and uses the | `BITBUCKET_CLONE_DIR` | when defined (including an empty value), skips creating the named Deno-cache volume and omits its bind mount from the bundler `docker run` (Bitbucket's restricted Docker environment rejects both); project values are passed explicitly to Docker setup | no | | `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which bundler image tag to use) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | | `NPM_CONFIG_REGISTRY` | forwarded into Docker bundling when set (the only npm variable forwarded; `NPM_AUTH_TOKEN` is not) | no | +| `SUPABASE_API_MAX_RETRIES` | Management API retries after the first attempt; when set, the API client's retries replace this command's own retry loops (a duplicated create still becomes one update) | no (unset keeps this command's own retries) | | `DEBUG` | enables verbose Docker bundle output when `true` | no | ## Exit Codes diff --git a/apps/cli/src/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/commands/functions/deploy/deploy.handler.ts index 143b3ff8a8..4e9578a359 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.handler.ts @@ -1,5 +1,5 @@ import { Effect, Option, Path, Ref, Stdio } from "effect"; -import { deployFunctions } from "../../../shared/functions/deploy.ts"; +import { DeployApiMaxRetries, deployFunctions } from "../../../shared/functions/deploy.ts"; import { resolveEdgeRuntimeVersionPin } from "../../../shared/functions/functions.shared.ts"; import { aqua, bold, yellow } from "../../../command-internal/colors.ts"; import { functionsGoConfigCompat } from "../../../command-internal/functions-go-config.ts"; @@ -53,6 +53,7 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( // Written to stderr, matching `yellow`'s default TTY gate. styleWarning: (text) => yellow(text), }).pipe( + Effect.provideService(DeployApiMaxRetries, cliSettings.apiMaxRetries), Effect.ensuring( Ref.get(resolvedProjectRef).pipe( Effect.flatMap( diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index a1bb1fe224..7f755797c6 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -766,6 +766,7 @@ describe("functions deploy", () => { handler: ApiHandler, args: ReadonlyArray = ["functions", "deploy", "hello-world", "--use-api"], rateLimitNotices?: Queue.Enqueue, + apiMaxRetries = Option.none(), ) { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ handler }); @@ -797,7 +798,7 @@ describe("functions deploy", () => { ).pipe(Layer.provide(out.layer)), }, api, - cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + cliSettings: mockCommandSettings({ workdir: tempRoot.current, apiMaxRetries }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), Layer.succeed(YesFlag, false), @@ -1027,6 +1028,62 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); + it.effect( + "leaves a failed function list to the client when SUPABASE_API_MAX_RETRIES is set", + () => { + const { layer, api } = apiDeployHarness( + (request) => Effect.succeed(jsonResponse(request, 500, { message: "boom" })), + undefined, + undefined, + Option.some(2), + ); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + /* The mock client never retries, so any further GET would come from the command. */ + const error = yield* functionsDeploy(baseFlags).pipe(Effect.flip); + + expect(error).toMatchObject({ status: 500 }); + expect( + api.requests.filter((request) => request.url.endsWith(`/${VALID_REF}/functions`)), + ).toHaveLength(1); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }, + ); + + it.effect( + "leaves a rate-limited deploy to the client when SUPABASE_API_MAX_RETRIES is set", + () => { + const { layer, api } = apiDeployHarness( + (request) => + Effect.succeed( + request.method === "GET" + ? jsonResponse(request, 200, []) + : HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": "0" } }), + ), + ), + undefined, + undefined, + Option.some(2), + ); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + /* The mock client never retries, so any further POST would come from the command. */ + const error = yield* functionsDeploy(baseFlags).pipe(Effect.flip); + + expect(error).toMatchObject({ status: 429 }); + expect(api.requests.filter((request) => request.method === "POST")).toHaveLength(1); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }, + ); + it.effect("gives up on a persistent rate limit after the command's backoff schedule", () => Effect.gen(function* () { const notices = yield* Queue.unbounded(); @@ -1570,6 +1627,7 @@ describe("functions deploy", () => { readonly deployStatuses: ReadonlyArray; readonly bulkStatuses?: ReadonlyArray; readonly functionStatuses?: Readonly>; + readonly apiMaxRetries?: Option.Option; }) { const out = mockOutput({ format: "text" }); let deployCalls = 0; @@ -1620,7 +1678,10 @@ describe("functions deploy", () => { buildTestRuntime({ out, api, - cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + cliSettings: mockCommandSettings({ + workdir: tempRoot.current, + apiMaxRetries: opts.apiMaxRetries, + }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), Layer.succeed(YesFlag, false), @@ -1665,6 +1726,34 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); + it.effect( + "leaves a failed bulk update to the client when SUPABASE_API_MAX_RETRIES is set", + () => { + const { api, layer } = setupBulkDeploy({ + deployStatuses: [201, 201], + bulkStatuses: [500, 200], + apiMaxRetries: Option.some(2), + }); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* writeLocalFunction(tempRoot.current, "bye-world"); + + /* The mock client never retries, so any further PUT would come from the command. */ + const error = yield* functionsDeploy({ + ...baseFlags, + functionNames: ["hello-world", "bye-world"], + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + message: 'unexpected bulk update status 500: {"message":"bulk update rejected"}', + }); + expect(api.requests.filter((request) => request.method === "PUT")).toHaveLength(1); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }, + ); + it.effect("skips the bulk update entirely when every upload fails", () => { const { out, api, layer } = setupBulkDeploy({ deployStatuses: [409, 400] }); @@ -2260,6 +2349,53 @@ describe("functions deploy", () => { }); } + it.live("updates a duplicated function once when SUPABASE_API_MAX_RETRIES is 0", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.method === "GET" + ? jsonResponse(request, 200, []) + : request.method === "POST" + ? jsonResponse(request, 400, { message: "Duplicated function slug" }) + : jsonResponse(request, 500, { message: "boom" }), + ), + }); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ + workdir: tempRoot.current, + apiMaxRetries: Option.some(0), + }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(YesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + const error = yield* functionsDeploy({ ...baseFlags, useApi: false, useDocker: true }).pipe( + Effect.flip, + ); + + expect(error).toMatchObject({ status: 500 }); + expect( + api.requests + .filter((request) => request.method !== "GET") + .map((request) => request.method), + ).toEqual(["POST", "PATCH"]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + it.live( "resolves the deno v1 edge-runtime image tag when SUPABASE_EDGE_RUNTIME_DENO_VERSION=1 overrides an unset config value", () => { diff --git a/apps/cli/src/commands/services/services.handler.ts b/apps/cli/src/commands/services/services.handler.ts index 52608ba28e..ed322fb859 100644 --- a/apps/cli/src/commands/services/services.handler.ts +++ b/apps/cli/src/commands/services/services.handler.ts @@ -143,6 +143,7 @@ export const services = Effect.fn("services")(function* (_flags: ServicesFlags) projectRef: validLinkedRef.value, accessToken: accessToken.value, userAgent: cliSettings.userAgent, + maxRetries: Option.getOrUndefined(cliSettings.apiMaxRetries), }) : {}; const result = yield* stackServiceVersions(cliSettings.workdir, remote); @@ -212,6 +213,7 @@ export const services = Effect.fn("services")(function* (_flags: ServicesFlags) projectRef: validLinkedRef.value, accessToken: accessToken.value, userAgent: cliSettings.userAgent, + maxRetries: Option.getOrUndefined(cliSettings.apiMaxRetries), }); rows = mergeRemoteServiceVersions(remote, localImageOptions); } diff --git a/apps/cli/src/commands/services/services.integration.test.ts b/apps/cli/src/commands/services/services.integration.test.ts index 3cdff4aa24..effbfe3e89 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -130,6 +130,7 @@ function setup( workdir: opts.workdir ?? defaultWorkdir.current, explicitWorkdir: false, userAgent: "SupabaseCLI/test", + apiMaxRetries: Option.none(), }), ), Layer.succeed( diff --git a/apps/cli/src/config/command-settings.layer.ts b/apps/cli/src/config/command-settings.layer.ts index 27dc344cf2..b820e24d04 100644 --- a/apps/cli/src/config/command-settings.layer.ts +++ b/apps/cli/src/config/command-settings.layer.ts @@ -1,4 +1,14 @@ -import { Config, ConfigProvider, Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { + Config, + ConfigProvider, + Data, + Effect, + FileSystem, + Layer, + Option, + Path, + Redacted, +} from "effect"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { lastExplicitLongFlagValue } from "../shared/cli/cobra-flag-groups.ts"; import { CLI_VERSION } from "../shared/cli/version.ts"; @@ -12,6 +22,20 @@ import { DebugLogger, type DebugLoggerShape } from "../command-internal/debug-lo import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { CommandSettings } from "./command-settings.service.ts"; import { resolveSupabaseHomeValue } from "../shared/config/supabase-home.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../shared/telemetry/error-actionability.ts"; + +/** A `SUPABASE_API_MAX_RETRIES` that is not a non-negative integer, rejected before any API request. */ +export class ApiMaxRetriesError extends Data.TaggedError("ApiMaxRetriesError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} function unknownMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); @@ -171,6 +195,26 @@ export const commandSettingsLayer = Layer.unwrap( path, ); + /* Empty means unset, like the other SUPABASE_* settings; anything else must be plain + digits, rejected here before a command reaches the network. */ + const rawApiMaxRetries = yield* read( + Config.option(Config.String("SUPABASE_API_MAX_RETRIES")), + ); + const apiMaxRetries = yield* Option.match( + Option.filter(rawApiMaxRetries, (value) => value.length > 0), + { + onNone: () => Effect.succeed(Option.none()), + onSome: (value) => + /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) + ? Effect.succeed(Option.some(Number(value))) + : Effect.fail( + new ApiMaxRetriesError({ + message: `invalid SUPABASE_API_MAX_RETRIES "${value}": expected a non-negative integer`, + }), + ), + }, + ); + const userAgent = `SupabaseCLI/${CLI_VERSION}`; yield* Effect.annotateCurrentSpan({ @@ -194,6 +238,7 @@ export const commandSettingsLayer = Layer.unwrap( explicitWorkdir, workdirEnvValue, userAgent, + apiMaxRetries, }); }).pipe(Effect.withSpan("CommandSettings.load")), ); diff --git a/apps/cli/src/config/command-settings.layer.unit.test.ts b/apps/cli/src/config/command-settings.layer.unit.test.ts index c548a92f91..de7ff3cb5d 100644 --- a/apps/cli/src/config/command-settings.layer.unit.test.ts +++ b/apps/cli/src/config/command-settings.layer.unit.test.ts @@ -19,7 +19,7 @@ import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; import { mockRuntimeInfo } from "../../tests/helpers/mocks.ts"; import { debugLoggerLayer } from "../command-internal/debug-logger.layer.ts"; import { ProfileLoadError } from "../command-internal/profile-load.ts"; -import { commandSettingsLayer } from "./command-settings.layer.ts"; +import { ApiMaxRetriesError, commandSettingsLayer } from "./command-settings.layer.ts"; import { CommandSettings } from "./command-settings.service.ts"; const path = Effect.runSync(Effect.provide(Path.Path, BunPath.layer)); @@ -502,6 +502,40 @@ describe("commandSettingsLayer", () => { ), ); + it.effect("captures SUPABASE_API_MAX_RETRIES, treating an empty value as unset", () => + Effect.gen(function* () { + const read = (value: string | undefined) => + Effect.service(CommandSettings).pipe( + Effect.provide( + makeLayer({ env: { SUPABASE_API_MAX_RETRIES: value }, cwd: tempRoot.current }), + ), + Effect.map((config) => config.apiMaxRetries), + ); + expect(yield* read("0")).toEqual(Option.some(0)); + expect(yield* read("3")).toEqual(Option.some(3)); + expect(yield* read("")).toEqual(Option.none()); + expect(yield* read(undefined)).toEqual(Option.none()); + }), + ); + + it.effect("rejects a SUPABASE_API_MAX_RETRIES that is not a non-negative integer", () => + Effect.gen(function* () { + for (const value of ["-1", "1.5", "1e2", " 3", "abc"]) { + const exit = yield* configExit({ + env: { SUPABASE_API_MAX_RETRIES: value }, + cwd: tempRoot.current, + }); + const error = Exit.isFailure(exit) + ? exit.cause.reasons.find(Cause.isFailReason)?.error + : undefined; + expect(error).toBeInstanceOf(ApiMaxRetriesError); + expect(error).toMatchObject({ + message: `invalid SUPABASE_API_MAX_RETRIES "${value}": expected a non-negative integer`, + }); + } + }), + ); + it.effect("captures SUPABASE_PROJECT_ID env", () => Effect.gen(function* () { const config = yield* CommandSettings; diff --git a/apps/cli/src/config/command-settings.service.ts b/apps/cli/src/config/command-settings.service.ts index 3c2b57143a..b14a6ff494 100644 --- a/apps/cli/src/config/command-settings.service.ts +++ b/apps/cli/src/config/command-settings.service.ts @@ -47,6 +47,8 @@ interface CommandSettingsShape { /** Raw `SUPABASE_WORKDIR` value; `Some("")` differs from an absent variable and is used verbatim. */ readonly workdirEnvValue: Option.Option; readonly userAgent: string; + /** `SUPABASE_API_MAX_RETRIES`: Management API retries after the first attempt; none keeps the client default. */ + readonly apiMaxRetries: Option.Option; } export class CommandSettings extends Context.Service()( diff --git a/apps/cli/src/config/remote-target.layer.unit.test.ts b/apps/cli/src/config/remote-target.layer.unit.test.ts index 3a31b38fe1..aee1734a10 100644 --- a/apps/cli/src/config/remote-target.layer.unit.test.ts +++ b/apps/cli/src/config/remote-target.layer.unit.test.ts @@ -33,6 +33,7 @@ function mockCliSettings(opts: { workdir: string; projectId?: string }) { workdir: opts.workdir, explicitWorkdir: false, userAgent: "SupabaseCLI/0.0.0-dev", + apiMaxRetries: Option.none(), }); } diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index a3c5b63d7e..403f6e89e4 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -14,6 +14,7 @@ import { import { Cause, Config, + Context, Duration, Effect, Fiber, @@ -1694,6 +1695,7 @@ const listRemoteFunctions = Effect.fn("functions.deploy.listRemoteFunctions")(fu api: ApiClient, projectRef: string, ) { + const apiMaxRetries = yield* DeployApiMaxRetries; return yield* Effect.gen(function* () { const response = yield* api .executeRaw(operationDefinitions.v1ListAllFunctions, { ref: projectRef }) @@ -1721,7 +1723,7 @@ const listRemoteFunctions = Effect.fn("functions.deploy.listRemoteFunctions")(fu }).pipe( Effect.retry({ schedule: Schedule.exponential(Duration.seconds(1)), - times: 3, + times: Option.isSome(apiMaxRetries) ? 0 : 3, while: (error) => !(error instanceof FunctionsApiStatusError) || error.status >= 500 || error.status === 429, }), @@ -1766,6 +1768,12 @@ function rateLimitDelayText(milliseconds: number) { // The command's own 429 loop is the only retry layer. const NO_CLIENT_RETRY = { retry: { maxRetries: 0 } } as const; +/** `SUPABASE_API_MAX_RETRIES`: once set, the client's retries replace the command loops below. */ +export const DeployApiMaxRetries = Context.Reference>( + "supabase/functions/deploy/DeployApiMaxRetries", + { defaultValue: () => Option.none() }, +); + const rateLimitedRequest = Effect.fnUntraced(function* ( action: string, request: () => Effect.Effect< @@ -1778,12 +1786,11 @@ const rateLimitedRequest = Effect.fnUntraced(function* ( >, ) { const output = yield* Output; + const maxRetries = Option.isSome(yield* DeployApiMaxRetries) ? 0 : DEPLOY_RATE_LIMIT_MAX_RETRIES; return yield* Effect.repeat(Effect.suspend(request), ($) => $(Schedule.forever).pipe( Schedule.passthrough, - Schedule.while( - ({ input, attempt }) => input.status === 429 && attempt <= DEPLOY_RATE_LIMIT_MAX_RETRIES, - ), + Schedule.while(({ input, attempt }) => input.status === 429 && attempt <= maxRetries), Schedule.modifyDelay(({ input, attempt, now }) => Effect.succeed(Duration.millis(rateLimitDelayMillis(input.headers, attempt - 1, now))), ), @@ -1807,6 +1814,7 @@ const uploadFunctionSource = Effect.fn("functions.deploy.uploadFunctionSource")( bundleOnly: boolean, ) { const output = yield* Output; + const apiMaxRetries = yield* DeployApiMaxRetries; const files = yield* joinDetached( collectSourceDeployFiles(sourceRoot, workdir, config, metadata, (text) => output.raw(text, "stderr"), @@ -1831,7 +1839,7 @@ const uploadFunctionSource = Effect.fn("functions.deploy.uploadFunctionSource")( file: files, }, }, - NO_CLIENT_RETRY, + Option.isSome(apiMaxRetries) ? undefined : NO_CLIENT_RETRY, ) .pipe( // Read the body as text (never failing) so the status check below wins: @@ -1912,6 +1920,7 @@ const bulkUpdateRemoteFunctions = Effect.fn("functions.deploy.bulkUpdateFunction projectRef: string, functions: ReadonlyArray, ) { + const apiMaxRetries = yield* DeployApiMaxRetries; return yield* Effect.gen(function* () { const response = yield* rateLimitedRequest("bulk updating functions", () => api @@ -1921,7 +1930,7 @@ const bulkUpdateRemoteFunctions = Effect.fn("functions.deploy.bulkUpdateFunction ref: projectRef, body: functions, }, - NO_CLIENT_RETRY, + Option.isSome(apiMaxRetries) ? undefined : NO_CLIENT_RETRY, ) .pipe( // Read the body as text (never failing) so the status check wins even @@ -1945,7 +1954,7 @@ const bulkUpdateRemoteFunctions = Effect.fn("functions.deploy.bulkUpdateFunction }).pipe( Effect.retry({ schedule: Schedule.exponential(Duration.seconds(1)), - times: 3, + times: Option.isSome(apiMaxRetries) ? 0 : 3, while: (error) => !(error instanceof FunctionsApiStatusError) || error.status >= 500, }), ); @@ -1957,6 +1966,7 @@ const upsertBundledFunction = Effect.fn("functions.deploy.upsertFunction")(funct bundled: BundledFunction, exists: boolean, ) { + const apiMaxRetries = yield* DeployApiMaxRetries; const shouldUpdateRef = yield* Ref.make(exists); const upsert = Effect.gen(function* () { @@ -2021,8 +2031,12 @@ const upsertBundledFunction = Effect.fn("functions.deploy.upsertFunction")(funct return yield* upsert.pipe( Effect.retry({ schedule: Schedule.exponential(Duration.millis(500)), - times: 3, - while: (error) => !(error instanceof FunctionsApiStatusError && error.decode === true), + /* With a limit set, the only follow-up is turning a duplicated create into an update. */ + times: Option.isSome(apiMaxRetries) ? 1 : 3, + while: (error) => + Option.isSome(apiMaxRetries) + ? Ref.get(shouldUpdateRef).pipe(Effect.map((shouldUpdate) => shouldUpdate !== exists)) + : Effect.succeed(!(error instanceof FunctionsApiStatusError && error.decode === true)), }), ); }); diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 297c675921..810f331e2b 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -267,6 +267,7 @@ export interface ServiceFetchConfig { readonly headers?: Readonly>; readonly api?: ApiClient; readonly tenantBaseUrlOverride?: string; + readonly maxRetries?: number; } /** @public */ @@ -445,12 +446,15 @@ const makeConfiguredApiClient = Effect.fn("Services.buildApiClient")(function* ( ) { return ( input.api ?? - (yield* makeApiClient({ - baseUrl: input.apiUrl, - accessToken: input.accessToken, - userAgent: input.userAgent, - headers: input.headers, - })) + (yield* makeApiClient( + { + baseUrl: input.apiUrl, + accessToken: input.accessToken, + userAgent: input.userAgent, + headers: input.headers, + }, + { retry: { maxRetries: input.maxRetries } }, + )) ); }); diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 4cba21d391..92fef00415 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -1,4 +1,5 @@ AccessTokenRequiredError +ApiMaxRetriesError BackupListNetworkError BackupListUnexpectedStatusError BackupRestoreNetworkError diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index 4181c6f7c6..d3aec2809f 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -422,6 +422,7 @@ export function mockCommandSettings(opts: { readonly supabaseHome?: string; readonly dbPassword?: Option.Option>; readonly githubToken?: Option.Option>; + readonly apiMaxRetries?: Option.Option; }): Layer.Layer { return Layer.succeed(CommandSettings, { dbPassword: opts.dbPassword ?? Option.none(), @@ -440,6 +441,7 @@ export function mockCommandSettings(opts: { workdirEnvValue: opts.workdirEnvValue === undefined ? Option.none() : Option.some(opts.workdirEnvValue), userAgent: opts.userAgent ?? DEFAULT_USER_AGENT, + apiMaxRetries: opts.apiMaxRetries ?? Option.none(), }); } diff --git a/apps/cli/tests/helpers/compute.ts b/apps/cli/tests/helpers/compute.ts index e45be9412f..9c3b0691a1 100644 --- a/apps/cli/tests/helpers/compute.ts +++ b/apps/cli/tests/helpers/compute.ts @@ -384,6 +384,7 @@ const testCliConfigLayer = (workdir: string, explicitWorkdir: boolean) => explicitWorkdir, workdirEnvValue: Option.none(), userAgent: "supabase", + apiMaxRetries: Option.none(), }); }), ).pipe(Layer.provide(BunServices.layer)); From 8acf0c9248b36f4c2d07ff1193899568d255a1b3 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:43:52 +0530 Subject: [PATCH 2/4] nit --- apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md | 2 +- .../commands/functions/deploy/deploy.integration.test.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index f13d604243..da7c815963 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -61,7 +61,7 @@ Docker bundling may pull or run the configured edge-runtime image and uses the | `BITBUCKET_CLONE_DIR` | when defined (including an empty value), skips creating the named Deno-cache volume and omits its bind mount from the bundler `docker run` (Bitbucket's restricted Docker environment rejects both); project values are passed explicitly to Docker setup | no | | `SUPABASE_EDGE_RUNTIME_DENO_VERSION` | overrides `edge_runtime.deno_version` (which bundler image tag to use) when set, from the ambient shell or project dotenv — takes effect even with no `config.toml` on disk | no | | `NPM_CONFIG_REGISTRY` | forwarded into Docker bundling when set (the only npm variable forwarded; `NPM_AUTH_TOKEN` is not) | no | -| `SUPABASE_API_MAX_RETRIES` | Management API retries after the first attempt; when set, the API client's retries replace this command's own retry loops (a duplicated create still becomes one update) | no (unset keeps this command's own retries) | +| `SUPABASE_API_MAX_RETRIES` | Management API retries after the first attempt; when set, the API client's retries replace this command's own retry loops (a duplicated create still becomes one update), so a rate limit prints no retry notice and a `Retry-After` over 10s fails the deploy instead of waiting | no (unset keeps this command's own retries) | | `DEBUG` | enables verbose Docker bundle output when `true` | no | ## Exit Codes diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index 7f755797c6..2b050e8317 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -2349,7 +2349,7 @@ describe("functions deploy", () => { }); } - it.live("updates a duplicated function once when SUPABASE_API_MAX_RETRIES is 0", () => { + it.effect("updates a duplicated function once when SUPABASE_API_MAX_RETRIES is 0", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ handler: (request) => @@ -2383,7 +2383,13 @@ describe("functions deploy", () => { yield* writeCliConfig(tempRoot.current); yield* writeLocalFunction(tempRoot.current, "hello-world"); + /* Each sleep advances the test clock itself, so the update's backoff costs no real time. */ + const clock = yield* TestClock.testClockWith(Effect.succeed); const error = yield* functionsDeploy({ ...baseFlags, useApi: false, useDocker: true }).pipe( + Effect.provideService(Clock.Clock, { + ...clock, + sleep: (duration) => clock.adjust(duration), + }), Effect.flip, ); From 5cbd74df64a3652fa9ed38373e625582bdadc5b0 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:32:08 +0530 Subject: [PATCH 3/4] nit --- apps/cli/src/SIDE_EFFECTS_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md b/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md index 584e124b8e..0dcf5315f4 100644 --- a/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md +++ b/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md @@ -62,7 +62,7 @@ | -------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | | `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_API_MAX_RETRIES` | Management API retries after the first attempt (`0` = one attempt) | no (falls back to 5) | +| `SUPABASE_API_MAX_RETRIES` | Management API client retries (`0` = none); excludes command loops | no (falls back to 5) | ## Exit Codes From 46c40a6ce660e587ca3c3bf532d1d24287686edb Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:35:32 +0530 Subject: [PATCH 4/4] docs(cli): list `SUPABASE_API_MAX_RETRIES` for login and services --- apps/cli/src/commands/login/SIDE_EFFECTS.md | 1 + apps/cli/src/commands/services/SIDE_EFFECTS.md | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/cli/src/commands/login/SIDE_EFFECTS.md b/apps/cli/src/commands/login/SIDE_EFFECTS.md index dff1bfa55e..9e729cd230 100644 --- a/apps/cli/src/commands/login/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/login/SIDE_EFFECTS.md @@ -33,6 +33,7 @@ captures `cli_login_completed`. | ---------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | non-interactive token source | no (falls back to `--token` → piped stdin → browser flow) | | `SUPABASE_NO_KEYRING` | disables the OS keyring, forcing the file fallback | no | +| `SUPABASE_API_MAX_RETRIES` | Management API profile lookup retries (`0` = none) | no (falls back to 5) | | `CLAUDECODE` / `CLAUDE_CODE` | enables the Claude Code plugin hint (TTY stdout only) | no | | `DO_NOT_TRACK` / `SUPABASE_TELEMETRY_DISABLED` | suppress analytics delivery (state file still written) | no | diff --git a/apps/cli/src/commands/services/SIDE_EFFECTS.md b/apps/cli/src/commands/services/SIDE_EFFECTS.md index 80f9b8eb7a..53ebdfb240 100644 --- a/apps/cli/src/commands/services/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/services/SIDE_EFFECTS.md @@ -46,6 +46,7 @@ Tenant calls send `apikey: ` and additionally | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | auth token for Management API linked-version checks | no (falls back to keyring, then `~/.supabase/access-token`) | | `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_API_MAX_RETRIES` | Management API client retries for linked-version checks (`0` = none) | no (falls back to 5) | | `SUPABASE_USE_SLIM_IMAGES` | Ambient `process.env` only (`true`/`1` enable). Rewrites legacy current-pin `SERVICE IMAGE`/`name` fields to `ghcr.io/supabase/cli/`; Kong stays on docker.io. Majors 13/15 list the slim `15.14.1.167` pin when enabled; flag-off keeps `15.8.1.085`. Ignored by the stack backend. | no | | `SUPABASE_EXPERIMENTAL_STACK` | Selects the stack backend (`1`) or legacy backend (`0`); unset or empty uses `experimental.stack`. Other values fail routing. | no | | `SUPABASE_DB_MAJOR_VERSION` | Stack backend PostgreSQL major override; supported values are 15 and 17. | no |