Stream API error handling
Handle SSE failures, source fallbacks, and edge cases in an optional Stream API client.
The /movie and /tv endpoints stream via SSE — error handling looks different from a
standard JSON API. This page covers every failure mode and the recommended pattern for each.
These patterns apply when you choose the optional Stream API boundary. SDK integrations should apply the same fallback principles inside their own server-side request flow.
Error Response Shape
Non-streaming endpoints (/health, /subtitles, /downloads,
/test) return standard JSON errors:
{ "error": "description of what went wrong" }
| HTTP Status | When |
|---|---|
400 |
Missing or invalid request parameters |
404 |
No data found (subtitles, downloads) |
500 |
Unexpected server error |
For /movie and /tv, HTTP 200 is returned immediately when the SSE
stream opens. Failures surface as zero source events followed by a
done event with total: 0 — not as HTTP error codes.
Handling No Sources
The most common failure — all providers failed to return a playable stream. Detect it by checking
total in the done event.
const BASE = 'http://localhost:7860';
const res = await fetch(`${BASE}/movie?id=550`);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
let started = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop()!;
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6));
if (event.type === 'source') { started = true; playSource(event.source.url); }
if (event.type === 'done' && !started) {
showError('This title is currently unavailable. Try again later.');
}
}
}
Source Fallback Queue
Sources stream in as providers resolve. Queue every source event URL and fall back to the next
one if playback fails mid-stream:
import Hls from 'hls.js';
const BASE = 'http://localhost:7860';
async function playWithFallback(
videoEl: HTMLVideoElement,
tmdbId: number,
season?: number,
episode?: number,
onSourceChange?: (label: string) => void
) {
const endpoint = season && episode
? `${BASE}/tv?id=${tmdbId}&season=${season}&episode=${episode}`
: `${BASE}/movie?id=${tmdbId}`;
const queue: { url: string; label: string }[] = [];
let started = false;
let hls: Hls | null = null;
function tryNext() {
if (!queue.length) {
console.error('All sources exhausted');
return;
}
const { url, label } = queue.shift()!;
onSourceChange?.(label);
hls?.destroy();
hls = new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60 });
hls.on(Hls.Events.ERROR, (_, err) => {
if (err.fatal) {
console.warn(`Source "${label}" failed:`, err.type);
tryNext();
}
});
hls.loadSource(url);
hls.attachMedia(videoEl);
hls.on(Hls.Events.MANIFEST_PARSED, () => videoEl.play());
}
const res = await fetch(endpoint);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop()!;
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6));
if (event.type === 'source') {
queue.push({ url: event.source.url, label: event.source.label });
if (!started) { started = true; tryNext(); }
}
}
}
return () => hls?.destroy();
}
Network Timeouts
The SSE stream can stay open 20+ seconds while slow providers resolve. Set a client-side timeout using
AbortController:
async function fetchStreamWithTimeout(
url: string,
timeoutMs = 20000
): Promise<ReadableStreamDefaultReader<Uint8Array>> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, {
signal: controller.signal
});
clearTimeout(timer);
return res.body!.getReader();
} catch (err: any) {
clearTimeout(timer);
if (err.name === 'AbortError') throw new Error('Stream timed out');
throw err;
}
}
Handling Missing Subtitles
Subtitles arrive in the meta event. Always guard against an empty array:
if (event.type === 'meta') {
if (!event.subtitles.length) {
subtitleButton.style.display = 'none';
} else {
attachSubtitles(videoEl, event.subtitles);
}
}
Retry Logic for Stream Failures
Add a top-level retry for complete stream failures (network errors, server 5xx before the stream opens):
async function fetchStreamWithRetry(
url: string,
attempts = 3,
delayMs = 2000
): Promise<Response> {
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch(url);
if (res.ok) return res;
if (i === attempts - 1) throw new Error(`HTTP ${res.status}`);
} catch (err) {
if (i === attempts - 1) throw err;
await new Promise(r => setTimeout(r, delayMs * (i + 1)));
}
}
throw new Error('Max retries exceeded');
}
Checking Health Before Requests
If you're building a dashboard, check provider health before opening a stream to give users an early warning:
async function checkAndStream(tmdbId: number) {
const health = await fetch(
'http://localhost:7860/api/health'
).then(r => r.json());
if (health.status === 'degraded') {
const down = Object.entries(health.sources)
.filter(([, v]: any) => !v.ok)
.map(([k]) => k);
console.warn(`Degraded providers: ${down.length}`);
}
return fetch(
`http://localhost:7860/movie?id=${tmdbId}`
);
}
Full Error Boundary (React)
import { useState, useCallback, useRef } from 'react';
import Hls from 'hls.js';
const BASE = 'http://localhost:7860';
type PlayerState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'playing'; sourceLabel: string }
| { status: 'error'; message: string };
export function useStreamPlayer(tmdbId: number) {
const [state, setState] = useState<PlayerState>({ status: 'idle' });
const hlsRef = useRef<Hls | null>(null);
const load = useCallback(async (videoEl: HTMLVideoElement) => {
setState({ status: 'loading' });
const queue: { url: string; label: string }[] = [];
let started = false;
function tryNext() {
if (!queue.length) {
setState({ status: 'error', message: 'All sources failed.' });
return;
}
const { url, label } = queue.shift()!;
hlsRef.current?.destroy();
const hls = new Hls();
hlsRef.current = hls;
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setState({ status: 'playing', sourceLabel: label });
videoEl.play();
});
hls.on(Hls.Events.ERROR, (_, err) => {
if (err.fatal) { hls.destroy(); tryNext(); }
});
hls.loadSource(url);
hls.attachMedia(videoEl);
}
try {
const res = await fetch(`${BASE}/movie?id=${tmdbId}`);
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop()!;
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6));
if (event.type === 'source') {
queue.push({ url: event.source.url, label: event.source.label });
if (!started) { started = true; tryNext(); }
}
if (event.type === 'done' && !started) {
setState({ status: 'error', message: 'No sources available for this title.' });
}
}
}
} catch {
setState({ status: 'error', message: 'Network error. Check your connection.' });
}
}, [tmdbId]);
return { state, load };
}
