fix(admin): retry GoTrue admin reads on transient empty-body responses (bulk-load robustness)
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Retry helper for GoTrue admin reads (`auth.admin.listUsers` /
|
||||
* `auth.admin.getUserById`).
|
||||
*
|
||||
* Under a rapid burst of admin mutations (bulk ban/unban/delete) immediately
|
||||
* followed by the auto list-refresh, GoTrue's admin endpoints intermittently
|
||||
* return an EMPTY or TRUNCATED HTTP body. supabase-js then fails to parse the
|
||||
* response and throws `Unexpected end of JSON input` (or returns an
|
||||
* empty/transient `error`). The underlying request actually succeeded a moment
|
||||
* later, so a small bounded retry turns these flaky failures into reliable
|
||||
* reads without changing happy-path behaviour.
|
||||
*
|
||||
* IMPORTANT: only TRANSIENT failures are retried. Legitimate not-found (404) and
|
||||
* validation (4xx) errors are returned immediately so genuine failures still
|
||||
* surface as proper 4xx/5xx responses upstream.
|
||||
*/
|
||||
|
||||
// Up to 3 attempts total (1 initial + 2 retries). Delays are applied BEFORE the
|
||||
// 2nd and 3rd attempts respectively, so worst-case added latency is ~350ms —
|
||||
// kept well under a second to keep the admin surface snappy.
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const RETRY_DELAYS_MS = [100, 250];
|
||||
|
||||
/** Loose shape that both `AuthError` and `PostgrestError` satisfy. */
|
||||
type MaybeError =
|
||||
| { message?: string | null; status?: number | null; code?: string | number | null }
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type RetryOptions = {
|
||||
/** Total attempts including the first. Defaults to {@link MAX_ATTEMPTS}. */
|
||||
attempts?: number;
|
||||
/** Backoff delays (ms) applied before each retry. Defaults to {@link RETRY_DELAYS_MS}. */
|
||||
delaysMs?: number[];
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** True when a thrown/parse error looks like an empty/truncated-body failure. */
|
||||
function isTransientMessage(message: string): boolean {
|
||||
const m = message.toLowerCase();
|
||||
return (
|
||||
m.includes('unexpected end of json input') ||
|
||||
m.includes('unexpected end of input') ||
|
||||
m.includes('unexpected end of data') ||
|
||||
// Some runtimes phrase the empty-body parse failure differently.
|
||||
(m.includes('json') && m.includes('parse') && m.includes('unexpected')) ||
|
||||
// Low-level network blips that also warrant a quick retry.
|
||||
m.includes('fetch failed') ||
|
||||
m.includes('network') ||
|
||||
m.includes('econnreset') ||
|
||||
m.includes('socket hang up') ||
|
||||
m.includes('terminated')
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a thrown value is a transient, retryable failure. */
|
||||
function isTransientThrow(err: unknown): boolean {
|
||||
if (err instanceof Error) return isTransientMessage(err.message);
|
||||
if (typeof err === 'string') return isTransientMessage(err);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a returned supabase-js `error` is transient. We retry on 5xx and on
|
||||
* empty/parse-style messages, but NEVER on legitimate not-found/validation
|
||||
* (e.g. a 404 with a real "User not found" message).
|
||||
*/
|
||||
function isTransientError(error: MaybeError): boolean {
|
||||
if (!error) return false;
|
||||
const status = typeof error.status === 'number' ? error.status : undefined;
|
||||
// Explicit client/validation/not-found statuses are genuine — do not retry.
|
||||
if (status !== undefined && status >= 400 && status < 500) return false;
|
||||
if (status !== undefined && status >= 500) return true;
|
||||
const message = typeof error.message === 'string' ? error.message : '';
|
||||
if (message && isTransientMessage(message)) return true;
|
||||
// An error object with neither a usable status nor message is treated as an
|
||||
// opaque/empty transient failure worth one more try.
|
||||
if (!status && !message) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Await an async supabase-js admin call that returns `{ data, error }` (or that
|
||||
* may throw), retrying only on transient empty-body / network failures.
|
||||
*
|
||||
* Returns the successful (or genuinely-failed, non-transient) result. After
|
||||
* exhausting all attempts it returns the last `{ data, error }` result or
|
||||
* re-throws the last thrown error, so persistent failures still surface.
|
||||
*/
|
||||
export async function withAdminRetry<R extends { error: MaybeError }>(
|
||||
fn: () => Promise<R>,
|
||||
opts?: RetryOptions,
|
||||
): Promise<R> {
|
||||
const attempts = opts?.attempts ?? MAX_ATTEMPTS;
|
||||
const delays = opts?.delaysMs ?? RETRY_DELAYS_MS;
|
||||
|
||||
let lastResult: R | undefined;
|
||||
let lastThrown: unknown;
|
||||
let threw = false;
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
try {
|
||||
const result = await fn();
|
||||
threw = false;
|
||||
lastResult = result;
|
||||
// Success, or a genuine (non-transient) error — return as-is.
|
||||
if (!isTransientError(result.error)) return result;
|
||||
} catch (err) {
|
||||
// A non-transient throw is a real failure: surface it immediately.
|
||||
if (!isTransientThrow(err)) throw err;
|
||||
threw = true;
|
||||
lastThrown = err;
|
||||
}
|
||||
|
||||
if (attempt < attempts) {
|
||||
const delay = delays[attempt - 1] ?? delays[delays.length - 1] ?? 0;
|
||||
if (delay > 0) await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// Exhausted all attempts on transient failures: surface the last outcome so
|
||||
// the caller still sees a real error rather than a masked success.
|
||||
if (threw) throw lastThrown;
|
||||
return lastResult as R;
|
||||
}
|
||||
Reference in New Issue
Block a user