mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-03 08:38:42 -05:00
Timeout long request with error
This commit is contained in:
@@ -32,6 +32,18 @@ const MAX_MATCHES_PER_REQUEST = 50;
|
||||
*/
|
||||
const UNLINKED_RETRY_DELAYS_MS = [30_000, 2 * 60_000, 5 * 60_000];
|
||||
|
||||
/** A request sendou.ink hasn't answered by then fails, so a hung one can't hold up the sends queued behind it. */
|
||||
const INGEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* A "sending" status older than this was cut off (the tab closed mid-request)
|
||||
* and counts as failed. Outlives any live request, which the timeout bounds.
|
||||
*/
|
||||
const STALE_SENDING_MS = 2 * INGEST_TIMEOUT_MS;
|
||||
|
||||
/** Browsers refuse keepalive requests over 64 KiB; bigger ones go out without it. */
|
||||
const KEEPALIVE_MAX_BODY_BYTES = 64 * 1024;
|
||||
|
||||
export interface SendResult {
|
||||
sentMatches: number;
|
||||
failedMatches: number;
|
||||
@@ -117,13 +129,13 @@ export function matchContaining(
|
||||
/**
|
||||
* The single send status a match displays, folded from its source events: an
|
||||
* in-flight send wins, then failure, then success; within a state the most
|
||||
* recent change is shown.
|
||||
* recent change is shown. A send cut off mid-request shows as failed.
|
||||
*/
|
||||
export function aggregateSendStatus(
|
||||
sources: readonly ScanEvent[],
|
||||
): SendStatus | undefined {
|
||||
const statuses = sources
|
||||
.map((e) => e.send)
|
||||
.map((e) => currentSendStatus(e.send))
|
||||
.filter((status) => status !== undefined);
|
||||
for (const state of ["sending", "failed", "unlinked", "sent"] as const) {
|
||||
const ofState = statuses.filter((status) => status.state === state);
|
||||
@@ -136,9 +148,10 @@ export function aggregateSendStatus(
|
||||
|
||||
/** Match selector: matches not yet sent (nor currently sending). */
|
||||
export function unsentMatches(built: BuiltMatch<ScanEvent>): boolean {
|
||||
return !built.sources.some(
|
||||
(e) => e.send?.state === "sent" || e.send?.state === "sending",
|
||||
);
|
||||
return !built.sources.some((e) => {
|
||||
const state = currentSendStatus(e.send)?.state;
|
||||
return state === "sent" || state === "sending";
|
||||
});
|
||||
}
|
||||
|
||||
/** Match selector: matches stored without a game to link to, whose next retry is due. */
|
||||
@@ -164,6 +177,22 @@ export function unsentClosedMatches(built: BuiltMatch<ScanEvent>): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function currentSendStatus(
|
||||
status: SendStatus | undefined,
|
||||
): SendStatus | undefined {
|
||||
if (
|
||||
status?.state !== "sending" ||
|
||||
Date.now() - status.at < STALE_SENDING_MS
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
return {
|
||||
state: "failed",
|
||||
at: status.at,
|
||||
error: "the upload was interrupted before sendou.ink answered",
|
||||
};
|
||||
}
|
||||
|
||||
function ingestableBuilt<E extends DetectedEvent>(
|
||||
built: readonly BuiltMatch<E>[],
|
||||
): BuiltMatch<E>[] {
|
||||
@@ -174,17 +203,30 @@ function ingestableBuilt<E extends DetectedEvent>(
|
||||
async function postIngestMatches(
|
||||
matches: ScannerMatch[],
|
||||
): Promise<IngestResponse> {
|
||||
const res = await fetch(INGEST_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ matches }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
res.status === 401 ? "not logged in to sendou.ink" : await errorText(res),
|
||||
);
|
||||
const body = new TextEncoder().encode(JSON.stringify({ matches }));
|
||||
try {
|
||||
const res = await fetch(INGEST_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
signal: AbortSignal.timeout(INGEST_TIMEOUT_MS),
|
||||
// lets the send finish when the tab closes right after Stop
|
||||
keepalive: body.byteLength <= KEEPALIVE_MAX_BODY_BYTES,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
res.status === 401
|
||||
? "not logged in to sendou.ink"
|
||||
: await errorText(res),
|
||||
);
|
||||
}
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "TimeoutError") {
|
||||
throw new Error("sendou.ink didn't answer in time", { cause: err });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function errorText(res: Response): Promise<string> {
|
||||
|
||||
70
app/features/scanner/tests/logic/send-status.test.ts
Normal file
70
app/features/scanner/tests/logic/send-status.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
aggregateSendStatus,
|
||||
unsentMatches,
|
||||
} from "../../components/sendou-ingest";
|
||||
import type { ScanEvent } from "../../components/session-data";
|
||||
import type { BuiltMatch } from "../../core/match-builder";
|
||||
import type { ScannerMatch } from "../../core/scanner-match";
|
||||
import type { SendStatus } from "../../store/events";
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("aggregateSendStatus", () => {
|
||||
test("a send still in flight shows as sending", () => {
|
||||
const status = aggregateSendStatus([
|
||||
event({ state: "sending", at: NOW - 5_000 }),
|
||||
]);
|
||||
|
||||
expect(status?.state).toBe("sending");
|
||||
});
|
||||
|
||||
test("a send cut off mid-request shows as failed", () => {
|
||||
const status = aggregateSendStatus([
|
||||
event({ state: "sending", at: NOW - 5 * 60_000 }),
|
||||
]);
|
||||
|
||||
expect(status?.state).toBe("failed");
|
||||
expect(status?.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsentMatches", () => {
|
||||
test.each([
|
||||
{ why: "never attempted", send: undefined, expected: true },
|
||||
{ why: "sent", send: { state: "sent", at: NOW }, expected: false },
|
||||
{
|
||||
why: "sending",
|
||||
send: { state: "sending", at: NOW - 5_000 },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
why: "cut off mid-request",
|
||||
send: { state: "sending", at: NOW - 5 * 60_000 },
|
||||
expected: true,
|
||||
},
|
||||
] satisfies Array<{
|
||||
why: string;
|
||||
send: SendStatus | undefined;
|
||||
expected: boolean;
|
||||
}>)("$why", ({ send, expected }) => {
|
||||
expect(unsentMatches(built([event(send)]))).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
function event(send: SendStatus | undefined): ScanEvent {
|
||||
return { type: "Scoreboard", t: 0, confidence: 1, data: null, send };
|
||||
}
|
||||
|
||||
function built(sources: ScanEvent[]): BuiltMatch<ScanEvent> {
|
||||
return { match: {} as ScannerMatch, sources };
|
||||
}
|
||||
Reference in New Issue
Block a user