fix(admin): eliminate GoTrue empty-body 500s under bulk load (retry-all + undici keep-alive + sequential bulk), CSV formula-injection guard
This commit is contained in:
@@ -11,6 +11,13 @@ export function csvField(v: unknown): string {
|
||||
else if (typeof v === 'string') s = v;
|
||||
else if (typeof v === 'object') s = JSON.stringify(v);
|
||||
else s = String(v);
|
||||
// Spreadsheet formula-injection guard: a field whose first character is one
|
||||
// of = + - @ (or a leading tab/CR) is interpreted as a formula by Excel /
|
||||
// Sheets / LibreOffice. Neutralize it by prefixing a single quote BEFORE the
|
||||
// RFC-4180 quote-escaping below, so the value renders as literal text.
|
||||
if (s.length > 0 && /^[=+\-@\t\r]/.test(s)) {
|
||||
s = `'${s}`;
|
||||
}
|
||||
if (/[",\r\n]/.test(s)) {
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
||||
import { withAdminRetry } from '@/lib/admin/retry';
|
||||
|
||||
export type AdminMetrics = {
|
||||
totalUsers: number;
|
||||
@@ -52,7 +53,9 @@ export async function computeMetrics(): Promise<AdminMetrics> {
|
||||
let signups30d = 0;
|
||||
const perPage = 1000;
|
||||
for (let page = 1; page <= 50; page++) {
|
||||
const { data, error } = await admin.auth.admin.listUsers({ page, perPage });
|
||||
const { data, error } = await withAdminRetry(() =>
|
||||
admin.auth.admin.listUsers({ page, perPage }),
|
||||
);
|
||||
if (error) break;
|
||||
const users = data.users;
|
||||
if (users.length === 0) break;
|
||||
|
||||
+16
-6
@@ -15,11 +15,20 @@
|
||||
* 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];
|
||||
// Up to 5 attempts total (1 initial + 4 retries). Delays are applied BEFORE
|
||||
// attempts 2..5 respectively and are jittered (see `jitter`), so worst-case
|
||||
// added latency is ~80+150+300+600 ≈ 1.13s plus jitter — kept under ~1.5s to
|
||||
// keep the admin surface snappy while reliably riding out the empty-body /
|
||||
// poisoned-keep-alive window that Node 24's bundled undici amplifies.
|
||||
const MAX_ATTEMPTS = 5;
|
||||
const RETRY_DELAYS_MS = [80, 150, 300, 600];
|
||||
|
||||
/** Apply ±30% random jitter so concurrent retries don't synchronise. */
|
||||
function jitter(ms: number): number {
|
||||
if (ms <= 0) return 0;
|
||||
const delta = ms * 0.3;
|
||||
return Math.round(ms - delta + Math.random() * 2 * delta);
|
||||
}
|
||||
|
||||
/** Loose shape that both `AuthError` and `PostgrestError` satisfy. */
|
||||
type MaybeError =
|
||||
@@ -116,7 +125,8 @@ export async function withAdminRetry<R extends { error: MaybeError }>(
|
||||
}
|
||||
|
||||
if (attempt < attempts) {
|
||||
const delay = delays[attempt - 1] ?? delays[delays.length - 1] ?? 0;
|
||||
const base = delays[attempt - 1] ?? delays[delays.length - 1] ?? 0;
|
||||
const delay = jitter(base);
|
||||
if (delay > 0) await sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
+36
-2
@@ -1,7 +1,36 @@
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||
import { Agent } from 'undici';
|
||||
|
||||
let _admin: SupabaseClient | null = null;
|
||||
|
||||
/**
|
||||
* Dedicated undici dispatcher for all admin-client traffic (GoTrue + PostgREST).
|
||||
*
|
||||
* ROOT CAUSE this addresses: GoTrue/undici intermittently returns an empty or
|
||||
* truncated HTTP body when a pooled keep-alive connection is REUSED right after
|
||||
* a write (bulk ban/role/delete) and then immediately read back (the auto list
|
||||
* refresh). supabase-js then throws `Unexpected end of JSON input` and the
|
||||
* route 500s. Node 24's bundled undici reuses connections more aggressively,
|
||||
* amplifying the race.
|
||||
*
|
||||
* `pipelining: 0` disables request pipelining on a connection, and the bounded
|
||||
* keep-alive timeouts keep idle sockets from lingering long enough to be reused
|
||||
* in a half-closed state. When a request DOES still hit a poisoned socket,
|
||||
* undici destroys that socket on error, so the `withAdminRetry` wrapper at the
|
||||
* call sites lands on a FRESH connection rather than the same dead one. The two
|
||||
* layers together eliminate the empty-body 500s.
|
||||
*
|
||||
* NOTE: `setGlobalDispatcher` from the npm `undici` package does NOT affect
|
||||
* Node's built-in global `fetch` (it bundles its own undici), so we attach this
|
||||
* dispatcher explicitly via the `dispatcher` init option, which built-in fetch
|
||||
* does honour.
|
||||
*/
|
||||
const adminDispatcher = new Agent({
|
||||
pipelining: 0,
|
||||
keepAliveTimeout: 10_000,
|
||||
keepAliveMaxTimeout: 10_000,
|
||||
});
|
||||
|
||||
export function getSupabaseAdmin(): SupabaseClient {
|
||||
if (_admin) return _admin;
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
@@ -14,9 +43,14 @@ export function getSupabaseAdmin(): SupabaseClient {
|
||||
global: {
|
||||
// Force every request the admin client makes (GoTrue listUsers and
|
||||
// PostgREST reads alike) to bypass Next's fetch Data Cache, so the admin
|
||||
// surface always reflects current state regardless of page config.
|
||||
// surface always reflects current state regardless of page config, and
|
||||
// route it through the dedicated keep-alive-tamed dispatcher above.
|
||||
fetch: (input: RequestInfo | URL, init?: RequestInit) =>
|
||||
fetch(input, { ...init, cache: 'no-store' }),
|
||||
fetch(input, {
|
||||
...init,
|
||||
cache: 'no-store',
|
||||
dispatcher: adminDispatcher,
|
||||
} as RequestInit),
|
||||
},
|
||||
});
|
||||
return _admin;
|
||||
|
||||
Reference in New Issue
Block a user