import { describe, expect, test } from "vitest";
import {
Cause,
Clock,
Duration,
Effect,
Exit,
Fiber,
Layer,
Option,
Random,
Redacted,
} from "effect";
import { NodeInspectSymbol } from "effect/Inspectable";
import { TestClock } from "effect/testing";
import * as HttpBody from "effect/http/HttpBody";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientError from "effect/http/HttpClientError";
import * as HttpClientResponse from "effect/http/HttpClientResponse";
import * as HttpClientRequest from "effect/http/HttpClientRequest";
import * as UrlParams from "effect/http/UrlParams";
import * as Schema from "effect/Schema";
import { operationDefinitions } from "../generated/contracts.ts";
import {
makeSupabaseApiClient,
markSupabaseApiInputErrorAsUserInput,
type SupabaseApiClientOptions,
type SupabaseApiClientShape,
type SupabaseApiRetryOptions,
SupabaseApiConfigError,
SupabaseApiInputError,
} from "./client.ts";
const textDecoder = new TextDecoder();
function httpClientLayer(
handler: (
request: HttpClientRequest.HttpClientRequest,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>,
) {
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) => handler(request)),
);
}
function jsonResponse(
request: HttpClientRequest.HttpClientRequest,
status: number,
body: unknown,
): HttpClientResponse.HttpClientResponse {
return HttpClientResponse.fromWeb(
request,
new Response(JSON.stringify(body), {
status,
headers: {
"content-type": "application/json",
},
}),
);
}
function oauthTokenResponse(
request: HttpClientRequest.HttpClientRequest,
): HttpClientResponse.HttpClientResponse {
return jsonResponse(request, 201, {
access_token: "access-token",
refresh_token: "refresh-token",
expires_in: 3600,
token_type: "Bearer",
});
}
function functionResponse(
request: HttpClientRequest.HttpClientRequest,
status: number,
): HttpClientResponse.HttpClientResponse {
return jsonResponse(request, status, {
id: "function-id",
slug: "demo",
name: "Demo Function",
status: "ACTIVE",
version: 1,
created_at: 1_710_000_000,
updated_at: 1_710_000_001,
verify_jwt: true,
entrypoint_path: "functions/demo/index.ts",
import_map_path: "functions/demo/deno.json",
ezbr_sha256: "abc123",
});
}
function deployFunctionResponse(
request: HttpClientRequest.HttpClientRequest,
): HttpClientResponse.HttpClientResponse {
return jsonResponse(request, 201, {
id: "function-id",
slug: "demo",
name: "Demo Function",
status: "ACTIVE",
version: 1,
created_at: 1_710_000_000,
updated_at: 1_710_000_001,
verify_jwt: true,
entrypoint_path: "functions/demo/index.ts",
import_map_path: "functions/demo/deno.json",
});
}
function requestUrl(request: HttpClientRequest.HttpClientRequest): URL {
return new URL(request.url);
}
function requestBodyBytes(request: HttpClientRequest.HttpClientRequest): Uint8Array {
if (request.body._tag !== "Uint8Array") {
throw new Error(`Expected Uint8Array body, got ${request.body._tag}`);
}
return request.body.body;
}
function requestBodyText(request: HttpClientRequest.HttpClientRequest): string {
return textDecoder.decode(requestBodyBytes(request));
}
function requestFormData(request: HttpClientRequest.HttpClientRequest): FormData {
if (request.body._tag !== "FormData") {
throw new Error(`Expected FormData body, got ${request.body._tag}`);
}
return request.body.formData;
}
function formDataTextValue(formData: FormData, key: string): string {
const value = formData.get(key);
if (typeof value !== "string") {
throw new Error(`Expected string form-data value for ${key}`);
}
return value;
}
function formDataFileTexts(formData: FormData, key: string): Effect.Effect<Array<string>> {
const values = formData.getAll(key);
return Effect.forEach(values, (value) =>
typeof value === "string" ? Effect.succeed(value) : Effect.promise(() => value.text()),
);
}
function requestUrlParam(
request: HttpClientRequest.HttpClientRequest,
key: string,
): string | ReadonlyArray<string> | undefined {
const value = UrlParams.getFirst(request.urlParams, key);
return Option.isSome(value) ? value.value : undefined;
}
function transportError(
request: HttpClientRequest.HttpClientRequest,
description: string,
): HttpClientError.HttpClientError {
return new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request,
description,
}),
});
}
const config = {
baseUrl: "https://api.supabase.com",
accessToken: "test-token",
userAgent: "supabase-api/test",
} as const;
function onVirtualClock<A, E>(
effect: Effect.Effect<A, E, TestClock.TestClock>,
jitter = 0.5,
): Effect.Effect<A, E> {
return effect.pipe(
Effect.provideService(Random.Random, {
nextIntUnsafe: () => 0,
nextDoubleUnsafe: () => jitter,
}),
Effect.provide(TestClock.layer()),
);
}
* Starts one client call against a stub transport on the virtual clock and records the clock
* reading of every request the transport receives.
*/
function startVirtualCall<A, E>(
call: (client: SupabaseApiClientShape) => Effect.Effect<A, E, never>,
respond: (
request: HttpClientRequest.HttpClientRequest,
attempt: number,
) => HttpClientResponse.HttpClientResponse | HttpClientError.HttpClientError,
options?: SupabaseApiClientOptions,
) {
return Effect.gen(function* () {
const requestTimes: Array<number> = [];
const fiber = yield* makeSupabaseApiClient(config, options).pipe(
Effect.flatMap(call),
Effect.exit,
Effect.provide(
httpClientLayer((request) =>
Effect.gen(function* () {
requestTimes.push(yield* Clock.currentTimeMillis);
const outcome = respond(request, requestTimes.length);
return yield* HttpClientError.isHttpClientError(outcome)
? Effect.fail(outcome)
: Effect.succeed(outcome);
}),
),
),
Effect.forkChild,
);
yield* Effect.yieldNow;
return { requestTimes, fiber };
});
}
const listProjects = (client: SupabaseApiClientShape) =>
client.execute<"v1ListAllProjects">(operationDefinitions.v1ListAllProjects, {});
describe("makeSupabaseApiClient", () => {
test("defaults request-schema failures to generated-client provenance", () =>
Effect.runPromise(
Effect.gen(function* () {
let requests = 0;
const client = yield* makeSupabaseApiClient(config).pipe(
Effect.provide(
httpClientLayer((request) => {
requests += 1;
return Effect.succeed(jsonResponse(request, 200, {}));
}),
),
);
const executeError = yield* client
.execute(operationDefinitions.v1DeleteAFunction, {
ref: "invalid-ref",
function_slug: "hello-world",
})
.pipe(Effect.flip);
const executeRawError = yield* client
.executeRaw(operationDefinitions.v1DeleteAFunction, {
ref: "invalid-ref",
function_slug: "hello-world",
})
.pipe(Effect.flip);
for (const error of [executeError, executeRawError]) {
expect(error).toBeInstanceOf(SupabaseApiInputError);
if (!(error instanceof SupabaseApiInputError)) {
throw new Error("expected SupabaseApiInputError");
}
expect(error.source).toBe("generated_client");
}
if (!(executeRawError instanceof SupabaseApiInputError)) {
throw new Error("expected SupabaseApiInputError");
}
expect(markSupabaseApiInputErrorAsUserInput(executeRawError)).toBe(executeRawError);
expect(executeRawError.source).toBe("user_input");
expect(requests).toBe(0);
}),
));
test("rejects unknown request keys at any depth before sending a request", () =>
Effect.runPromise(
Effect.gen(function* () {
let requests = 0;
const client = yield* makeSupabaseApiClient(config).pipe(
Effect.provide(
httpClientLayer((request) => {
requests += 1;
return Effect.succeed(jsonResponse(request, 200, {}));
}),
),
);
const ref = "abcdefghijklmnopqrst";
const topLevelExcess = { ref, requestedConfig: { database: true }, junk: 1 };
const nestedExcess = { ref, requestedConfig: { database: true, junk: 1 } };
const errors = yield* Effect.all(
[
client
.execute(operationDefinitions.v1UpdateSslEnforcementConfig, topLevelExcess)
.pipe(Effect.flip),
client
.executeRaw(operationDefinitions.v1UpdateSslEnforcementConfig, nestedExcess)
.pipe(Effect.flip),
],
{ concurrency: "unbounded" },
);
for (const error of errors) {
expect(error).toBeInstanceOf(SupabaseApiInputError);
}
expect(errors[0]?.message).toContain('["junk"]');
expect(errors[1]?.message).toContain('["requestedConfig"]["junk"]');
expect(requests).toBe(0);
}),
));
test("per-request options override the base URL, token, and headers for that request only", () =>
Effect.runPromise(
Effect.gen(function* () {
const seen: Array<{
url: string;
authorization: string | undefined;
userAgent: string | undefined;
custom: string | undefined;
}> = [];
const client = yield* makeSupabaseApiClient({
...config,
headers: { "X-Custom": "default" },
}).pipe(
Effect.provide(
httpClientLayer((request) => {
seen.push({
url: request.url,
authorization: request.headers.authorization,
userAgent: request.headers["user-agent"],
custom: request.headers["x-custom"],
});
return Effect.succeed(jsonResponse(request, 200, []));
}),
),
);
yield* client.execute(
operationDefinitions.v1ListAllProjects,
{},
{
baseUrl: "https://api.example.test/prefix",
accessToken: Redacted.make("override-token"),
headers: { "X-Custom": "override" },
},
);
yield* client.executeRaw(
operationDefinitions.v1ListAllProjects,
{},
{ baseUrl: "https://raw.example.test", accessToken: "raw-token" },
);
yield* client.execute(operationDefinitions.v1ListAllProjects, {});
expect(seen).toEqual([
{
url: "https://api.example.test/prefix/v1/projects",
authorization: "Bearer override-token",
userAgent: "supabase-api/test",
custom: "override",
},
{
url: "https://raw.example.test/v1/projects",
authorization: "Bearer raw-token",
userAgent: "supabase-api/test",
custom: "default",
},
{
url: "https://api.supabase.com/v1/projects",
authorization: "Bearer test-token",
userAgent: "supabase-api/test",
custom: "default",
},
]);
}),
));
test("requests with per-request overrides still pass through transformClient", () =>
Effect.runPromise(
Effect.gen(function* () {
const transformed: Array<string> = [];
const wire: Array<{ url: string; stamp: string | undefined }> = [];
const client = yield* makeSupabaseApiClient(config, {
transformClient: (inner) =>
Effect.succeed(
HttpClient.mapRequest(inner, (request) => {
transformed.push(request.url);
return HttpClientRequest.setHeader(request, "X-Stamp", "stamped");
}),
),
}).pipe(
Effect.provide(
httpClientLayer((request) => {
wire.push({ url: request.url, stamp: request.headers["x-stamp"] });
return Effect.succeed(jsonResponse(request, 200, []));
}),
),
);
yield* client.execute(
operationDefinitions.v1ListAllProjects,
{},
{ baseUrl: "https://api.example.test" },
);
expect(transformed).toEqual(["https://api.example.test/v1/projects"]);
expect(wire).toEqual([{ url: "https://api.example.test/v1/projects", stamp: "stamped" }]);
}),
));
test("fails request-body construction before sending a request", () =>
Effect.runPromise(
Effect.gen(function* () {
class BrokenBlob extends Blob {
override arrayBuffer(): Promise<ArrayBuffer> {
return Promise.reject(new Error("body read failed"));
}
}
let requests = 0;
const error = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.executeRaw(operationDefinitions.v1CreateAFunction, {
ref: "abcdefghijklmnopqrst",
slug: "demo",
body: new BrokenBlob([]),
}),
),
Effect.provide(
httpClientLayer((request) => {
requests += 1;
return Effect.succeed(functionResponse(request, 201));
}),
),
Effect.flip,
);
expect(error).toBeInstanceOf(HttpBody.HttpBodyError);
expect(requests).toBe(0);
}),
));
test("executeRaw forwards extraBody fields and lets validated fields win", () =>
Effect.runPromise(
Effect.gen(function* () {
const bodies: unknown[] = [];
yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.executeRaw(
operationDefinitions.v1UpdatePostgresConfig,
{ ref: "abcdefghijklmnopqrst", effective_cache_size: "4GB" },
{
extraBody: {
effective_cache_size: "1GB",
future_parameter: "on",
},
},
),
),
Effect.provide(
httpClientLayer((request) => {
bodies.push(JSON.parse(requestBodyText(request)));
return Effect.succeed(jsonResponse(request, 200, {}));
}),
),
);
expect(bodies).toEqual([{ effective_cache_size: "4GB", future_parameter: "on" }]);
}),
));
test("executeRaw rejects extraBody on operations without a JSON field body", () =>
Effect.runPromise(
Effect.gen(function* () {
let requests = 0;
const exit = yield* Effect.exit(
makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.executeRaw(
operationDefinitions.v1ListAllProjects,
{},
{ extraBody: { a: 1 } },
),
),
Effect.provide(
httpClientLayer((request) => {
requests += 1;
return Effect.succeed(jsonResponse(request, 200, []));
}),
),
),
);
expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true);
expect(requests).toBe(0);
}),
));
test("retries transport errors for GET requests", () =>
Effect.runPromise(
Effect.gen(function* () {
const { requestTimes, result } = yield* onVirtualClock(
Effect.gen(function* () {
const call = yield* startVirtualCall(listProjects, (request, attempt) =>
attempt < 3
? transportError(request, "socket reset")
: jsonResponse(request, 200, []),
);
yield* TestClock.adjust(Duration.minutes(1));
return { requestTimes: call.requestTimes, result: yield* Fiber.join(call.fiber) };
}),
);
expect(requestTimes).toHaveLength(3);
expect(result).toEqual(Exit.succeed([]));
}),
));
test("reveals redacted auth tokens only at the transport boundary", () =>
Effect.runPromise(
Effect.gen(function* () {
let authorizationHeader: string | undefined;
const result = yield* makeSupabaseApiClient({
...config,
accessToken: Redacted.make("redacted-token"),
}).pipe(
Effect.flatMap((client) =>
client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, {
db_pass: "hunter2",
name: "project-name",
organization_slug: "my-org",
}),
),
Effect.provide(
httpClientLayer((request) => {
authorizationHeader = request.headers.authorization;
return Effect.succeed(
jsonResponse(request, 200, {
id: "project-id",
ref: "abcdefghijklmnopqrst",
organization_id: "org-id",
organization_slug: "my-org",
name: "project-name",
region: "us-east-1",
created_at: "2026-03-13T12:00:00.000Z",
status: "ACTIVE_HEALTHY",
}),
);
}),
),
);
expect(authorizationHeader).toBe("Bearer redacted-token");
expect(result.ref).toBe("abcdefghijklmnopqrst");
}),
));
test("applies default headers alongside auth, user agent, and request headers", () =>
Effect.runPromise(
Effect.gen(function* () {
let seenHeaders:
| {
authorization: string | undefined;
userAgent: string | undefined;
command: string | undefined;
commandRunId: string | undefined;
idempotencyKey: string | undefined;
}
| undefined;
yield* makeSupabaseApiClient({
...config,
headers: {
"X-Supabase-Command": "branches list",
"X-Supabase-Command-Run-ID": "run-123",
},
}).pipe(
Effect.flatMap((client) =>
client.execute<"v1ApplyAMigration">(operationDefinitions.v1ApplyAMigration, {
ref: "abcdefghijklmnopqrst",
query: "select 1",
name: "smoke_test",
"Idempotency-Key": "migration-123",
}),
),
Effect.provide(
httpClientLayer((request) => {
seenHeaders = {
authorization: request.headers.authorization,
userAgent: request.headers["user-agent"],
command: request.headers["x-supabase-command"],
commandRunId: request.headers["x-supabase-command-run-id"],
idempotencyKey: request.headers["idempotency-key"],
};
return Effect.succeed(
HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })),
);
}),
),
);
expect(seenHeaders).toEqual({
authorization: "Bearer test-token",
userAgent: "supabase-api/test",
command: "branches list",
commandRunId: "run-123",
idempotencyKey: "migration-123",
});
}),
));
test("decodes nullable JWT templates in API key responses", () =>
Effect.runPromise(
Effect.gen(function* () {
const result = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1GetProjectApiKeys">(operationDefinitions.v1GetProjectApiKeys, {
ref: "abcdefghijklmnopqrst",
reveal: true,
}),
),
Effect.provide(
httpClientLayer((request) => {
const url = requestUrl(request);
expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/api-keys");
expect(requestUrlParam(request, "reveal")).toBe("true");
return Effect.succeed(
jsonResponse(request, 200, [
{
name: "anon",
type: "legacy",
api_key: "anon-key",
secret_jwt_template: null,
},
{
name: "service_role",
type: "secret",
api_key: "service-role-key",
secret_jwt_template: { role: "service_role" },
},
]),
);
}),
),
);
expect(result).toEqual([
{
name: "anon",
type: "legacy",
api_key: "anon-key",
secret_jwt_template: null,
},
{
name: "service_role",
type: "secret",
api_key: "service-role-key",
secret_jwt_template: { role: "service_role" },
},
]);
}),
));
test("decodes timestamps with a numeric UTC offset", () =>
Effect.runPromise(
Effect.gen(function* () {
const apiKeys = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1GetProjectApiKeys">(operationDefinitions.v1GetProjectApiKeys, {
ref: "abcdefghijklmnopqrst",
}),
),
Effect.provide(
httpClientLayer((request) =>
Effect.succeed(
jsonResponse(request, 200, [
{
name: "anon",
type: "legacy",
api_key: "anon-key",
inserted_at: "2026-05-01T08:00:00+00:00",
updated_at: "2026-05-01T08:00:00.123456+02:00",
},
]),
),
),
),
);
expect(apiKeys[0]?.inserted_at).toBe("2026-05-01T08:00:00+00:00");
expect(apiKeys[0]?.updated_at).toBe("2026-05-01T08:00:00.123456+02:00");
const branches = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1ListAllBranches">(operationDefinitions.v1ListAllBranches, {
ref: "abcdefghijklmnopqrst",
}),
),
Effect.provide(
httpClientLayer((request) =>
Effect.succeed(
jsonResponse(request, 200, [
{
id: "6f8f9d2c-1f43-4b8a-9d0e-3a2b1c4d5e6f",
name: "preview",
project_ref: "abcdefghijklmnopqrst",
parent_project_ref: "tsrqponmlkjihgfedcba",
is_default: false,
persistent: false,
status: "MIGRATIONS_PASSED",
with_data: false,
created_at: "2026-08-06T19:27:30.261795+00:00",
updated_at: "2026-08-06T19:27:30.261795+00:00",
},
]),
),
),
),
);
expect(branches[0]?.created_at).toBe("2026-08-06T19:27:30.261795+00:00");
}),
));
test("accepts missing custom-hostname SSL validation records", () =>
Effect.runPromise(
Effect.gen(function* () {
const result = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, {
ref: "abcdefghijklmnopqrst",
}),
),
Effect.provide(
httpClientLayer((request) =>
Effect.succeed(
jsonResponse(request, 200, {
status: "2_initiated",
custom_hostname: "shop.acme.dev",
data: {
success: true,
errors: [],
messages: [],
result: {
id: "hostname-id",
hostname: "shop.acme.dev",
ssl: {
status: "pending_validation",
},
ownership_verification: {
type: "txt",
name: "_cf-custom-hostname.shop.acme.dev",
value: "verification-token",
},
custom_origin_server: "abcdefghijklmnopqrst.supabase.co",
status: "pending",
},
},
}),
),
),
),
);
expect(result.data.result.ssl?.validation_records).toBeUndefined();
}),
));
test("accepts missing custom-hostname ownership verification", () =>
Effect.runPromise(
Effect.gen(function* () {
const result = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, {
ref: "abcdefghijklmnopqrst",
}),
),
Effect.provide(
httpClientLayer((request) =>
Effect.succeed(
jsonResponse(request, 200, {
status: "4_origin_setup_completed",
custom_hostname: "shop.acme.dev",
data: {
success: true,
errors: [],
messages: [],
result: {
id: "hostname-id",
hostname: "shop.acme.dev",
ssl: {
status: "active",
},
custom_origin_server: "abcdefghijklmnopqrst.supabase.co",
status: "active",
},
},
}),
),
),
),
);
expect(result.data.result.ownership_verification).toBeUndefined();
expect(result.data.result.ssl?.validation_records).toBeUndefined();
}),
));
test("accepts processing custom-hostname responses without top-level status or hostname", () =>
Effect.runPromise(
Effect.gen(function* () {
const result = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, {
ref: "abcdefghijklmnopqrst",
}),
),
Effect.provide(
httpClientLayer((request) =>
Effect.succeed(
jsonResponse(request, 200, {
data: {
success: true,
errors: [],
messages: [],
result: {
id: "hostname-id",
hostname: "shop.acme.dev",
ssl: {
status: "initializing",
},
ownership_verification: {
type: "txt",
name: "_cf-custom-hostname.shop.acme.dev",
value: "verification-token",
},
custom_origin_server: "abcdefghijklmnopqrst.supabase.co",
status: "pending",
},
},
}),
),
),
),
);
expect(result.status).toBeUndefined();
expect(result.custom_hostname).toBeUndefined();
expect(result.data.result.ssl?.validation_records).toBeUndefined();
}),
));
test("does not retry 5xx responses for POST requests", () =>
Effect.runPromise(
Effect.gen(function* () {
let attempts = 0;
const exit = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, {
db_pass: "hunter2",
name: "project-name",
organization_slug: "my-org",
}),
),
Effect.exit,
Effect.provide(
httpClientLayer((request) => {
attempts += 1;
return Effect.succeed(
jsonResponse(request, 500, {
error: "do not retry post",
}),
);
}),
),
);
expect(attempts).toBe(1);
expect(Exit.isFailure(exit)).toBe(true);
}),
));
test("does not retry transport errors for POST requests", () =>
Effect.runPromise(
Effect.gen(function* () {
let attempts = 0;
const exit = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, {
db_pass: "hunter2",
name: "project-name",
organization_slug: "my-org",
}),
),
Effect.exit,
Effect.provide(
httpClientLayer((request) => {
attempts += 1;
return attempts === 1
? Effect.fail(transportError(request, "connection reset"))
: Effect.succeed(
jsonResponse(request, 409, {
message: "project already exists",
}),
);
}),
),
);
expect(attempts).toBe(1);
expect(Exit.isFailure(exit)).toBe(true);
}),
));
test("does not retry transport errors for PATCH requests", () =>
Effect.runPromise(
Effect.gen(function* () {
let attempts = 0;
const exit = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1PatchAMigration">(operationDefinitions.v1PatchAMigration, {
ref: "abcdefghijklmnopqrst",
version: "20240101000000",
name: "renamed",
}),
),
Effect.exit,
Effect.provide(
httpClientLayer((request) => {
attempts += 1;
return attempts === 1
? Effect.fail(transportError(request, "connection reset"))
: Effect.succeed(
HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })),
);
}),
),
);
expect(attempts).toBe(1);
expect(Exit.isFailure(exit)).toBe(true);
}),
));
test("stops after the configured number of transport retries", () =>
Effect.runPromise(
Effect.gen(function* () {
const { requestTimes, exit } = yield* onVirtualClock(
Effect.gen(function* () {
const call = yield* startVirtualCall(
listProjects,
(request) => transportError(request, "still broken"),
{ retry: { maxRetries: 2 } },
);
yield* TestClock.adjust(Duration.minutes(1));
return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) };
}),
);
expect(requestTimes).toHaveLength(3);
expect(Exit.isFailure(exit)).toBe(true);
}),
));
test("decodes text responses through the unified execute path", () =>
Effect.runPromise(
Effect.gen(function* () {
const result = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1DiffABranch">(operationDefinitions.v1DiffABranch, {
branch_id_or_ref: "abcdefghijklmnopqrst",
}),
),
Effect.provide(
httpClientLayer((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response("select * from test;", {
status: 200,
headers: {
"content-type": "text/plain",
},
}),
),
),
),
),
);
expect(result).toBe("select * from test;");
}),
));
test("decodes void responses through the unified execute path", () =>
Effect.runPromise(
Effect.gen(function* () {
const result = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1DisablePreviewBranching">(
operationDefinitions.v1DisablePreviewBranching,
{
ref: "abcdefghijklmnopqrst",
},
),
),
Effect.provide(
httpClientLayer((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(null, {
status: 204,
}),
),
),
),
),
);
expect(result).toBeUndefined();
}),
));
test("serializes oauth token exchange bodies as x-www-form-urlencoded", () =>
Effect.runPromise(
Effect.gen(function* () {
let seenRequest: HttpClientRequest.HttpClientRequest | undefined;
const result = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1ExchangeOauthToken">(operationDefinitions.v1ExchangeOauthToken, {
body: {
grant_type: "authorization_code",
client_id: "11111111-1111-4111-8111-111111111111",
client_secret: "client-secret",
code: "auth-code",
code_verifier: "code-verifier",
redirect_uri: "https://example.com/callback",
resource: "https://mcp.supabase.com",
},
}),
),
Effect.provide(
httpClientLayer((request) => {
seenRequest = request;
return Effect.succeed(oauthTokenResponse(request));
}),
),
);
expect(result.access_token).toBe("access-token");
expect(seenRequest).toBeDefined();
expect(seenRequest?.headers["content-type"]).toBe("application/x-www-form-urlencoded");
const url = requestUrl(seenRequest!);
expect(url.pathname).toBe("/v1/oauth/token");
expect(Array.from(url.searchParams.keys())).toEqual([]);
const body = new URLSearchParams(requestBodyText(seenRequest!));
expect(body.get("grant_type")).toBe("authorization_code");
expect(body.get("client_id")).toBe("11111111-1111-4111-8111-111111111111");
expect(body.get("client_secret")).toBe("client-secret");
expect(body.get("code")).toBe("auth-code");
expect(body.get("code_verifier")).toBe("code-verifier");
expect(body.get("redirect_uri")).toBe("https://example.com/callback");
expect(body.get("resource")).toBe("https://mcp.supabase.com");
expect(body.has("refresh_token")).toBe(false);
expect(body.has("scope")).toBe(false);
}),
));
test("serializes refresh-token exchange bodies without omitted oauth fields", () =>
Effect.runPromise(
Effect.gen(function* () {
let seenRequest: HttpClientRequest.HttpClientRequest | undefined;
const result = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1ExchangeOauthToken">(operationDefinitions.v1ExchangeOauthToken, {
body: {
grant_type: "refresh_token",
refresh_token: "refresh-token",
scope: "read:projects",
},
}),
),
Effect.provide(
httpClientLayer((request) => {
seenRequest = request;
return Effect.succeed(oauthTokenResponse(request));
}),
),
);
expect(result.refresh_token).toBe("refresh-token");
const body = new URLSearchParams(requestBodyText(seenRequest!));
expect(body.get("grant_type")).toBe("refresh_token");
expect(body.get("refresh_token")).toBe("refresh-token");
expect(body.get("scope")).toBe("read:projects");
expect(body.has("code")).toBe(false);
expect(body.has("client_id")).toBe(false);
}),
));
test("serializes create function requests as eszip bodies with metadata query params", () =>
Effect.runPromise(
Effect.gen(function* () {
let seenRequest: HttpClientRequest.HttpClientRequest | undefined;
const body = new TextEncoder().encode("console.log('deploy create');");
const result = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1CreateAFunction">(operationDefinitions.v1CreateAFunction, {
ref: "abcdefghijklmnopqrst",
slug: "demo",
name: "Demo Function",
verify_jwt: true,
entrypoint_path: "functions/demo/index.ts",
import_map_path: "functions/demo/deno.json",
ezbr_sha256: "abc123",
body,
}),
),
Effect.provide(
httpClientLayer((request) => {
seenRequest = request;
return Effect.succeed(functionResponse(request, 201));
}),
),
);
expect(result.slug).toBe("demo");
expect(seenRequest).toBeDefined();
expect(seenRequest?.headers["content-type"]).toBe("application/vnd.denoland.eszip");
expect(requestBodyBytes(seenRequest!)).toEqual(body);
const url = requestUrl(seenRequest!);
expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions");
expect(requestUrlParam(seenRequest!, "slug")).toBe("demo");
expect(requestUrlParam(seenRequest!, "name")).toBe("Demo Function");
expect(requestUrlParam(seenRequest!, "verify_jwt")).toBe("true");
expect(requestUrlParam(seenRequest!, "entrypoint_path")).toBe("functions/demo/index.ts");
expect(requestUrlParam(seenRequest!, "import_map_path")).toBe("functions/demo/deno.json");
expect(requestUrlParam(seenRequest!, "ezbr_sha256")).toBe("abc123");
}),
));
test("serializes update function requests as eszip bodies with metadata query params", () =>
Effect.runPromise(
Effect.gen(function* () {
let seenRequest: HttpClientRequest.HttpClientRequest | undefined;
const body = new TextEncoder().encode("console.log('deploy update');").buffer;
const result = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1UpdateAFunction">(operationDefinitions.v1UpdateAFunction, {
ref: "abcdefghijklmnopqrst",
function_slug: "demo",
slug: "demo-renamed",
verify_jwt: true,
entrypoint_path: "functions/demo/index.ts",
import_map_path: "functions/demo/deno.json",
ezbr_sha256: "def456",
body,
}),
),
Effect.provide(
httpClientLayer((request) => {
seenRequest = request;
return Effect.succeed(functionResponse(request, 200));
}),
),
);
expect(result.slug).toBe("demo");
expect(seenRequest).toBeDefined();
expect(seenRequest?.headers["content-type"]).toBe("application/vnd.denoland.eszip");
expect(requestBodyBytes(seenRequest!)).toEqual(new Uint8Array(body));
const url = requestUrl(seenRequest!);
expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions/demo");
expect(requestUrlParam(seenRequest!, "slug")).toBe("demo-renamed");
expect(requestUrlParam(seenRequest!, "verify_jwt")).toBe("true");
expect(requestUrlParam(seenRequest!, "entrypoint_path")).toBe("functions/demo/index.ts");
expect(requestUrlParam(seenRequest!, "import_map_path")).toBe("functions/demo/deno.json");
expect(requestUrlParam(seenRequest!, "ezbr_sha256")).toBe("def456");
}),
));
test("serializes deploy function requests as multipart bodies with json metadata", () =>
Effect.runPromise(
Effect.gen(function* () {
let seenRequest: HttpClientRequest.HttpClientRequest | undefined;
const metadata = {
entrypoint_path: "functions/demo/index.ts",
import_map_path: "functions/demo/deno.json",
static_patterns: ["functions/demo/static/**/*.js"],
verify_jwt: true,
name: "demo",
} as const;
const result = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v1DeployAFunction">(operationDefinitions.v1DeployAFunction, {
ref: "abcdefghijklmnopqrst",
slug: "demo",
bundleOnly: true,
body: {
metadata,
file: [new Uint8Array([1, 2, 3]), new Blob(["deno-config"])],
},
}),
),
Effect.provide(
httpClientLayer((request) => {
seenRequest = request;
return Effect.succeed(deployFunctionResponse(request));
}),
),
);
expect(result.slug).toBe("demo");
expect(seenRequest).toBeDefined();
const url = requestUrl(seenRequest!);
expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions/deploy");
expect(requestUrlParam(seenRequest!, "slug")).toBe("demo");
expect(requestUrlParam(seenRequest!, "bundleOnly")).toBe("true");
const formData = requestFormData(seenRequest!);
expect(
yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(
formDataTextValue(formData, "metadata"),
),
).toEqual(metadata);
expect(yield* formDataFileTexts(formData, "file")).toEqual([
"\u0001\u0002\u0003",
"deno-config",
]);
}),
));
test("rejects string raw binary bodies at schema decode time", () => {
expect(() =>
Schema.decodeUnknownSync(operationDefinitions.v1CreateAFunction.inputSchema)({
ref: "abcdefghijklmnopqrst",
slug: "demo",
body: "not-binary",
}),
).toThrow();
});
test("rejects string multipart file entries at schema decode time", () => {
expect(() =>
Schema.decodeUnknownSync(operationDefinitions.v1DeployAFunction.inputSchema)({
ref: "abcdefghijklmnopqrst",
slug: "demo",
body: {
metadata: {
entrypoint_path: "functions/demo/index.ts",
},
file: ["index.ts"],
},
}),
).toThrow();
});
test("surfaces a 404 on a v2 operation as a distinguishable status error and wires the request identically to v1", () =>
Effect.runPromise(
Effect.gen(function* () {
let seenRequest: HttpClientRequest.HttpClientRequest | undefined;
const client = yield* makeSupabaseApiClient(config).pipe(
Effect.provide(
httpClientLayer((request) => {
seenRequest = request;
return Effect.succeed(
jsonResponse(request, 404, { message: "Organization not found" }),
);
}),
),
);
const error = yield* client
.execute(operationDefinitions.v2ListOrganizationMembers, { slug: "my-org" })
.pipe(Effect.flip);
expect(HttpClientError.isHttpClientError(error)).toBe(true);
if (!HttpClientError.isHttpClientError(error)) {
throw new Error("expected HttpClientError");
}
expect(error.reason._tag).toBe("StatusCodeError");
if (error.reason._tag !== "StatusCodeError") {
throw new Error("expected StatusCodeError");
}
expect(error.reason.response.status).toBe(404);
expect(seenRequest).toBeDefined();
expect(seenRequest?.url).toBe("https://api.supabase.com/v2/organizations/my-org/members");
expect(seenRequest?.headers.authorization).toBe("Bearer test-token");
}),
));
test("decodes a nested v2GetProjectConfig payload through the unified execute path", () =>
Effect.runPromise(
Effect.gen(function* () {
const result = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap((client) =>
client.execute<"v2GetProjectConfig">(operationDefinitions.v2GetProjectConfig, {
ref: "abcdefghijklmnopqrst",
}),
),
Effect.provide(
httpClientLayer((request) =>
Effect.succeed(
jsonResponse(request, 200, {
data: {
type: "project_config",
id: "abcdefghijklmnopqrst",
attributes: {
database: {
major_version: 17,
ssl_enforced: true,
network_restrictions: {
entitlement: "disallowed",
status: "stored",
allowed_cidrs: [],
},
postgres_settings: {},
},
pooler: {
pool_mode: "transaction",
ignore_startup_parameters: "",
server_idle_timeout: 0,
server_lifetime: 0,
query_wait_timeout: 0,
reserve_pool_size: 0,
default_pool_size: 0,
max_client_conn: 0,
},
auth: {},
api: {
db_schema: "public",
db_extra_search_path: "",
max_rows: 1000,
db_pool_acquisition_timeout: 0,
db_pool_max_lifetime: 0,
db_pool_max_idletime: 0,
db_pool: null,
},
realtime: {
private_only: false,
max_concurrent_users: 0,
max_events_per_second: 0,
max_bytes_per_second: 0,
max_channels_per_client: 0,
max_joins_per_second: 0,
max_presence_events_per_second: 0,
max_payload_size_in_kb: 0,
presence_enabled: true,
suspend: false,
connection_pool: 0,
postgres_changes_pool: null,
},
storage: {
file_size_limit: 0,
features: {
image_transformation: { enabled: true },
s3_protocol: { enabled: true },
purge_cache: { enabled: true },
iceberg_catalog: {
enabled: false,
max_namespaces: 0,
max_tables: 0,
max_catalogs: 0,
},
vector_buckets: { enabled: false, max_buckets: 0, max_indexes: 0 },
},
capabilities: {
list_v2: true,
iceberg_catalog: true,
object_versioning: true,
},
upstream_target: "main",
migration_version: "1",
},
},
},
}),
),
),
),
);
expect(result.data.attributes.database.network_restrictions.entitlement).toBe("disallowed");
expect(result.data.attributes.database.major_version).toBe(17);
expect(result.data.attributes.storage.upstream_target).toBe("main");
expect(result.data.attributes.api.db_pool).toBeNull();
}),
));
test("expands deepObject query parameters into one pair per entry", () =>
Effect.runPromise(
Effect.gen(function* () {
let seenRequest: HttpClientRequest.HttpClientRequest | undefined;
const client = yield* makeSupabaseApiClient(config).pipe(
Effect.provide(
httpClientLayer((request) => {
seenRequest = request;
return Effect.succeed(
jsonResponse(request, 200, {
data: [],
links: { first: null, last: null, prev: null, next: null },
}),
);
}),
),
);
yield* client.execute(operationDefinitions.v2ListNotebooks, {
ref: "abcdefghijklmnopqrst",
page: { size: 100, after: "cursor-1" },
filter: { name: "sales" },
});
expect(seenRequest).toBeDefined();
expect(requestUrlParam(seenRequest!, "page[size]")).toBe("100");
expect(requestUrlParam(seenRequest!, "page[after]")).toBe("cursor-1");
expect(requestUrlParam(seenRequest!, "filter[name]")).toBe("sales");
expect(requestUrlParam(seenRequest!, "page")).toBeUndefined();
expect(requestUrlParam(seenRequest!, "filter")).toBeUndefined();
}),
));
});
describe("retry policy", () => {
const createProject = (client: SupabaseApiClientShape) =>
client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, {
db_pass: "hunter2",
name: "project-name",
organization_slug: "my-org",
});
const createdProject = (request: HttpClientRequest.HttpClientRequest) =>
jsonResponse(request, 201, {
id: "project-id",
ref: "abcdefghijklmnopqrst",
organization_id: "org-id",
organization_slug: "my-org",
name: "project-name",
region: "us-east-1",
created_at: "2026-03-13T12:00:00.000Z",
status: "ACTIVE_HEALTHY",
});
const gaps = (times: ReadonlyArray<number>) => times.slice(1).map((time, i) => time - times[i]!);
test.each([500, 503])("waits out the backoff before retrying a GET after a %i", (status) =>
Effect.runPromise(
Effect.gen(function* () {
const { early, afterBackoff, exit } = yield* onVirtualClock(
Effect.gen(function* () {
const call = yield* startVirtualCall(listProjects, (request, attempt) =>
attempt === 1 ? jsonResponse(request, status, {}) : jsonResponse(request, 200, []),
);
yield* TestClock.adjust(Duration.millis(124));
const early = call.requestTimes.length;
yield* TestClock.adjust(Duration.millis(1));
const afterBackoff = call.requestTimes.length;
return { early, afterBackoff, exit: yield* Fiber.join(call.fiber) };
}),
);
expect(early).toBe(1);
expect(afterBackoff).toBe(2);
expect(exit).toEqual(Exit.succeed([]));
}),
),
);
test("backs off exponentially up to the delay cap", () =>
Effect.runPromise(
Effect.gen(function* () {
const requestTimes = yield* onVirtualClock(
Effect.gen(function* () {
const call = yield* startVirtualCall(
listProjects,
(request) => jsonResponse(request, 503, {}),
{ retry: { maxRetries: 8 } },
);
yield* TestClock.adjust(Duration.minutes(1));
yield* Fiber.join(call.fiber);
return call.requestTimes;
}),
1,
);
expect(gaps(requestTimes)).toEqual([250, 500, 1000, 2000, 4000, 4000, 4000, 4000]);
}),
));
test("retries a POST after a 429 only once its Retry-After seconds have elapsed", () =>
Effect.runPromise(
Effect.gen(function* () {
const { early, afterWait, exit } = yield* onVirtualClock(
Effect.gen(function* () {
const call = yield* startVirtualCall(createProject, (request, attempt) =>
attempt === 1
? HttpClientResponse.fromWeb(
request,
new Response("{}", { status: 429, headers: { "retry-after": "2" } }),
)
: createdProject(request),
);
yield* TestClock.adjust(Duration.millis(1999));
const early = call.requestTimes.length;
yield* TestClock.adjust(Duration.millis(1));
const afterWait = call.requestTimes.length;
return { early, afterWait, exit: yield* Fiber.join(call.fiber) };
}),
);
expect(early).toBe(1);
expect(afterWait).toBe(2);
expect(Exit.isSuccess(exit)).toBe(true);
}),
));
test("retries a GET immediately after a 500 with Retry-After 0", () =>
Effect.runPromise(
Effect.gen(function* () {
const requestTimes = yield* onVirtualClock(
Effect.gen(function* () {
const call = yield* startVirtualCall(listProjects, (request, attempt) =>
attempt === 1
? HttpClientResponse.fromWeb(
request,
new Response("{}", { status: 500, headers: { "retry-after": "0" } }),
)
: jsonResponse(request, 200, []),
);
yield* TestClock.adjust(Duration.millis(0));
yield* Fiber.join(call.fiber);
return call.requestTimes;
}),
);
expect(gaps(requestTimes)).toEqual([0]);
}),
));
test("honours an HTTP-date Retry-After on a 503", () =>
Effect.runPromise(
Effect.gen(function* () {
const now = Date.parse("2026-10-13T12:00:00Z");
const requestTimes = yield* onVirtualClock(
Effect.gen(function* () {
yield* TestClock.setTime(now);
const call = yield* startVirtualCall(listProjects, (request, attempt) =>
attempt === 1
? HttpClientResponse.fromWeb(
request,
new Response("{}", {
status: 503,
headers: { "retry-after": "Tue, 13 Oct 2026 12:00:03 GMT" },
}),
)
: jsonResponse(request, 200, []),
);
yield* TestClock.adjust(Duration.minutes(1));
yield* Fiber.join(call.fiber);
return call.requestTimes;
}),
);
expect(gaps(requestTimes)).toEqual([3000]);
}),
));
test.each(["soon", "-5", ""])("falls back to the backoff when Retry-After is %j", (retryAfter) =>
Effect.runPromise(
Effect.gen(function* () {
const requestTimes = yield* onVirtualClock(
Effect.gen(function* () {
const call = yield* startVirtualCall(listProjects, (request, attempt) =>
attempt === 1
? HttpClientResponse.fromWeb(
request,
new Response("{}", { status: 429, headers: { "retry-after": retryAfter } }),
)
: jsonResponse(request, 200, []),
);
yield* TestClock.adjust(Duration.minutes(1));
yield* Fiber.join(call.fiber);
return call.requestTimes;
}),
);
expect(gaps(requestTimes)).toEqual([125]);
}),
),
);
test("returns a 429 whose Retry-After exceeds the cap without retrying", () =>
Effect.runPromise(
Effect.gen(function* () {
const { requestTimes, exit } = yield* onVirtualClock(
Effect.gen(function* () {
const call = yield* startVirtualCall(listProjects, (request) =>
HttpClientResponse.fromWeb(
request,
new Response("{}", { status: 429, headers: { "retry-after": "30" } }),
),
);
yield* TestClock.adjust(Duration.minutes(1));
return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) };
}),
);
expect(requestTimes).toHaveLength(1);
const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined;
if (!HttpClientError.isHttpClientError(error) || error.reason._tag !== "StatusCodeError") {
throw new Error("expected StatusCodeError");
}
expect(error.reason.response.status).toBe(429);
}),
));
test("honours a Retry-After exactly at the cap", () =>
Effect.runPromise(
Effect.gen(function* () {
const requestTimes = yield* onVirtualClock(
Effect.gen(function* () {
const call = yield* startVirtualCall(listProjects, (request, attempt) =>
attempt === 1
? HttpClientResponse.fromWeb(
request,
new Response("{}", { status: 429, headers: { "retry-after": "10" } }),
)
: jsonResponse(request, 200, []),
);
yield* TestClock.adjust(Duration.minutes(1));
yield* Fiber.join(call.fiber);
return call.requestTimes;
}),
);
expect(gaps(requestTimes)).toEqual([10_000]);
}),
));
describe("per-request retry options", () => {
const listProjectsWith = (retry: SupabaseApiRetryOptions) => (client: SupabaseApiClientShape) =>
client.execute<"v1ListAllProjects">(operationDefinitions.v1ListAllProjects, {}, { retry });
const failedStatus = (exit: Exit.Exit<unknown, unknown>) => {
const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined;
if (!HttpClientError.isHttpClientError(error) || error.reason._tag !== "StatusCodeError") {
throw new Error("expected StatusCodeError");
}
return error.reason.response.status;
};
test("maxRetries 0 makes a single attempt and returns the 503", () =>
Effect.runPromise(
Effect.gen(function* () {
const { requestTimes, exit } = yield* onVirtualClock(
Effect.gen(function* () {
const call = yield* startVirtualCall(listProjectsWith({ maxRetries: 0 }), (request) =>
jsonResponse(request, 503, {}),
);
yield* TestClock.adjust(Duration.minutes(1));
return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) };
}),
);
expect(requestTimes).toHaveLength(1);
expect(failedStatus(exit)).toBe(503);
}),
));
test("wins over the client-level retry options, field by field", () =>
Effect.runPromise(
Effect.gen(function* () {
const { requestTimes, exit } = yield* onVirtualClock(
Effect.gen(function* () {
const call = yield* startVirtualCall(
listProjectsWith({ maxRetries: 1 }),
(request) => jsonResponse(request, 503, {}),
{ retry: { maxRetries: 4, requestTimeoutMs: 1000 } },
);
yield* TestClock.adjust(Duration.minutes(1));
return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) };
}),
);
expect(requestTimes).toHaveLength(2);
expect(failedStatus(exit)).toBe(503);
}),
));
test("still applies when transformClient wraps the client", () =>
Effect.runPromise(
Effect.gen(function* () {
const { requestTimes } = yield* onVirtualClock(
Effect.gen(function* () {
const call = yield* startVirtualCall(
listProjectsWith({ maxRetries: 0 }),
(request) => jsonResponse(request, 503, {}),
{
transformClient: (client) =>
Effect.succeed(HttpClient.mapRequest(client, (r) => r)),
},
);
yield* TestClock.adjust(Duration.minutes(1));
yield* Fiber.join(call.fiber);
return { requestTimes: call.requestTimes };
}),
);
expect(requestTimes).toHaveLength(1);
}),
));
test("requestTimeoutMs Infinity lets an attempt outlast the default timeout", () =>
Effect.runPromise(
Effect.gen(function* () {
const { attempts, exit } = yield* onVirtualClock(
Effect.gen(function* () {
let attempts = 0;
const fiber = yield* makeSupabaseApiClient(config).pipe(
Effect.flatMap(listProjectsWith({ requestTimeoutMs: Number.POSITIVE_INFINITY })),
Effect.exit,
Effect.provide(
httpClientLayer((request) =>
Effect.suspend(() => {
attempts += 1;
return Effect.as(
Effect.sleep(Duration.minutes(5)),
jsonResponse(request, 200, []),
);
}),
),
),
Effect.forkChild,
);
yield* Effect.yieldNow;
yield* TestClock.adjust(Duration.minutes(5));
return { attempts, exit: yield* Fiber.join(fiber) };
}),
);
expect(attempts).toBe(1);
expect(exit).toEqual(Exit.succeed([]));
}),
));
});
test("does not retry a POST after a 500 however long it waits", () =>
Effect.runPromise(
Effect.gen(function* () {
const requestTimes = yield* onVirtualClock(
Effect.gen(function* () {
const call = yield* startVirtualCall(createProject, (request) =>
jsonResponse(request, 500, {}),
);
yield* TestClock.adjust(Duration.minutes(1));
yield* Fiber.join(call.fiber);
return call.requestTimes;
}),
);
expect(requestTimes).toHaveLength(1);
}),
));
test("surfaces the last 503 once a GET exhausts its retries", () =>
Effect.runPromise(
Effect.gen(function* () {
const { requestTimes, exit } = yield* onVirtualClock(
Effect.gen(function* () {
const call = yield* startVirtualCall(listProjects, (request) =>
jsonResponse(request, 503, { message: "unavailable" }),
);
yield* TestClock.adjust(Duration.minutes(1));
return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) };
}),
);
expect(requestTimes).toHaveLength(6);
const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined;
expect(HttpClientError.isHttpClientError(error)).toBe(true);
if (!HttpClientError.isHttpClientError(error) || error.reason._tag !== "StatusCodeError") {
throw new Error("expected StatusCodeError");
}
expect(error.reason.response.status).toBe(503);
}),
));
});
describe("API errors", () => {
test("keep their plain Error shape", () => {
const cause = new Error("schema failure");
const configError = new SupabaseApiConfigError("Missing access token.");
const inputError = new SupabaseApiInputError("Invalid request.");
const causedInputError = new SupabaseApiInputError("Invalid request.", { cause });
for (const error of [configError, inputError, causedInputError]) {
expect(error).toBeInstanceOf(Error);
expect(Object.keys(error)).toEqual(["_tag", "name"]);
expect(error.toJSON()).toEqual({ _tag: error._tag, name: error.name });
expect(Reflect.get(error, NodeInspectSymbol)).toBeUndefined();
}
expect(String(configError)).toBe("SupabaseApiConfigError: Missing access token.");
expect("cause" in inputError).toBe(false);
expect(causedInputError.cause).toBe(cause);
});
});