diff --git a/apps/cli/docs/supabase/login.md b/apps/cli/docs/supabase/login.md index 5dbc7df448..dbdf721e31 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 (the operatin > 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 1dec99b0f8..d8fd5f75cf 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 client retries (`0` = none); excludes command loops | 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 112f224e19..fce96774e1 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 ac98a9add2..3f1c9dfb51 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, { githubToken: Option.none(), @@ -55,6 +56,7 @@ function mockCliSettings(opts: { workdir: "/tmp", explicitWorkdir: false, userAgent: opts.userAgent ?? "SupabaseCLI/0.0.0-dev", + apiMaxRetries: Option.fromUndefinedOr(opts.apiMaxRetries), }); } @@ -184,7 +186,7 @@ function withBaseDeps( ); } -function captureRequests(responseHeaders: Record = {}) { +function captureRequests(responseHeaders: Record = {}, status = 200) { const requests: Array<{ url: string; headers: Readonly>; @@ -195,7 +197,7 @@ function captureRequests(responseHeaders: Record = {}) { HttpClientResponse.fromWeb( request, new Response(JSON.stringify([]), { - status: 200, + status, headers: { "content-type": "application/json", ...responseHeaders }, }), ), @@ -306,6 +308,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 d2d91992fc..079142b166 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 "../shared/config/project-link.ts"; import type { DbConnectError } from "./db-connection.errors.ts"; import type { LocalDbRunningError } from "./db-bootstrap/local-db-running.ts"; @@ -68,8 +69,9 @@ export type DbConfigError = // the pooler-config fallback. | 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 need an // access token) from the resolver's captured context, so `resolve`'s R stays `never` and the 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 535e628a85..58b58b9a85 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 e52ce3b99c..32830931bd 100644 --- a/apps/cli/src/command-internal/resolve-token.unit.test.ts +++ b/apps/cli/src/command-internal/resolve-token.unit.test.ts @@ -22,6 +22,7 @@ const settings = (accessToken: Option.Option>) => workdirEnvValue: 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 2e4261f03f..3b7b6c2e7f 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 @@ -208,6 +208,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 4ee0ca965b..f68f057c93 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -66,6 +66,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), 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.handler.ts b/apps/cli/src/commands/functions/deploy/deploy.handler.ts index f6948248cb..6f9bd91edc 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 { functionsLocalConfigLoader } from "../../../command-internal/functions-local-config.ts"; @@ -46,6 +46,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 8bcbff1ee3..02e6553a38 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -763,6 +763,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 }); @@ -795,7 +796,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 }), }), approvedHostedMutation, @@ -1222,6 +1223,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(); @@ -1883,6 +1940,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; @@ -1934,7 +1992,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 }), }), approvedHostedMutation, @@ -1979,6 +2040,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] }); @@ -2580,6 +2669,60 @@ describe("functions deploy", () => { }); } + it.effect("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( + cliConfigValuesTestLayer, + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ + workdir: tempRoot.current, + apiMaxRetries: Option.some(0), + }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + approvedHostedMutation, + 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"); + + /* 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, + ); + + 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/login/SIDE_EFFECTS.md b/apps/cli/src/commands/login/SIDE_EFFECTS.md index 4cdbd876ca..e3887419f3 100644 --- a/apps/cli/src/commands/login/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/login/SIDE_EFFECTS.md @@ -35,6 +35,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 f562766237..9eb8041645 100644 --- a/apps/cli/src/commands/services/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/services/SIDE_EFFECTS.md @@ -44,6 +44,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`/`true`) or legacy backend (`0`/`false`); unset or empty uses `experimental.stack`. Non-boolean values fail routing. | no | | `SUPABASE_DB_MAJOR_VERSION` | Stack backend PostgreSQL major override; supported values are 15 and 17. | no | diff --git a/apps/cli/src/commands/services/services.handler.ts b/apps/cli/src/commands/services/services.handler.ts index d062da032a..6343e202e6 100644 --- a/apps/cli/src/commands/services/services.handler.ts +++ b/apps/cli/src/commands/services/services.handler.ts @@ -141,6 +141,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); @@ -210,6 +211,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 872be19f58..5d0e932a50 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -131,6 +131,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/cli-config-key-annotations.ts b/apps/cli/src/config/cli-config-key-annotations.ts index 6bc084efda..9cbaad43c3 100644 --- a/apps/cli/src/config/cli-config-key-annotations.ts +++ b/apps/cli/src/config/cli-config-key-annotations.ts @@ -277,6 +277,7 @@ export const CLI_NON_CONFIG_ENV_NAMES: Readonly> = { SUPABASE_ACCESS_TOKEN: "platform auth", SUPABASE_ACTIVE_HELP: "shell completion", SUPABASE_ANON_KEY: "functions runtime env", + SUPABASE_API_MAX_RETRIES: "platform api retries", SUPABASE_API_URL: "platform profile", SUPABASE_BASELINE: "pgdata snapshot marker file name", SUPABASE_CA_SKIP_VERIFY: "telemetry signal", diff --git a/apps/cli/src/config/command-settings.layer.ts b/apps/cli/src/config/command-settings.layer.ts index b5ec300e3b..df0f80433c 100644 --- a/apps/cli/src/config/command-settings.layer.ts +++ b/apps/cli/src/config/command-settings.layer.ts @@ -1,5 +1,15 @@ import { findCliProjectRoot } from "@supabase/config/effect"; -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/flag-groups.ts"; import { CLI_VERSION } from "../shared/cli/version.ts"; @@ -14,6 +24,20 @@ import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { CliEnvNames } from "./cli-config-keys.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); @@ -162,6 +186,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({ @@ -184,6 +228,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 a53247d677..e8bcf7a531 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)); @@ -536,6 +536,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 58b702e824..b87294cb5b 100644 --- a/apps/cli/src/config/command-settings.service.ts +++ b/apps/cli/src/config/command-settings.service.ts @@ -44,6 +44,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/project-ref.layer.unit.test.ts b/apps/cli/src/config/project-ref.layer.unit.test.ts index 433b83d21e..5446b0e3b0 100644 --- a/apps/cli/src/config/project-ref.layer.unit.test.ts +++ b/apps/cli/src/config/project-ref.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 c198a7ce34..fa4c7f5aaa 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, @@ -1686,6 +1687,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 }) @@ -1713,7 +1715,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, }), @@ -1758,6 +1760,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< @@ -1770,12 +1778,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))), ), @@ -1799,6 +1806,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"), @@ -1823,7 +1831,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: @@ -1904,6 +1912,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 @@ -1913,7 +1922,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 @@ -1937,7 +1946,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, }), ); @@ -1949,6 +1958,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* () { @@ -2013,8 +2023,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 0c943d1917..4b3a592d4a 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 1367aa9394..263619fbfb 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -427,6 +427,7 @@ export function mockCommandSettings(opts: { readonly userAgent?: string; readonly supabaseHome?: string; readonly githubToken?: Option.Option>; + readonly apiMaxRetries?: Option.Option; }): Layer.Layer { return Layer.succeed(CommandSettings, { githubToken: opts.githubToken ?? Option.none(), @@ -444,6 +445,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 03c9cc7aae..53286ac0de 100644 --- a/apps/cli/tests/helpers/compute.ts +++ b/apps/cli/tests/helpers/compute.ts @@ -406,6 +406,7 @@ const testCliConfigLayer = (workdir: string, explicitWorkdir: boolean) => explicitWorkdir, workdirEnvValue: Option.none(), userAgent: "supabase", + apiMaxRetries: Option.none(), }); }), ).pipe(Layer.provide(BunServices.layer));