Unverified 提交 7749a5d6 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Retry rate limit errors for Supabase API callsites (#2148)

Addresses #2147 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds robust retry handling for Supabase API interactions to reduce flakiness on HTTP 429. > > - Introduces `RateLimitError`, `isRateLimitError`, `retryWithRateLimit` (defaults: 6 retries, 2s base, capped, jitter, scoped logging) and `fetchWithRetry` > - Applies retries to key callsites: project API keys, schema/queries, secrets, orgs/members/details, projects, project logs, branches, function deploy/delete, bulk update, and SQL execution > - Standardizes non-429 fetch failures to throw `SupabaseManagementAPIError` to preserve response context > - Improves function deploy by rebuilding `FormData` per attempt and converting 429 to `RateLimitError` > - Adds thorough unit tests covering error detection, backoff behavior, options, and retry exhaustion > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit eaf86cffb2d2b3a3771e8607a52f4e04af630ecd. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Add automatic retries for Supabase API calls on HTTP 429 using exponential backoff with jitter to reduce flakiness and improve reliability. Addresses #2147 by applying retries across key management and context callsites and adding thorough tests. - **New Features** - Added retryWithRateLimit (defaults: 8 retries, 2s base delay, 30s max, 10% jitter, scoped logging), isRateLimitError, RateLimitError, and fetchWithRetry. - Applied retries to fetch-based endpoints and runQuery operations: orgs/members/details/projects, project logs, schema/table/function queries, secrets/API keys, branches, function deploy/delete and bulk update, and SQL execution. - Standardized error handling by throwing SupabaseManagementAPIError for failed fetches to preserve response details. - Added unit tests covering detection, backoff timing, options, retry exhaustion, and fetchWithRetry behavior. <sup>Written for commit c1fbffaf9c559c7f43a6999f4160a81b97ed9eaf. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. -->
上级 05e035f6
import {
isRateLimitError,
retryWithRateLimit,
RateLimitError,
fetchWithRetry,
} from "@/ipc/utils/retryWithRateLimit";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("RateLimitError", () => {
it("should be an instance of Error", () => {
const mockResponse = new Response(null, { status: 429 });
const error = new RateLimitError("Rate limited", mockResponse);
expect(error).toBeInstanceOf(Error);
});
it("should have status 429", () => {
const mockResponse = new Response(null, { status: 429 });
const error = new RateLimitError("Rate limited", mockResponse);
expect(error.status).toBe(429);
});
it("should have the correct name", () => {
const mockResponse = new Response(null, { status: 429 });
const error = new RateLimitError("Rate limited", mockResponse);
expect(error.name).toBe("RateLimitError");
});
it("should store the response", () => {
const mockResponse = new Response(null, { status: 429 });
const error = new RateLimitError("Rate limited", mockResponse);
expect(error.response).toBe(mockResponse);
});
it("should store the message", () => {
const mockResponse = new Response(null, { status: 429 });
const error = new RateLimitError("Custom rate limit message", mockResponse);
expect(error.message).toBe("Custom rate limit message");
});
});
describe("isRateLimitError", () => {
describe("returns true for rate limit errors", () => {
it("should return true for RateLimitError instance", () => {
const mockResponse = new Response(null, { status: 429 });
const error = new RateLimitError("Rate limited", mockResponse);
expect(isRateLimitError(error)).toBe(true);
});
it("should return true for error with status === 429 directly", () => {
const error = { status: 429 };
expect(isRateLimitError(error)).toBe(true);
});
it("should return true for error with response.status === 429", () => {
const error = { response: { status: 429 } };
expect(isRateLimitError(error)).toBe(true);
});
it("should return true for error with additional properties", () => {
const error = {
message: "Rate limited",
response: { status: 429, data: { error: "Too many requests" } },
};
expect(isRateLimitError(error)).toBe(true);
});
});
describe("returns false for non-rate-limit errors", () => {
it("should return false for 400 status", () => {
const error = { response: { status: 400 } };
expect(isRateLimitError(error)).toBe(false);
});
it("should return false for 401 status", () => {
const error = { response: { status: 401 } };
expect(isRateLimitError(error)).toBe(false);
});
it("should return false for 403 status", () => {
const error = { response: { status: 403 } };
expect(isRateLimitError(error)).toBe(false);
});
it("should return false for 404 status", () => {
const error = { response: { status: 404 } };
expect(isRateLimitError(error)).toBe(false);
});
it("should return false for 500 status", () => {
const error = { response: { status: 500 } };
expect(isRateLimitError(error)).toBe(false);
});
it("should return false for 503 status", () => {
const error = { response: { status: 503 } };
expect(isRateLimitError(error)).toBe(false);
});
});
describe("returns false for invalid error shapes", () => {
it("should return false for null", () => {
expect(isRateLimitError(null)).toBe(false);
});
it("should return false for undefined", () => {
expect(isRateLimitError(undefined)).toBe(false);
});
it("should return false for empty object", () => {
expect(isRateLimitError({})).toBe(false);
});
it("should return false for error without response", () => {
const error = { message: "Something went wrong" };
expect(isRateLimitError(error)).toBe(false);
});
it("should return false for error with null response", () => {
const error = { response: null };
expect(isRateLimitError(error)).toBe(false);
});
it("should return false for error with response but no status", () => {
const error = { response: { data: "error" } };
expect(isRateLimitError(error)).toBe(false);
});
it("should return false for string error", () => {
expect(isRateLimitError("Rate limited")).toBe(false);
});
it("should return false for Error instance without response", () => {
expect(isRateLimitError(new Error("Rate limited"))).toBe(false);
});
});
});
describe("retryWithRateLimit", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe("successful operations", () => {
it("should return result on first successful attempt", async () => {
const operation = vi.fn().mockResolvedValue("success");
const resultPromise = retryWithRateLimit(operation, "test-operation");
const result = await resultPromise;
expect(result).toBe("success");
expect(operation).toHaveBeenCalledTimes(1);
});
it("should return result after retry on rate limit then success", async () => {
const rateLimitError = { response: { status: 429 } };
const operation = vi
.fn()
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValueOnce("success after retry");
const resultPromise = retryWithRateLimit(operation, "test-operation");
// First attempt fails immediately
await vi.advanceTimersByTimeAsync(0);
// Wait for the retry delay
await vi.advanceTimersByTimeAsync(3000);
const result = await resultPromise;
expect(result).toBe("success after retry");
expect(operation).toHaveBeenCalledTimes(2);
});
it("should succeed after multiple rate limit errors", async () => {
const rateLimitError = { response: { status: 429 } };
const operation = vi
.fn()
.mockRejectedValueOnce(rateLimitError)
.mockRejectedValueOnce(rateLimitError)
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValueOnce("success after 3 retries");
const resultPromise = retryWithRateLimit(operation, "test-operation");
// Advance through all retry delays
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(3000); // First retry
await vi.advanceTimersByTimeAsync(5000); // Second retry
await vi.advanceTimersByTimeAsync(10000); // Third retry
const result = await resultPromise;
expect(result).toBe("success after 3 retries");
expect(operation).toHaveBeenCalledTimes(4);
});
});
describe("non-rate-limit errors", () => {
it("should throw immediately for 400 error", async () => {
const badRequestError = { response: { status: 400 } };
const operation = vi.fn().mockRejectedValue(badRequestError);
await expect(
retryWithRateLimit(operation, "test-operation"),
).rejects.toEqual(badRequestError);
expect(operation).toHaveBeenCalledTimes(1);
});
it("should throw immediately for 500 error", async () => {
const serverError = { response: { status: 500 } };
const operation = vi.fn().mockRejectedValue(serverError);
await expect(
retryWithRateLimit(operation, "test-operation"),
).rejects.toEqual(serverError);
expect(operation).toHaveBeenCalledTimes(1);
});
it("should throw immediately for generic Error", async () => {
const error = new Error("Network failure");
const operation = vi.fn().mockRejectedValue(error);
await expect(
retryWithRateLimit(operation, "test-operation"),
).rejects.toThrow("Network failure");
expect(operation).toHaveBeenCalledTimes(1);
});
it("should throw immediately for error without response", async () => {
const error = { message: "Connection timeout" };
const operation = vi.fn().mockRejectedValue(error);
await expect(
retryWithRateLimit(operation, "test-operation"),
).rejects.toEqual(error);
expect(operation).toHaveBeenCalledTimes(1);
});
});
describe("exhausted retries", () => {
it("should throw after max retries are exhausted", async () => {
const rateLimitError = { response: { status: 429 } };
const operation = vi.fn().mockRejectedValue(rateLimitError);
const resultPromise = retryWithRateLimit(operation, "test-operation", {
maxRetries: 2,
baseDelay: 100,
});
// Attach rejection handler BEFORE running timers to avoid unhandled rejection
const expectation = expect(resultPromise).rejects.toEqual(rateLimitError);
// Run all timers to completion
await vi.runAllTimersAsync();
await expectation;
expect(operation).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
});
it("should throw after default max retries (6)", async () => {
const rateLimitError = { response: { status: 429 } };
const operation = vi.fn().mockRejectedValue(rateLimitError);
const resultPromise = retryWithRateLimit(operation, "test-operation", {
baseDelay: 100,
});
// Attach rejection handler BEFORE running timers to avoid unhandled rejection
const expectation = expect(resultPromise).rejects.toEqual(rateLimitError);
// Run all timers to completion
await vi.runAllTimersAsync();
await expectation;
expect(operation).toHaveBeenCalledTimes(7); // 1 initial + 6 retries
});
});
describe("custom options", () => {
it("should respect custom maxRetries", async () => {
const rateLimitError = { response: { status: 429 } };
const operation = vi.fn().mockRejectedValue(rateLimitError);
const resultPromise = retryWithRateLimit(operation, "test-operation", {
maxRetries: 1,
baseDelay: 100,
});
// Attach rejection handler BEFORE running timers to avoid unhandled rejection
const expectation = expect(resultPromise).rejects.toEqual(rateLimitError);
// Run all timers to completion
await vi.runAllTimersAsync();
await expectation;
expect(operation).toHaveBeenCalledTimes(2); // 1 initial + 1 retry
});
it("should respect custom baseDelay", async () => {
const rateLimitError = { response: { status: 429 } };
const operation = vi
.fn()
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValueOnce("success");
const resultPromise = retryWithRateLimit(operation, "test-operation", {
baseDelay: 5000,
});
await vi.advanceTimersByTimeAsync(0);
// Should not have retried yet (baseDelay is 5000ms)
await vi.advanceTimersByTimeAsync(4000);
expect(operation).toHaveBeenCalledTimes(1);
// Now it should retry
await vi.advanceTimersByTimeAsync(2000);
const result = await resultPromise;
expect(result).toBe("success");
expect(operation).toHaveBeenCalledTimes(2);
});
it("should cap delay at maxDelay", async () => {
const rateLimitError = { response: { status: 429 } };
const operation = vi
.fn()
.mockRejectedValueOnce(rateLimitError) // attempt 0
.mockRejectedValueOnce(rateLimitError) // attempt 1
.mockRejectedValueOnce(rateLimitError) // attempt 2
.mockRejectedValueOnce(rateLimitError) // attempt 3
.mockResolvedValueOnce("success"); // attempt 4
const resultPromise = retryWithRateLimit(operation, "test-operation", {
baseDelay: 1000,
maxDelay: 5000,
maxRetries: 10,
});
// Advance through retries - delays should be capped at maxDelay
for (let i = 0; i < 5; i++) {
await vi.advanceTimersByTimeAsync(6000);
}
const result = await resultPromise;
expect(result).toBe("success");
expect(operation).toHaveBeenCalledTimes(5);
});
it("should use defaults when options is undefined", async () => {
const operation = vi.fn().mockResolvedValue("success");
const result = await retryWithRateLimit(operation, "test-operation");
expect(result).toBe("success");
expect(operation).toHaveBeenCalledTimes(1);
});
it("should use defaults for unspecified options", async () => {
const rateLimitError = { response: { status: 429 } };
const operation = vi
.fn()
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValueOnce("success");
const resultPromise = retryWithRateLimit(operation, "test-operation", {
maxRetries: 3,
// baseDelay and maxDelay should use defaults
});
// Default baseDelay is 2000ms
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(3000);
const result = await resultPromise;
expect(result).toBe("success");
});
});
describe("return types", () => {
it("should preserve return type for objects", async () => {
const data = { id: 1, name: "test" };
const operation = vi.fn().mockResolvedValue(data);
const result = await retryWithRateLimit(operation, "test-operation");
expect(result).toEqual(data);
});
it("should preserve return type for arrays", async () => {
const data = [1, 2, 3];
const operation = vi.fn().mockResolvedValue(data);
const result = await retryWithRateLimit(operation, "test-operation");
expect(result).toEqual(data);
});
it("should preserve return type for null", async () => {
const operation = vi.fn().mockResolvedValue(null);
const result = await retryWithRateLimit(operation, "test-operation");
expect(result).toBeNull();
});
it("should preserve return type for undefined", async () => {
const operation = vi.fn().mockResolvedValue(undefined);
const result = await retryWithRateLimit(operation, "test-operation");
expect(result).toBeUndefined();
});
});
});
describe("fetchWithRetry", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("should return successful response on first attempt", async () => {
const successResponse = new Response(JSON.stringify({ ok: true }), {
status: 200,
});
vi.mocked(fetch).mockResolvedValue(successResponse);
const result = await fetchWithRetry(
"https://example.com/api",
{ method: "GET" },
"test-fetch",
);
expect(result).toBe(successResponse);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith("https://example.com/api", {
method: "GET",
});
});
it("should retry on 429 response and succeed", async () => {
const rateLimitResponse = new Response(null, {
status: 429,
statusText: "Too Many Requests",
});
const successResponse = new Response(JSON.stringify({ ok: true }), {
status: 200,
});
vi.mocked(fetch)
.mockResolvedValueOnce(rateLimitResponse)
.mockResolvedValueOnce(successResponse);
const resultPromise = fetchWithRetry(
"https://example.com/api",
{ method: "GET" },
"test-fetch",
{ baseDelay: 100 },
);
// First attempt fails, wait for retry delay
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(200);
const result = await resultPromise;
expect(result).toBe(successResponse);
expect(fetch).toHaveBeenCalledTimes(2);
});
it("should exhaust retries on persistent 429 responses", async () => {
const rateLimitResponse = new Response(null, {
status: 429,
statusText: "Too Many Requests",
});
vi.mocked(fetch).mockResolvedValue(rateLimitResponse);
const resultPromise = fetchWithRetry(
"https://example.com/api",
{ method: "GET" },
"test-fetch",
{ maxRetries: 2, baseDelay: 100 },
);
const expectation = expect(resultPromise).rejects.toThrow(RateLimitError);
await vi.runAllTimersAsync();
await expectation;
expect(fetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
});
it("should pass through non-429 error responses without retrying", async () => {
const badRequestResponse = new Response(null, {
status: 400,
statusText: "Bad Request",
});
vi.mocked(fetch).mockResolvedValue(badRequestResponse);
const result = await fetchWithRetry(
"https://example.com/api",
{ method: "GET" },
"test-fetch",
{ baseDelay: 100 },
);
expect(result).toBe(badRequestResponse);
expect(fetch).toHaveBeenCalledTimes(1);
});
it("should propagate fetch network errors without retrying", async () => {
const networkError = new Error("Network failure");
vi.mocked(fetch).mockRejectedValue(networkError);
await expect(
fetchWithRetry(
"https://example.com/api",
{ method: "GET" },
"test-fetch",
),
).rejects.toThrow("Network failure");
expect(fetch).toHaveBeenCalledTimes(1);
});
it("should pass request body correctly", async () => {
const successResponse = new Response(null, { status: 201 });
vi.mocked(fetch).mockResolvedValue(successResponse);
const body = JSON.stringify({ name: "test" });
await fetchWithRetry(
"https://example.com/api",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body,
},
"test-fetch",
);
expect(fetch).toHaveBeenCalledWith("https://example.com/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
});
it("should handle undefined init options", async () => {
const successResponse = new Response(null, { status: 200 });
vi.mocked(fetch).mockResolvedValue(successResponse);
const result = await fetchWithRetry(
"https://example.com/api",
undefined,
"test-fetch",
);
expect(result).toBe(successResponse);
expect(fetch).toHaveBeenCalledWith("https://example.com/api", undefined);
});
});
import log from "electron-log";
export const logger = log.scope("retryWithRateLimit");
/**
* Custom error class for rate limit errors thrown from fetch responses.
* This allows retryWithRateLimit to detect and retry on 429 responses.
*/
export class RateLimitError extends Error {
public readonly status = 429;
public readonly response: Response;
constructor(message: string, response: Response) {
super(message);
this.name = "RateLimitError";
this.response = response;
}
}
/**
* Checks if an error is a rate limit error (HTTP 429).
*/
export function isRateLimitError(error: any): boolean {
// Check for RateLimitError instance
if (error instanceof RateLimitError) {
return true;
}
// Check for status property directly on error (e.g., RateLimitError)
if (error?.status === 429) {
return true;
}
// Check for nested response.status (legacy pattern)
const status = error?.response?.status;
return status === 429;
}
// Retry configuration
const RETRY_CONFIG = {
maxRetries: 8,
baseDelay: 2_000, // 2 seconds
maxDelay: 30_000, // 30 seconds
jitterFactor: 0.1, // 10% jitter
};
export interface RetryWithRateLimitOptions {
/** Maximum number of retries */
maxRetries?: number;
/** Base delay in ms for exponential backoff */
baseDelay?: number;
/** Maximum delay in ms */
maxDelay?: number;
}
/**
* Retries an async operation with exponential backoff on rate limit errors (429).
* Uses exponential backoff.
*
* @param operation - The async operation to retry
* @param context - A descriptive context string for logging
* @param options - Optional retry configuration
*/
export async function retryWithRateLimit<T>(
operation: () => Promise<T>,
context: string,
options?: RetryWithRateLimitOptions,
): Promise<T> {
const maxRetries = options?.maxRetries ?? RETRY_CONFIG.maxRetries;
const baseDelay = options?.baseDelay ?? RETRY_CONFIG.baseDelay;
const maxDelay = options?.maxDelay ?? RETRY_CONFIG.maxDelay;
let lastError: any;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await operation();
if (attempt > 0) {
logger.info(`${context}: Success after ${attempt + 1} attempts`);
}
return result;
} catch (error: any) {
lastError = error;
// Only retry on rate limit errors
if (!isRateLimitError(error)) {
throw error;
}
// Don't retry if we've exhausted all attempts
if (attempt === maxRetries) {
logger.error(
`${context}: Failed after ${maxRetries + 1} attempts due to rate limit`,
);
throw error;
}
let delay: number;
// Use exponential backoff with jitter
const exponentialDelay = baseDelay * Math.pow(2, attempt);
const jitter =
exponentialDelay * RETRY_CONFIG.jitterFactor * Math.random();
delay = Math.min(exponentialDelay + jitter, maxDelay);
logger.warn(
`${context}: Rate limited (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${Math.round(delay)}ms`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError;
}
/**
* Wrapper around fetch that automatically retries on rate limit (429) responses.
* Uses exponential backoff via retryWithRateLimit.
*
* @param input - The fetch input (URL or Request)
* @param init - Optional fetch init options
* @param context - A descriptive context string for logging
* @param retryOptions - Optional retry configuration
* @returns The fetch Response (will not be a 429 response unless retries exhausted)
*/
export async function fetchWithRetry(
input: RequestInfo | URL,
init: RequestInit | undefined,
context: string,
retryOptions?: RetryWithRateLimitOptions,
): Promise<Response> {
return retryWithRateLimit(
async () => {
const response = await fetch(input, init);
if (response.status === 429) {
throw new RateLimitError(
`Rate limited (429): ${response.statusText}`,
response,
);
}
return response;
},
context,
retryOptions,
);
}
import { IS_TEST_BUILD } from "@/ipc/utils/test_utils"; import { IS_TEST_BUILD } from "@/ipc/utils/test_utils";
import { retryWithRateLimit } from "@/ipc/utils/retryWithRateLimit";
import { getSupabaseClient } from "./supabase_management_client"; import { getSupabaseClient } from "./supabase_management_client";
import { import {
SUPABASE_SCHEMA_QUERY, SUPABASE_SCHEMA_QUERY,
...@@ -20,7 +21,10 @@ async function getPublishableKey({ ...@@ -20,7 +21,10 @@ async function getPublishableKey({
const supabase = await getSupabaseClient({ organizationSlug }); const supabase = await getSupabaseClient({ organizationSlug });
let keys; let keys;
try { try {
keys = await supabase.getProjectApiKeys(projectId); keys = await retryWithRateLimit(
() => supabase.getProjectApiKeys(projectId),
`Get API keys for ${projectId}`,
);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Failed to fetch API keys for Supabase project "${projectId}". This could be due to: 1) Invalid project ID, 2) Network connectivity issues, or 3) Supabase API unavailability. Original error: ${error instanceof Error ? error.message : String(error)}`, `Failed to fetch API keys for Supabase project "${projectId}". This could be due to: 1) Invalid project ID, 2) Network connectivity issues, or 3) Supabase API unavailability. Original error: ${error instanceof Error ? error.message : String(error)}`,
...@@ -84,12 +88,15 @@ export async function getSupabaseContext({ ...@@ -84,12 +88,15 @@ export async function getSupabaseContext({
projectId: supabaseProjectId, projectId: supabaseProjectId,
organizationSlug, organizationSlug,
}); });
const schema = await supabase.runQuery( const schema = await retryWithRateLimit(
supabaseProjectId, () => supabase.runQuery(supabaseProjectId, SUPABASE_SCHEMA_QUERY),
SUPABASE_SCHEMA_QUERY, `Get schema for ${supabaseProjectId}`,
); );
const secrets = await supabase.getSecrets(supabaseProjectId); const secrets = await retryWithRateLimit(
() => supabase.getSecrets(supabaseProjectId),
`Get secrets for ${supabaseProjectId}`,
);
const secretNames = secrets?.map((secret) => secret.name); const secretNames = secrets?.map((secret) => secret.name);
// TODO: include EDGE FUNCTIONS and SECRETS! // TODO: include EDGE FUNCTIONS and SECRETS!
...@@ -165,12 +172,15 @@ test-publishable-key ...@@ -165,12 +172,15 @@ test-publishable-key
organizationSlug, organizationSlug,
}); });
const secrets = await supabase.getSecrets(supabaseProjectId); const secrets = await retryWithRateLimit(
() => supabase.getSecrets(supabaseProjectId),
`Get secrets for ${supabaseProjectId}`,
);
const secretNames = secrets?.map((secret) => secret.name) ?? []; const secretNames = secrets?.map((secret) => secret.name) ?? [];
const tableResult = await supabase.runQuery( const tableResult = await retryWithRateLimit(
supabaseProjectId, () => supabase.runQuery(supabaseProjectId, TABLE_NAMES_QUERY),
TABLE_NAMES_QUERY, `Get table names for ${supabaseProjectId}`,
); );
const tableNames = const tableNames =
(tableResult as unknown as { table_name: string }[] | undefined)?.map( (tableResult as unknown as { table_name: string }[] | undefined)?.map(
...@@ -193,9 +203,9 @@ ${JSON.stringify(tableNames)} ...@@ -193,9 +203,9 @@ ${JSON.stringify(tableNames)}
`; `;
if (includeDbFunctions) { if (includeDbFunctions) {
const functionsResult = await supabase.runQuery( const functionsResult = await retryWithRateLimit(
supabaseProjectId, () => supabase.runQuery(supabaseProjectId, SUPABASE_FUNCTIONS_QUERY),
SUPABASE_FUNCTIONS_QUERY, `Get DB functions for ${supabaseProjectId}`,
); );
result += ` result += `
## Database Functions ## Database Functions
...@@ -225,7 +235,10 @@ export async function getSupabaseTableSchema({ ...@@ -225,7 +235,10 @@ export async function getSupabaseTableSchema({
const supabase = await getSupabaseClient({ organizationSlug }); const supabase = await getSupabaseClient({ organizationSlug });
const query = buildSupabaseSchemaQuery(tableName); const query = buildSupabaseSchemaQuery(tableName);
const schemaResult = await supabase.runQuery(supabaseProjectId, query); const schemaResult = await retryWithRateLimit(
() => supabase.runQuery(supabaseProjectId, query),
`Get table schema for ${supabaseProjectId}${tableName ? `:${tableName}` : ""}`,
);
return JSON.stringify(schemaResult); return JSON.stringify(schemaResult);
} }
...@@ -9,6 +9,11 @@ import { ...@@ -9,6 +9,11 @@ import {
import log from "electron-log"; import log from "electron-log";
import { IS_TEST_BUILD } from "../ipc/utils/test_utils"; import { IS_TEST_BUILD } from "../ipc/utils/test_utils";
import type { SupabaseOrganizationCredentials } from "../lib/schemas"; import type { SupabaseOrganizationCredentials } from "../lib/schemas";
import {
fetchWithRetry,
RateLimitError,
retryWithRateLimit,
} from "../ipc/utils/retryWithRateLimit";
const fsPromises = fs.promises; const fsPromises = fs.promises;
...@@ -353,19 +358,26 @@ export async function getSupabaseClientForOrganization( ...@@ -353,19 +358,26 @@ export async function getSupabaseClientForOrganization(
export async function listSupabaseOrganizations( export async function listSupabaseOrganizations(
accessToken: string, accessToken: string,
): Promise<SupabaseOrganizationDetails[]> { ): Promise<SupabaseOrganizationDetails[]> {
const response = await fetch("https://api.supabase.com/v1/organizations", { const response = await fetchWithRetry(
"https://api.supabase.com/v1/organizations",
{
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
}); },
"List Supabase organizations",
);
if (response.status !== 200) { if (response.status !== 200) {
const errorText = await response.text(); const errorText = await response.text();
logger.error( logger.error(
`Failed to fetch organizations (${response.status}): ${errorText}`, `Failed to fetch organizations (${response.status}): ${errorText}`,
); );
throw new Error(`Failed to fetch organizations: ${response.statusText}`); throw new SupabaseManagementAPIError(
`Failed to fetch organizations: ${response.statusText}`,
response,
);
} }
const organizations: SupabaseOrganizationDetails[] = await response.json(); const organizations: SupabaseOrganizationDetails[] = await response.json();
...@@ -407,7 +419,7 @@ export async function getOrganizationMembers( ...@@ -407,7 +419,7 @@ export async function getOrganizationMembers(
const client = await getSupabaseClientForOrganization(organizationSlug); const client = await getSupabaseClientForOrganization(organizationSlug);
const accessToken = (client as any).options.accessToken; const accessToken = (client as any).options.accessToken;
const response = await fetch( const response = await fetchWithRetry(
`https://api.supabase.com/v1/organizations/${organizationSlug}/members`, `https://api.supabase.com/v1/organizations/${organizationSlug}/members`,
{ {
method: "GET", method: "GET",
...@@ -415,6 +427,7 @@ export async function getOrganizationMembers( ...@@ -415,6 +427,7 @@ export async function getOrganizationMembers(
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
}, },
`Get organization members for ${organizationSlug}`,
); );
if (response.status !== 200) { if (response.status !== 200) {
...@@ -422,8 +435,9 @@ export async function getOrganizationMembers( ...@@ -422,8 +435,9 @@ export async function getOrganizationMembers(
logger.error( logger.error(
`Failed to fetch organization members (${response.status}): ${errorText}`, `Failed to fetch organization members (${response.status}): ${errorText}`,
); );
throw new Error( throw new SupabaseManagementAPIError(
`Failed to fetch organization members: ${response.statusText}`, `Failed to fetch organization members: ${response.statusText}`,
response,
); );
} }
...@@ -459,7 +473,7 @@ export async function getOrganizationDetails( ...@@ -459,7 +473,7 @@ export async function getOrganizationDetails(
const client = await getSupabaseClientForOrganization(organizationSlug); const client = await getSupabaseClientForOrganization(organizationSlug);
const accessToken = (client as any).options.accessToken; const accessToken = (client as any).options.accessToken;
const response = await fetch( const response = await fetchWithRetry(
`https://api.supabase.com/v1/organizations/${organizationSlug}`, `https://api.supabase.com/v1/organizations/${organizationSlug}`,
{ {
method: "GET", method: "GET",
...@@ -467,6 +481,7 @@ export async function getOrganizationDetails( ...@@ -467,6 +481,7 @@ export async function getOrganizationDetails(
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
}, },
`Get organization details for ${organizationSlug}`,
); );
if (response.status !== 200) { if (response.status !== 200) {
...@@ -474,8 +489,9 @@ export async function getOrganizationDetails( ...@@ -474,8 +489,9 @@ export async function getOrganizationDetails(
logger.error( logger.error(
`Failed to fetch organization details (${response.status}): ${errorText}`, `Failed to fetch organization details (${response.status}): ${errorText}`,
); );
throw new Error( throw new SupabaseManagementAPIError(
`Failed to fetch organization details: ${response.statusText}`, `Failed to fetch organization details: ${response.statusText}`,
response,
); );
} }
...@@ -496,7 +512,10 @@ export async function getSupabaseProjectName( ...@@ -496,7 +512,10 @@ export async function getSupabaseProjectName(
} }
const supabase = await getSupabaseClient({ organizationSlug }); const supabase = await getSupabaseClient({ organizationSlug });
const projects = await supabase.getProjects(); const projects = await retryWithRateLimit(
() => supabase.getProjects(),
`Get Supabase projects for ${projectId}`,
);
const project = projects?.find((p) => p.id === projectId); const project = projects?.find((p) => p.id === projectId);
return project?.name || `<project not found for: ${projectId}>`; return project?.name || `<project not found for: ${projectId}>`;
} }
...@@ -539,18 +558,23 @@ LIMIT 1000`; ...@@ -539,18 +558,23 @@ LIMIT 1000`;
logger.info(`Fetching logs from: ${url}`); logger.info(`Fetching logs from: ${url}`);
const response = await fetch(url, { const response = await fetchWithRetry(
url,
{
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${(supabase as any).options.accessToken}`, Authorization: `Bearer ${(supabase as any).options.accessToken}`,
}, },
}); },
`Get Supabase project logs for ${projectId}`,
);
if (response.status !== 200) { if (response.status !== 200) {
const errorText = await response.text(); const errorText = await response.text();
logger.error(`Failed to fetch logs (${response.status}): ${errorText}`); logger.error(`Failed to fetch logs (${response.status}): ${errorText}`);
throw new Error( throw new SupabaseManagementAPIError(
`Failed to fetch logs: ${response.statusText} (${response.status}) - ${errorText}`, `Failed to fetch logs: ${response.statusText} (${response.status}) - ${errorText}`,
response,
); );
} }
...@@ -574,7 +598,10 @@ export async function executeSupabaseSql({ ...@@ -574,7 +598,10 @@ export async function executeSupabaseSql({
} }
const supabase = await getSupabaseClient({ organizationSlug }); const supabase = await getSupabaseClient({ organizationSlug });
const result = await supabase.runQuery(supabaseProjectId, query); const result = await retryWithRateLimit(
() => supabase.runQuery(supabaseProjectId, query),
`Execute SQL on ${supabaseProjectId}`,
);
return JSON.stringify(result); return JSON.stringify(result);
} }
...@@ -591,7 +618,10 @@ export async function deleteSupabaseFunction({ ...@@ -591,7 +618,10 @@ export async function deleteSupabaseFunction({
`Deleting Supabase function: ${functionName} from project: ${supabaseProjectId}`, `Deleting Supabase function: ${functionName} from project: ${supabaseProjectId}`,
); );
const supabase = await getSupabaseClient({ organizationSlug }); const supabase = await getSupabaseClient({ organizationSlug });
await supabase.deleteFunction(supabaseProjectId, functionName); await retryWithRateLimit(
() => supabase.deleteFunction(supabaseProjectId, functionName),
`Delete function ${functionName}`,
);
logger.info( logger.info(
`Deleted Supabase function: ${functionName} from project: ${supabaseProjectId}`, `Deleted Supabase function: ${functionName} from project: ${supabaseProjectId}`,
); );
...@@ -627,7 +657,7 @@ export async function listSupabaseBranches({ ...@@ -627,7 +657,7 @@ export async function listSupabaseBranches({
logger.info(`Listing Supabase branches for project: ${supabaseProjectId}`); logger.info(`Listing Supabase branches for project: ${supabaseProjectId}`);
const supabase = await getSupabaseClient({ organizationSlug }); const supabase = await getSupabaseClient({ organizationSlug });
const response = await fetch( const response = await fetchWithRetry(
`https://api.supabase.com/v1/projects/${supabaseProjectId}/branches`, `https://api.supabase.com/v1/projects/${supabaseProjectId}/branches`,
{ {
method: "GET", method: "GET",
...@@ -635,6 +665,7 @@ export async function listSupabaseBranches({ ...@@ -635,6 +665,7 @@ export async function listSupabaseBranches({
Authorization: `Bearer ${(supabase as any).options.accessToken}`, Authorization: `Bearer ${(supabase as any).options.accessToken}`,
}, },
}, },
`List Supabase branches for ${supabaseProjectId}`,
); );
if (response.status !== 200) { if (response.status !== 200) {
...@@ -704,9 +735,9 @@ export async function deploySupabaseFunction({ ...@@ -704,9 +735,9 @@ export async function deploySupabaseFunction({
// 5) Prepare multipart form-data // 5) Prepare multipart form-data
const supabase = await getSupabaseClient({ organizationSlug }); const supabase = await getSupabaseClient({ organizationSlug });
function buildFormData() {
const formData = new FormData(); const formData = new FormData();
// Metadata: instruct Supabase to use our import map
const metadata = { const metadata = {
entrypoint_path: entrypointPath, entrypoint_path: entrypointPath,
name: functionName, name: functionName,
...@@ -716,7 +747,6 @@ export async function deploySupabaseFunction({ ...@@ -716,7 +747,6 @@ export async function deploySupabaseFunction({
formData.append("metadata", JSON.stringify(metadata)); formData.append("metadata", JSON.stringify(metadata));
// Add all files to form data
for (const f of filesToUpload) { for (const f of filesToUpload) {
const buf: Buffer = f.content; const buf: Buffer = f.content;
const mime = guessMimeType(f.relativePath); const mime = guessMimeType(f.relativePath);
...@@ -724,24 +754,34 @@ export async function deploySupabaseFunction({ ...@@ -724,24 +754,34 @@ export async function deploySupabaseFunction({
formData.append("file", blob, f.relativePath); formData.append("file", blob, f.relativePath);
} }
return formData;
}
// 6) Perform the deploy request // 6) Perform the deploy request
const deployUrl = `https://api.supabase.com/v1/projects/${encodeURIComponent( const deployUrl = `https://api.supabase.com/v1/projects/${encodeURIComponent(
supabaseProjectId, supabaseProjectId,
)}/functions/deploy?slug=${encodeURIComponent(functionName)}${bundleOnly ? "&bundleOnly=true" : ""}`; )}/functions/deploy?slug=${encodeURIComponent(functionName)}${bundleOnly ? "&bundleOnly=true" : ""}`;
const response = await fetch(deployUrl, { const response = await retryWithRateLimit(async () => {
const res = await fetch(deployUrl, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${(supabase as any).options.accessToken}`, Authorization: `Bearer ${(supabase as any).options.accessToken}`,
}, },
body: formData, // Safer to rebuild form data each time.
body: buildFormData(),
}); });
if (res.status === 429) {
throw new RateLimitError(`Rate limited (429): ${res.statusText}`, res);
}
return res;
}, `Deploy Supabase function ${functionName}`);
if (response.status !== 201) { if (response.status !== 201) {
throw await createResponseError(response, "create function"); throw await createResponseError(response, "create function");
} }
const result: DeployedFunctionResponse = await response.json(); const result = (await response.json()) as DeployedFunctionResponse;
logger.info( logger.info(
`Deployed Supabase function: ${functionName} to project: ${supabaseProjectId}${bundleOnly ? " (bundle only)" : ""}`, `Deployed Supabase function: ${functionName} to project: ${supabaseProjectId}${bundleOnly ? " (bundle only)" : ""}`,
...@@ -765,7 +805,7 @@ export async function bulkUpdateFunctions({ ...@@ -765,7 +805,7 @@ export async function bulkUpdateFunctions({
const supabase = await getSupabaseClient({ organizationSlug }); const supabase = await getSupabaseClient({ organizationSlug });
const response = await fetch( const response = await fetchWithRetry(
`https://api.supabase.com/v1/projects/${encodeURIComponent(supabaseProjectId)}/functions`, `https://api.supabase.com/v1/projects/${encodeURIComponent(supabaseProjectId)}/functions`,
{ {
method: "PUT", method: "PUT",
...@@ -775,6 +815,7 @@ export async function bulkUpdateFunctions({ ...@@ -775,6 +815,7 @@ export async function bulkUpdateFunctions({
}, },
body: JSON.stringify(functions), body: JSON.stringify(functions),
}, },
`Bulk update functions for ${supabaseProjectId}`,
); );
if (response.status !== 200) { if (response.status !== 200) {
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论