Unverified 提交 070c6307 authored 作者: Ryan Groch's avatar Ryan Groch 提交者: GitHub

fix: avoid excessive supabase requests to prevent rate limit (#3250)

This is related to #3240, but likely does not fix the crash. #3240 mentions heavy rate-limits from Supabase. This happens because currently: - we are polling the Supabase endpoint every 5 seconds, and - we do not stop making requests when we get rate limited. This PR makes a few changes: 1. When we get rate-limited by Supabase, we'll check the `Retry-After` property that Supabase sends back. This tells us when Supabase will allow us to make our next request, and we'll honor that value. 2. Changes the polling rate from once every 5 seconds to once every 15 seconds. From what I can tell the requests are just fetching logs from the Supabase API, so I don't think that it's critical enough to need to happen once every 5 seconds. If I'm wrong about this though, please correct me. 3. Refactors `loadEdgeLogsMutation` into a `useQuery` call, which is probably what we want it to be given that we're periodically polling an API endpoint. The side effects can be handled in a separate `useEffect` call, so the behavior should stay essentially the same. This does also prevent us from making extra requests to the endpoint when we already have an active request. Also closes #3244. <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3250" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open in Devin Review"> </picture> </a> <!-- devin-review-badge-end -->
上级 4f96b55b
......@@ -63,7 +63,6 @@ export function PreviewPanel() {
const selectedAppId = useAtomValue(selectedAppIdAtom);
const [isConsoleOpen, setIsConsoleOpen] = useState(false);
const { runApp, loading, app } = useRunApp();
const { loadEdgeLogs } = useSupabase();
const key = useAtomValue(previewPanelKeyAtom);
const consoleEntries = useAtomValue(appConsoleEntriesAtom);
......@@ -81,6 +80,12 @@ export function PreviewPanel() {
}
}, []);
useSupabase({
edgeLogsProjectId: app?.supabaseProjectId,
edgeLogsOrganizationSlug: app?.supabaseOrganizationSlug,
edgeLogsAppId: app?.id,
});
useEffect(() => {
let cancelled = false;
......@@ -121,27 +126,6 @@ export function PreviewPanel() {
// will handle cleanup of idle apps, and users may want apps to keep
// running in the background.
// Load edge logs if app has Supabase project configured
useEffect(() => {
const projectId = app?.supabaseProjectId;
const organizationSlug = app?.supabaseOrganizationSlug ?? undefined;
if (!projectId) return;
// Load logs immediately
loadEdgeLogs({ projectId, organizationSlug }).catch((error) => {
console.error("Failed to load edge logs:", error);
});
// Poll for new logs every 5 seconds
const intervalId = setInterval(() => {
loadEdgeLogs({ projectId, organizationSlug }).catch((error) => {
console.error("Failed to load edge logs:", error);
});
}, 5000);
return () => clearInterval(intervalId);
}, [app?.supabaseProjectId, app?.supabaseOrganizationSlug, loadEdgeLogs]);
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden">
......
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { useEffect, useRef } from "react";
import { lastLogTimestampAtom } from "@/atoms/supabaseAtoms";
import { appConsoleEntriesAtom, selectedAppIdAtom } from "@/atoms/appAtoms";
import {
ipc,
ConsoleEntry,
SetSupabaseAppProjectParams,
DeleteSupabaseOrganizationParams,
SupabaseOrganizationInfo,
......@@ -14,13 +16,24 @@ import { useSettings } from "./useSettings";
import { isSupabaseConnected } from "@/lib/schemas";
import { queryKeys } from "@/lib/queryKeys";
const EDGE_LOGS_POLL_INTERVAL_MS = 5_000;
export interface UseSupabaseOptions {
branchesProjectId?: string | null;
branchesOrganizationSlug?: string | null;
edgeLogsProjectId?: string | null;
edgeLogsOrganizationSlug?: string | null;
edgeLogsAppId?: number | null; // The app id that `edgeLogsProjectId` belongs to
}
export function useSupabase(options: UseSupabaseOptions = {}) {
const { branchesProjectId, branchesOrganizationSlug } = options;
const {
branchesProjectId,
branchesOrganizationSlug,
edgeLogsProjectId,
edgeLogsOrganizationSlug,
edgeLogsAppId,
} = options;
const queryClient = useQueryClient();
const { settings } = useSettings();
const isConnected = isSupabaseConnected(settings);
......@@ -105,54 +118,87 @@ export function useSupabase(options: UseSupabaseOptions = {}) {
enabled: !!branchesProjectId,
});
// Mutation: Load edge function logs for a Supabase project
// Using mutation because it has side effects (updating console entries)
const loadEdgeLogsMutation = useMutation<
void,
Error,
{ projectId: string; organizationSlug?: string }
>({
mutationFn: async ({ projectId, organizationSlug }) => {
if (!selectedAppId) return;
// Use last timestamp if available, otherwise fetch logs from the past 10 minutes
const lastTimestamp = lastLogTimestamp[projectId];
// Query: Poll edge function logs for a Supabase project.
// Polling + in-flight serialization + background-tab pause are all handled
// by React Query. Side effects live in the useEffect below, not in queryFn.
const lastLogTimestampRef = useRef(lastLogTimestamp);
lastLogTimestampRef.current = lastLogTimestamp;
const edgeLogsEnabled =
!!edgeLogsProjectId && !!selectedAppId && edgeLogsAppId === selectedAppId;
const edgeLogsQuery = useQuery<ConsoleEntry[], Error>({
queryKey: edgeLogsEnabled
? queryKeys.supabase.edgeLogs({
projectId: edgeLogsProjectId!,
appId: selectedAppId,
organizationSlug: edgeLogsOrganizationSlug ?? null,
})
: ["supabase", "edgeLogs", "disabled"],
queryFn: async () => {
const projectId = edgeLogsProjectId!;
const lastTimestamp = lastLogTimestampRef.current[projectId];
const timestampStart = lastTimestamp ?? Date.now() - 10 * 60 * 1000;
const logs = await ipc.supabase.getEdgeLogs({
return ipc.supabase.getEdgeLogs({
projectId,
timestampStart,
appId: selectedAppId,
organizationSlug: organizationSlug ?? null,
appId: selectedAppId!,
organizationSlug: edgeLogsOrganizationSlug ?? null,
});
if (logs.length === 0) {
// Even if no logs, set the timestamp so we don't keep looking back 10 minutes
if (!lastTimestamp) {
setLastLogTimestamp((prev) => ({
...prev,
[projectId]: Date.now(),
}));
}
return;
}
logs.forEach((log) => {
ipc.misc.addLog(log);
});
setConsoleEntries((prev) => [...prev, ...logs]);
// Update the last timestamp for this project
const latestLog = logs.reduce((latest, log) =>
log.timestamp > latest.timestamp ? log : latest,
);
setLastLogTimestamp((prev) => ({
...prev,
[projectId]: latestLog.timestamp,
}));
},
enabled: edgeLogsEnabled,
refetchInterval: EDGE_LOGS_POLL_INTERVAL_MS,
refetchOnWindowFocus: false,
retry: false,
});
// Apply side effects once per successful fetch. dataUpdatedAt changes on
// every successful response (even when the returned array is empty), so
// this fires exactly once per poll tick.
const edgeLogsDataUpdatedAt = edgeLogsQuery.dataUpdatedAt;
useEffect(() => {
if (!edgeLogsEnabled || !edgeLogsDataUpdatedAt) return;
const projectId = edgeLogsProjectId!;
const logs = edgeLogsQuery.data;
if (!logs) return;
const lastTimestamp = lastLogTimestampRef.current[projectId];
if (logs.length === 0) {
if (!lastTimestamp) {
setLastLogTimestamp((prev) => ({
...prev,
[projectId]: Date.now(),
}));
}
return;
}
// Filter out logs we've already processed. React Query serves cached
// data on remount with a non-zero dataUpdatedAt, which would otherwise
// re-fire this effect and duplicate entries that were appended during
// the original fetch. Also defends against StrictMode double-invoke.
const newLogs = lastTimestamp
? logs.filter((log) => log.timestamp > lastTimestamp)
: logs;
if (newLogs.length === 0) return;
newLogs.forEach((log) => {
ipc.misc.addLog(log);
});
setConsoleEntries((prev) => [...prev, ...newLogs]);
const latestLog = newLogs.reduce((latest, log) =>
log.timestamp > latest.timestamp ? log : latest,
);
setLastLogTimestamp((prev) => ({
...prev,
[projectId]: latestLog.timestamp,
}));
// edgeLogsDataUpdatedAt is the stable per-fetch trigger; other deps are
// read via ref or are stable setters.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edgeLogsDataUpdatedAt]);
return {
// Data
organizations: organizationsQuery.data ?? [],
......@@ -178,14 +224,13 @@ export function useSupabase(options: UseSupabaseOptions = {}) {
isDeletingOrganization: deleteOrganizationMutation.isPending,
isSettingAppProject: setAppProjectMutation.isPending,
isUnsettingAppProject: unsetAppProjectMutation.isPending,
isLoadingEdgeLogs: loadEdgeLogsMutation.isPending,
isLoadingEdgeLogs: edgeLogsQuery.isFetching,
// Actions
refetchOrganizations: organizationsQuery.refetch,
refetchProjects: projectsQuery.refetch,
refetchBranches: branchesQuery.refetch,
deleteOrganization: deleteOrganizationMutation.mutateAsync,
loadEdgeLogs: loadEdgeLogsMutation.mutateAsync,
setAppProject: setAppProjectMutation.mutateAsync,
unsetAppProject: unsetAppProjectMutation.mutateAsync,
};
......
......@@ -9,14 +9,38 @@ export const logger = log.scope("retryWithRateLimit");
export class RateLimitError extends Error {
public readonly status = 429;
public readonly response: Response;
/** Parsed Retry-After value in milliseconds, if the server supplied one. */
public readonly retryAfterMs?: number;
constructor(message: string, response: Response) {
constructor(message: string, response: Response, retryAfterMs?: number) {
super(message);
this.name = "RateLimitError";
this.response = response;
this.retryAfterMs = retryAfterMs;
}
}
/**
* Parses a Retry-After header value per RFC 7231. The header is either a
* non-negative integer number of seconds, or an HTTP-date. Returns the delay
* in milliseconds, or undefined if the header is missing or unparseable.
* Negative dates (in the past) clamp to 0.
*/
export function parseRetryAfter(
headerValue: string | null,
): number | undefined {
if (!headerValue) return undefined;
const trimmed = headerValue.trim();
if (/^\d+$/.test(trimmed)) {
return parseInt(trimmed, 10) * 1000;
}
const dateMs = Date.parse(trimmed);
if (!Number.isNaN(dateMs)) {
return Math.max(0, dateMs - Date.now());
}
return undefined;
}
/**
* Checks if an error is a rate limit error (HTTP 429).
*/
......@@ -95,14 +119,29 @@ export async function retryWithRateLimit<T>(
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`,
);
// Honor server-supplied Retry-After when present. It can legitimately
// exceed maxDelay — the server knows best; clamping would just 429 again.
const retryAfterMs =
error instanceof RateLimitError ? error.retryAfterMs : undefined;
if (retryAfterMs !== undefined) {
// Clamp to the 32-bit signed int max (~24.8 days) that setTimeout
// accepts. In practice Retry-After from Supabase is seconds to
// minutes, so this ceiling should never be reached — pure defense
// against a malformed/pathological HTTP-date value.
delay = Math.min(retryAfterMs, 2_147_483_647);
logger.warn(
`${context}: Rate limited (attempt ${attempt + 1}/${maxRetries + 1}), honoring Retry-After: ${Math.round(delay)}ms`,
);
} else {
// 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));
}
......@@ -131,9 +170,13 @@ export async function fetchWithRetry(
async () => {
const response = await fetch(input, init);
if (response.status === 429) {
const retryAfterMs = parseRetryAfter(
response.headers.get("Retry-After"),
);
throw new RateLimitError(
`Rate limited (429): ${response.statusText}`,
response,
retryAfterMs,
);
}
return response;
......
......@@ -286,6 +286,16 @@ export const queryKeys = {
projectId: string;
organizationSlug: string | null;
}) => ["supabase", "branches", projectId, organizationSlug] as const,
edgeLogs: ({
projectId,
appId,
organizationSlug,
}: {
projectId: string;
appId: number | null;
organizationSlug: string | null;
}) =>
["supabase", "edgeLogs", projectId, appId, organizationSlug] as const,
},
// ─────────────────────────────────────────────────────────────────────────────
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论