Skip to content

Commit 85bb5a6

Browse files
committed
fix: preserve SABR playback across gateway outages
1 parent 0c7bcf8 commit 85bb5a6

6 files changed

Lines changed: 186 additions & 14 deletions

File tree

‎src/http-client.ts‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ class TypeTypeHttpError extends Error {
88
}
99
}
1010

11+
const MAX_TRANSIENT_RETRIES = 24;
12+
const MAX_RETRY_DELAY_MS = 3_000;
13+
const TRANSIENT_STATUSES = new Set([502, 503, 504]);
14+
1115
export type HttpClientOptions = {
1216
endpoint: string;
1317
headers?: HeadersInit;
@@ -32,7 +36,7 @@ export class HttpClient {
3236
}
3337

3438
response(url: string, init?: RequestInit): Promise<Response> {
35-
return this.fetchRaw(url, init);
39+
return this.fetchWithTransientRetry(url, init);
3640
}
3741

3842
absolute(path: string): string {
@@ -44,11 +48,26 @@ export class HttpClient {
4448
}
4549

4650
private async fetchAbsolute(url: string, init?: RequestInit): Promise<Response> {
47-
const response = await this.fetchRaw(url, init);
51+
const response = await this.fetchWithTransientRetry(url, init);
4852
if (!response.ok) throw new TypeTypeHttpError(response.statusText, response.status);
4953
return response;
5054
}
5155

56+
private async fetchWithTransientRetry(url: string, init?: RequestInit): Promise<Response> {
57+
for (let attempt = 0; ; attempt += 1) {
58+
try {
59+
const response = await this.fetchRaw(url, init);
60+
if (!TRANSIENT_STATUSES.has(response.status) || attempt >= MAX_TRANSIENT_RETRIES)
61+
return response;
62+
await retryDelay(response, attempt, init?.signal);
63+
} catch (error) {
64+
if (isAbortError(error)) throw error;
65+
if (attempt >= MAX_TRANSIENT_RETRIES) throw error;
66+
await retryDelay(null, attempt, init?.signal);
67+
}
68+
}
69+
}
70+
5271
private async fetchRaw(url: string, init?: RequestInit): Promise<Response> {
5372
const headers = new Headers(this.options.headers);
5473
new Headers(init?.headers).forEach((value, key) => {
@@ -63,3 +82,41 @@ export class HttpClient {
6382
: `${this.options.endpoint}/`;
6483
}
6584
}
85+
86+
function retryDelay(
87+
response: Response | null,
88+
attempt: number,
89+
signal?: AbortSignal | null,
90+
): Promise<void> {
91+
const headerDelay = response ? retryAfterMs(response.headers.get("retry-after")) : null;
92+
const delayMs = headerDelay ?? Math.min(MAX_RETRY_DELAY_MS, 500 * 2 ** attempt);
93+
return new Promise((resolve, reject) => {
94+
if (signal?.aborted) {
95+
reject(signal.reason ?? new DOMException("Operation aborted", "AbortError"));
96+
return;
97+
}
98+
const timer = setTimeout(finish, delayMs);
99+
signal?.addEventListener("abort", abort, { once: true });
100+
function finish(): void {
101+
signal?.removeEventListener("abort", abort);
102+
resolve();
103+
}
104+
function abort(): void {
105+
clearTimeout(timer);
106+
reject(signal?.reason ?? new DOMException("Operation aborted", "AbortError"));
107+
}
108+
});
109+
}
110+
111+
function retryAfterMs(value: string | null): number | null {
112+
if (value === null) return null;
113+
const seconds = Number(value);
114+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(MAX_RETRY_DELAY_MS, seconds * 1000);
115+
const dateMs = Date.parse(value);
116+
if (!Number.isFinite(dateMs)) return null;
117+
return Math.min(MAX_RETRY_DELAY_MS, Math.max(0, dateMs - Date.now()));
118+
}
119+
120+
function isAbortError(error: unknown): boolean {
121+
return error instanceof DOMException && error.name === "AbortError";
122+
}

‎src/player-state.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ export class PlayerState {
1212
this.emitter.emit({ type: "state", state });
1313
}
1414

15-
fail(error: Error): void {
15+
fail(error: Error, recoveryPositionMs: number): void {
1616
if (error.name === "AbortError") return;
1717
this.set("error");
18-
this.emitter.emit({ type: "error", error });
18+
this.emitter.emit({ type: "error", error, recoveryPositionMs });
1919
}
2020

2121
destroy(): void {

‎src/type-type-mse-player.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type {
3232
private loadTask: Promise<void> | null = null;
3333
private sessionTransition: Promise<void> = Promise.resolve();
3434
private audioOnly: boolean;
35+
private recoveryPositionMs: number;
3536
private destroyed = false;
3637

3738
/** Creates a player without starting network or media operations. */ constructor(
@@ -41,6 +42,7 @@ import type {
4142
this.video.crossOrigin = "anonymous";
4243
this.video.playsInline = true;
4344
this.audioOnly = config.audioOnly === true;
45+
this.recoveryPositionMs = Math.max(0, Math.round(config.startTimeMs ?? 0));
4446
this.deps = createPlayerDeps({
4547
video,
4648
config,
@@ -49,7 +51,10 @@ import type {
4951
signal: () => this.operation.signal,
5052
state: (state) => this.playerState.set(state),
5153
error: (error) => this.reportPlaybackFailure(error),
52-
progress: (positionMs) => this.playbackRecovery.observeProgress(positionMs),
54+
progress: (positionMs) => {
55+
this.rememberRecoveryPosition(positionMs);
56+
this.playbackRecovery.observeProgress(positionMs);
57+
},
5358
loopError: (error, context) => this.handlePlaybackLoopError(error, context),
5459
});
5560
this.deps.mediaEvents.start();
@@ -63,7 +68,10 @@ import type {
6368

6469
/** Creates the initial playback session and fills the first media window. */ load(): Promise<void> {
6570
ensurePlayerAlive(this.destroyed);
66-
const task = this.loadInitialSession();
71+
const task = this.loadInitialSession().catch((error: unknown) => {
72+
if (!isAbortError(error)) this.reportPlaybackFailure(asError(error));
73+
throw error;
74+
});
6775
this.loadTask = task;
6876
const clearTask = () => {
6977
if (this.loadTask === task) this.loadTask = null;
@@ -79,6 +87,7 @@ import type {
7987
const signal = this.operation.signal;
8088
this.playerState.set("loading");
8189
const startTimeMs = Math.max(0, Math.round(this.config.startTimeMs ?? 0));
90+
this.recoveryPositionMs = startTimeMs;
8291
const response = await this.deps.playback.create(
8392
{
8493
videoId: this.config.videoId,
@@ -116,6 +125,7 @@ import type {
116125
async seek(positionMs: number): Promise<void> {
117126
this.playbackIntent.capture(this.video.paused, this.playerState.value === "seeking");
118127
const targetMs = Math.max(0, Math.round(positionMs));
128+
this.recoveryPositionMs = targetMs;
119129
return this.seekController.seek(
120130
targetMs,
121131
`seek:${targetMs}`,
@@ -323,7 +333,8 @@ import type {
323333
this.reportPlaybackFailure(error);
324334
return;
325335
}
326-
const positionMs = currentTimeMs(this.video);
336+
this.rememberRecoveryPosition(currentTimeMs(this.video));
337+
const positionMs = this.recoveryPositionMs;
327338
const decision = this.playbackRecovery.begin(sessionId);
328339
if (decision === "ignore") return;
329340
if (decision === "exhausted") {
@@ -380,13 +391,21 @@ import type {
380391
/** Publishes one final playback failure and aborts pending media work. */
381392
private reportPlaybackFailure(error: Error): void {
382393
if (this.destroyed) return;
394+
this.rememberRecoveryPosition(currentTimeMs(this.video));
383395
this.playbackRecovery.reportOnce(error, (failure) => {
384396
this.operation.abort();
385397
this.deps.loop.stop();
386-
this.playerState.fail(failure);
398+
this.playerState.fail(failure, this.recoveryPositionMs);
387399
});
388400
}
389401

402+
/** Keeps the last usable media position across source teardown. */
403+
private rememberRecoveryPosition(positionMs: number): void {
404+
if (Number.isFinite(positionMs) && positionMs > 0) {
405+
this.recoveryPositionMs = Math.round(positionMs);
406+
}
407+
}
408+
390409
/** Serializes MediaSource ownership transitions. */
391410
private enqueueSessionTransition<T>(work: () => Promise<T>): Promise<T> {
392411
const result = this.sessionTransition.then(work, work);

‎src/types.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export type TypeTypeMseEvent =
4646
| { type: "segment"; kind: TrackKind; url: string; startMs: number; durationMs: number }
4747
| { type: "buffer"; bufferedEndMs: number; currentTimeMs: number }
4848
| { type: "seek"; positionMs: number }
49-
| { type: "error"; error: Error };
49+
| { type: "error"; error: Error; recoveryPositionMs: number };
5050

5151
/** Discriminant accepted by {@link TypeTypeMsePlayer.on}. */
5252
export type TypeTypeMseEventType = TypeTypeMseEvent["type"];

‎tests/http-client.test.ts‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,80 @@
1-
import { expect, test } from "bun:test";
1+
import { afterEach, expect, test } from "bun:test";
22
import { HttpClient } from "../src/http-client";
33

4+
const originalFetch = globalThis.fetch;
5+
6+
afterEach(() => {
7+
globalThis.fetch = originalFetch;
8+
});
9+
410
test("preserves endpoint path for absolute API URLs", () => {
511
const http = new HttpClient({ endpoint: "https://beta.typetype.video/api" });
612
expect(http.absolute("/sabr/playback/session/manifest")).toBe(
713
"https://beta.typetype.video/api/sabr/playback/session/manifest",
814
);
915
});
16+
17+
test("retries transient gateway responses", async () => {
18+
let requests = 0;
19+
globalThis.fetch = () => {
20+
requests += 1;
21+
if (requests < 3) {
22+
return Promise.resolve(new Response(null, { status: 502, headers: { "retry-after": "0" } }));
23+
}
24+
return Promise.resolve(Response.json({ ready: true }));
25+
};
26+
27+
const result = await new HttpClient({ endpoint: "https://example.test/api" }).json("/window");
28+
29+
expect(result).toEqual({ ready: true });
30+
expect(requests).toBe(3);
31+
});
32+
33+
test("does not retry permanent client errors", async () => {
34+
let requests = 0;
35+
globalThis.fetch = () => {
36+
requests += 1;
37+
return Promise.resolve(new Response(null, { status: 400, statusText: "Bad Request" }));
38+
};
39+
40+
await expect(
41+
new HttpClient({ endpoint: "https://example.test/api" }).json("/window"),
42+
).rejects.toMatchObject({ status: 400 });
43+
expect(requests).toBe(1);
44+
});
45+
46+
test("returns permanent segment errors to the caller", async () => {
47+
globalThis.fetch = () => Promise.resolve(new Response(null, { status: 404 }));
48+
49+
const response = await new HttpClient({ endpoint: "https://example.test/api" }).response(
50+
"https://example.test/segment",
51+
);
52+
53+
expect(response.status).toBe(404);
54+
});
55+
56+
test("bounds transient gateway retries", async () => {
57+
let requests = 0;
58+
globalThis.fetch = () => {
59+
requests += 1;
60+
return Promise.resolve(new Response(null, { status: 503, headers: { "retry-after": "0" } }));
61+
};
62+
63+
await expect(
64+
new HttpClient({ endpoint: "https://example.test/api" }).json("/window"),
65+
).rejects.toMatchObject({ status: 503 });
66+
expect(requests).toBe(25);
67+
});
68+
69+
test("aborts a pending transient retry", async () => {
70+
const controller = new AbortController();
71+
globalThis.fetch = () =>
72+
Promise.resolve(new Response(null, { status: 503, headers: { "retry-after": "3" } }));
73+
const request = new HttpClient({ endpoint: "https://example.test/api" }).json("/window", {
74+
signal: controller.signal,
75+
});
76+
77+
controller.abort();
78+
79+
await expect(request).rejects.toMatchObject({ name: "AbortError" });
80+
});

‎tests/type-type-mse-recovery.test.ts‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@ type RecoveryHarness = {
2222
operation: PlayerOperation;
2323
playbackRecovery: PlaybackRecovery;
2424
playbackIntent: PlaybackIntent;
25+
recoveryPositionMs: number;
26+
loadTask: Promise<void> | null;
2527
emitter: { emit: (event: { type: string; videoItag?: number }) => void };
2628
playerState: {
2729
value: TypeTypeMseState;
2830
set: (state: TypeTypeMseState) => void;
29-
fail: (error: Error) => void;
31+
fail: (error: Error, recoveryPositionMs: number) => void;
3032
};
3133
deps: {
3234
loop: { stop: () => void };
@@ -39,6 +41,8 @@ type RecoveryHarness = {
3941
switchSession: (...args: unknown[]) => Promise<LoadedSession>;
4042
handlePlaybackLoopError: (error: Error, context: PlaybackLoopFailureContext) => void;
4143
reportPlaybackFailure: (error: Error) => void;
44+
loadInitialSession: () => Promise<void>;
45+
load: () => Promise<void>;
4246
enqueueSessionTransition: <T>(work: () => Promise<T>) => Promise<T>;
4347
sessionTransition: Promise<void>;
4448
};
@@ -74,13 +78,16 @@ function harness(
7478
player: RecoveryHarness;
7579
requests: CreatePlaybackRequest[];
7680
failures: Error[];
81+
failurePositions: number[];
7782
qualities: number[];
7883
} {
7984
const player = Object.create(TypeTypeMsePlayer.prototype) as RecoveryHarness;
8085
const requests: CreatePlaybackRequest[] = [];
8186
const failures: Error[] = [];
87+
const failurePositions: number[] = [];
8288
const qualities: number[] = [];
8389
player.destroyed = false;
90+
player.loadTask = null;
8491
player.sessionTransition = Promise.resolve();
8592
player.session = loaded("source");
8693
player.operation = new PlayerOperation();
@@ -89,7 +96,10 @@ function harness(
8996
player.playerState = {
9097
value: "playing",
9198
set: (state) => (player.playerState.value = state),
92-
fail: (error) => failures.push(error),
99+
fail: (error, positionMs) => {
100+
failures.push(error);
101+
failurePositions.push(positionMs);
102+
},
93103
};
94104
player.deps = {
95105
loop: { stop: () => undefined },
@@ -108,6 +118,7 @@ function harness(
108118
},
109119
};
110120
player.video = { paused, currentTime: 379.441 };
121+
player.recoveryPositionMs = 379_441;
111122
player.config = {
112123
endpoint: "https://beta.typetype.video/api",
113124
videoId: "nt1TGErpc0Q",
@@ -122,7 +133,7 @@ function harness(
122133
player.session = next;
123134
return next;
124135
};
125-
return { player, requests, failures, qualities };
136+
return { player, requests, failures, failurePositions, qualities };
126137
}
127138

128139
function response(sessionId: string): PlaybackResponse {
@@ -249,7 +260,7 @@ test("serializes media source session transitions", async () => {
249260
});
250261

251262
test("a generic failure aborts pending recovery and is reported once", async () => {
252-
const { player, failures } = harness(
263+
const { player, failures, failurePositions } = harness(
253264
async (_request, signal) =>
254265
new Promise<PlaybackResponse>((_resolve, reject) => {
255266
signal?.addEventListener(
@@ -264,6 +275,7 @@ test("a generic failure aborts pending recovery and is reported once", async ()
264275
signal: player.operation.signal,
265276
});
266277
const generic = new Error("media append failed");
278+
player.video.currentTime = 0;
267279

268280
player.handlePlaybackLoopError(generic, {
269281
sessionId: "source",
@@ -273,9 +285,22 @@ test("a generic failure aborts pending recovery and is reported once", async ()
273285
await Bun.sleep(0);
274286

275287
expect(failures).toEqual([generic]);
288+
expect(failurePositions).toEqual([379_441]);
276289
expect(player.operation.signal.aborted).toBe(true);
277290
});
278291

292+
test("an initial load failure reports its configured recovery position", async () => {
293+
const { player, failures, failurePositions } = harness(async () => response("unused"));
294+
const failure = new Error("gateway unavailable");
295+
player.video.currentTime = 0;
296+
player.loadInitialSession = () => Promise.reject(failure);
297+
298+
await expect(player.load()).rejects.toBe(failure);
299+
300+
expect(failures).toEqual([failure]);
301+
expect(failurePositions).toEqual([379_441]);
302+
});
303+
279304
test("an old generic failure cannot replace an active recovery", async () => {
280305
let release: ((value: PlaybackResponse) => void) | null = null;
281306
const pending = new Promise<PlaybackResponse>((resolve) => {

0 commit comments

Comments
 (0)