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:
Gerhard Scheikl
2026-05-31 17:30:04 +02:00
parent cbd29445bb
commit 8e8df7ae64
12 changed files with 134 additions and 28 deletions
+7
View File
@@ -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, '""')}"`;
}
+4 -1
View File
@@ -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
View File
@@ -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);
}
}