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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/cli/docs/supabase/login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 5 additions & 4 deletions apps/cli/src/SIDE_EFFECTS_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@
<!-- List every env var the command reads, directly or via a service.
Required?: "yes" / "no (falls back to …)" -->

| 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

Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/auth/command-platform-api.layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const makeCommandPlatformApi = Effect.gen(function* () {
},
{
transformClient,
retry: { maxRetries: Option.getOrUndefined(cliSettings.apiMaxRetries) },
},
);
}).pipe(Effect.withSpan("CommandPlatformApi.build"));
Expand Down
39 changes: 37 additions & 2 deletions apps/cli/src/auth/command-platform-api.layer.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ function mockCliSettings(opts: {
userAgent?: string;
profile?: string;
projectHost?: string;
apiMaxRetries?: number;
}) {
return Layer.succeed(CommandSettings, {
githubToken: Option.none(),
Expand All @@ -55,6 +56,7 @@ function mockCliSettings(opts: {
workdir: "/tmp",
explicitWorkdir: false,
userAgent: opts.userAgent ?? "SupabaseCLI/0.0.0-dev",
apiMaxRetries: Option.fromUndefinedOr(opts.apiMaxRetries),
});
}

Expand Down Expand Up @@ -184,7 +186,7 @@ function withBaseDeps(
);
}

function captureRequests(responseHeaders: Record<string, string> = {}) {
function captureRequests(responseHeaders: Record<string, string> = {}, status = 200) {
const requests: Array<{
url: string;
headers: Readonly<Record<string, string | undefined>>;
Expand All @@ -195,7 +197,7 @@ function captureRequests(responseHeaders: Record<string, string> = {}) {
HttpClientResponse.fromWeb(
request,
new Response(JSON.stringify([]), {
status: 200,
status,
headers: { "content-type": "application/json", ...responseHeaders },
}),
),
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions apps/cli/src/command-internal/db-config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function makeCliSettings(workdir = "/nonexistent-workdir") {
workdir,
explicitWorkdir: false,
userAgent: "test",
apiMaxRetries: Option.none(),
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<typeof mockCommandPlatformApi>[0]) {
function setup(
opts: Parameters<typeof mockCommandPlatformApi>[0],
apiMaxRetries = Option.none<number>(),
) {
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 };
}
Expand Down Expand Up @@ -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));
});
});
9 changes: 4 additions & 5 deletions apps/cli/src/command-internal/login-api.layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/command-internal/resolve-token.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const settings = (accessToken: Option.Option<Redacted.Redacted<string>>) =>
workdirEnvValue: Option.none(),
githubToken: Option.none(),
userAgent: "SupabaseCLI/test",
apiMaxRetries: Option.none(),
});

const credentials = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ const runStatusEnv = (input: {
explicitWorkdir: Option.isSome(workdirFlag),
workdirEnvValue: Option.none(),
userAgent: "SupabaseCLI/pointer-test",
apiMaxRetries: Option.none(),
});
}),
);
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Comment thread
7ttp marked this conversation as resolved.
Resolved
| `DEBUG` | enables verbose Docker bundle output when `true` | no |

## Exit Codes
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/commands/functions/deploy/deploy.handler.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading