Merge: promote dev to prod
This commit is contained in:
commit
8e99958cf4
2 changed files with 97 additions and 42 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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" }),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue