Merge: promote dev to prod
Some checks failed
CI / frontend (push) Failing after 1m7s
CI / backend (push) Failing after 36s

This commit is contained in:
npeter83 2026-06-18 17:56:49 +02:00
commit 8e99958cf4
2 changed files with 97 additions and 42 deletions

View file

@ -5,4 +5,9 @@ echo "Applying database migrations..."
alembic upgrade head alembic upgrade head
echo "Starting Siftlode API..." echo "Starting Siftlode API..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-config log_config.json # --timeout-keep-alive 75: keep idle upstream connections alive longer than the reverse
# proxy's idle-reuse window. uvicorn's default (5s) is shorter than Caddy's pooled-keepalive
# timeout, so Caddy would reuse a connection uvicorn had just closed -> broken pipe / reset
# on the next request -> 502. Non-idempotent POSTs (the player's progress/state writes,
# fired every 5s) can't be auto-retried by the proxy, so those 502s reached the user.
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-config log_config.json --timeout-keep-alive 75

View file

@ -173,8 +173,25 @@ function notifyErrorThrottled(title: string, message: string): void {
notify({ level: "error", title, message }); notify({ level: "error", title, message });
} }
async function req(url: string, opts: RequestInit = {}): Promise<any> { // Gateway statuses that mean "the upstream connection failed", not "the app rejected the
// request" — typically a reverse-proxy ↔ uvicorn keepalive connection reset before the
// request was processed. Safe to retry an idempotent call once on a fresh connection.
const RETRIABLE_GATEWAY = new Set([502, 503, 504]);
const delay = (ms: number) => new Promise((res) => window.setTimeout(res, ms));
// `idempotent`: may this request be replayed after a transient gateway/network failure?
// GETs always can; non-GET callers opt in (e.g. the player's progress/state writes, which
// are safe to repeat). Used to recover from the keepalive race without surfacing a 502.
interface ReqConfig {
idempotent?: boolean;
}
async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
const method = opts.method ?? "GET"; const method = opts.method ?? "GET";
const canRetry = cfg.idempotent ?? method === "GET";
let attempt = 0;
for (;;) {
let r: Response; let r: Response;
try { try {
r = await fetch(url, { r = await fetch(url, {
@ -183,12 +200,27 @@ async function req(url: string, opts: RequestInit = {}): Promise<any> {
...opts, ...opts,
}); });
} catch (e) { } catch (e) {
// Network-level failure (incl. a keepalive connection reset surfacing as a TypeError).
if (canRetry && attempt === 0) {
attempt++;
await delay(250);
continue;
}
notifyErrorThrottled( notifyErrorThrottled(
"Connection lost", "Connection lost",
"Couldn't reach the server — it may be restarting. This will clear once it's back." "Couldn't reach the server — it may be restarting. This will clear once it's back."
); );
throw e; throw e;
} }
// A gateway error usually means the upstream connection was reset before our request
// was processed — retry idempotent calls once on a fresh connection before surfacing it.
if (!r.ok && canRetry && attempt === 0 && RETRIABLE_GATEWAY.has(r.status)) {
attempt++;
await delay(250);
continue;
}
if (!r.ok) { if (!r.ok) {
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react. // Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
let detail: string | undefined; let detail: string | undefined;
@ -198,11 +230,17 @@ async function req(url: string, opts: RequestInit = {}): Promise<any> {
} catch { } catch {
/* no JSON body */ /* no JSON body */
} }
// Surface anything the server definitively refused as a self-explanatory dialog the // 502/503/504 mean the gateway couldn't reach the app (restarting / connection reset),
// user must acknowledge: 5xx (a fault) and 400/409/422 (validation/conflict, with the // not a request the app refused — treat them like a network blip with the soft,
// server's own reason). 401/403/404 are control flow handled by callers (auth, demo // self-clearing toast rather than a blocking modal the user must acknowledge.
// gating, not-found), so they just throw. // A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do
if (r.status >= 500) { // 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
if (RETRIABLE_GATEWAY.has(r.status)) {
notifyErrorThrottled(
"Connection lost",
"Couldn't reach the server — it may be restarting. This will clear once it's back."
);
} else if (r.status >= 500) {
reportError(detail || `${i18n.t("errors.server")} (${r.status})`); reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
} else if (r.status === 400 || r.status === 409 || r.status === 422) { } else if (r.status === 400 || r.status === 409 || r.status === 422) {
reportError(detail); reportError(detail);
@ -210,6 +248,7 @@ async function req(url: string, opts: RequestInit = {}): Promise<any> {
throw new HttpError(r.status, detail); throw new HttpError(r.status, detail);
} }
return r.status === 204 ? null : r.json(); return r.status === 204 ? null : r.json();
}
} }
// The filter half of the query (everything except paging/sort), shared by the feed and // The filter half of the query (everything except paging/sort), shared by the feed and
@ -392,17 +431,28 @@ export const api = {
req(`/api/feed/count?${feedQuery(f, 0, 0)}`), req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> => facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
req(`/api/facets?${filterParams(f).toString()}`), req(`/api/facets?${filterParams(f).toString()}`),
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
// recover from a transient gateway/keepalive 502 instead of surfacing it (see req()).
setState: (id: string, status: string) => setState: (id: string, status: string) =>
req(`/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }), req(
clearState: (id: string) => req(`/api/videos/${id}/state`, { method: "DELETE" }), `/api/videos/${id}/state`,
{ method: "POST", body: JSON.stringify({ status }) },
{ idempotent: true }
),
clearState: (id: string) =>
req(`/api/videos/${id}/state`, { method: "DELETE" }, { idempotent: true }),
saveProgress: (id: string, positionSeconds: number, durationSeconds: number) => saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>
req(`/api/videos/${id}/progress`, { req(
`/api/videos/${id}/progress`,
{
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
position_seconds: Math.floor(positionSeconds), position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds), duration_seconds: Math.floor(durationSeconds),
}), }),
}), },
{ idempotent: true }
),
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`), videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
pauseSync: () => req("/api/sync/pause", { method: "POST" }), pauseSync: () => req("/api/sync/pause", { method: "POST" }),
resumeSync: () => req("/api/sync/resume", { method: "POST" }), resumeSync: () => req("/api/sync/resume", { method: "POST" }),