A typed TypeScript client for an async video API: fetch, retries, polling, abort
Updated 2026-10-02
Node 18 and later ship fetch, AbortController and web streams, so a video API client needs no HTTP dependency. What it does need is discipline about the one thing that makes paid async APIs awkward: creation is billed once, at the moment the job is created, so a careless retry wrapper can buy the same clip twice. This page gives a complete TypeScript client around POST /videos and GET /videos/{id}, and explains the decisions so you can change them. It targets https://videorouter.sh/api/v1; the same shape is available in Python.
Constraints that shape the code
- Create is not safely retryable on network failure. If the connection drops after the server accepted the job, you do not know whether it exists. Retry only on HTTP responses that prove no job was made (429 and 5xx), never on a thrown network error.
- Polling is free and idempotent. Retry reads generously, with a deadline.
- Errors are OpenAI-style.
{"error": {"message", "type", "code"}}, so a single error class can carry status and code. - Cancellation has to be real. A user closing a tab should stop your polling loop. That is what
AbortSignalis for.
The client
// video-client.ts (Node 18+, ESM)
const BASE = "https://videorouter.sh/api/v1";
const RETRY_STATUS = new Set([429, 500, 502, 503, 504]);
export type JobStatus = "queued" | "in_progress" | "completed" | "failed";
export interface Job {
id: string;
status: JobStatus;
data: { url: string }[] | null;
error: unknown;
provider?: string;
}
export interface CreateParams {
model: string;
prompt: string;
duration_secs?: number;
aspect_ratio?: string;
resolution?: string;
start_image_url?: string;
provider?: Record<string, unknown>;
}
export class ApiError extends Error {
constructor(
public status: number,
public code: string | undefined,
message: string,
) {
super(`${status} ${code ?? ""}: ${message}`);
}
}
/** Connection dropped on create: a job may or may not exist. */
export class AmbiguousCreate extends Error {}
export class JobFailed extends Error {}
const sleep = (ms: number, signal?: AbortSignal) =>
new Promise<void>((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {
clearTimeout(t);
reject(signal.reason ?? new Error("aborted"));
}, { once: true });
});
const backoff = (attempt: number, floorS = 0) =>
(Math.max(floorS, Math.min(60, 2 ** attempt)) + Math.random()) * 1000;
export class VideoClient {
constructor(
private apiKey = process.env.LLMR_API_KEY!,
private maxAttempts = 5,
private requestTimeoutMs = 30_000,
) {}
private async request<T>(
method: "GET" | "POST",
path: string,
opts: { body?: unknown; retryNetwork: boolean; signal?: AbortSignal },
): Promise<T> {
for (let attempt = 0; ; attempt++) {
let res: Response;
try {
res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: opts.body ? JSON.stringify(opts.body) : undefined,
signal: AbortSignal.any([
AbortSignal.timeout(this.requestTimeoutMs),
...(opts.signal ? [opts.signal] : []),
]),
});
} catch (e) {
if (opts.signal?.aborted) throw e; // caller cancelled
if (!opts.retryNetwork) throw new AmbiguousCreate(path);
if (attempt + 1 >= this.maxAttempts) throw e;
await sleep(backoff(attempt), opts.signal);
continue;
}
if (res.ok) return (await res.json()) as T;
const err = (await res.json().catch(() => ({}))).error ?? {};
const last = attempt + 1 >= this.maxAttempts;
if (!RETRY_STATUS.has(res.status) || last) {
throw new ApiError(res.status, err.code, err.message ?? res.statusText);
}
const retryAfter = Number(res.headers.get("Retry-After") ?? 0);
await sleep(backoff(attempt, retryAfter), opts.signal);
}
}
create(params: CreateParams, signal?: AbortSignal): Promise<Job> {
return this.request<Job>("POST", "/videos", {
body: params, retryNetwork: false, signal,
});
}
get(id: string, signal?: AbortSignal): Promise<Job> {
return this.request<Job>("GET", `/videos/${id}`, {
retryNetwork: true, signal,
});
}
async wait(id: string, deadlineMs = 900_000, intervalMs = 5_000,
signal?: AbortSignal): Promise<Job> {
const end = Date.now() + deadlineMs;
while (Date.now() < end) {
const job = await this.get(id, signal);
if (job.status === "completed") return job;
if (job.status === "failed") throw new JobFailed(JSON.stringify(job.error));
await sleep(intervalMs + Math.random() * 1500, signal);
}
throw new Error(`job ${id} not finished after ${deadlineMs} ms`);
}
}
Why the pieces look the way they do
Two retry modes, one function
The retryNetwork flag is the whole safety story. For get it is true: a dropped connection on a read is harmless. For create it is false, so a thrown network error becomes AmbiguousCreate and bubbles to your code, where you should record the attempt and reconcile rather than re-submit. HTTP 429 and 5xx on create are retried, because those responses prove the request was refused; VideoRouter documents 5xx from an exhausted fallback chain as not billed.
Honouring Retry-After
Rate limits are token buckets and a 429 carries a Retry-After header saying how long until the next request would succeed. The helper uses it as a floor under the exponential backoff, plus jitter so parallel workers do not wake in lockstep.
Timeouts versus cancellation
AbortSignal.timeout bounds each request, and AbortSignal.any (Node 20+; on 18 use a manual AbortController with setTimeout) merges it with the caller's signal. The catch block distinguishes the two: if the caller's signal is aborted you rethrow immediately; if it was only the per-request timeout, the retry policy decides.
Wait is just a loop with a deadline
A timeout in wait does not mean the job failed. It may still finish, and re-submitting would create a second charge. Keep the id and resume polling later; resubmit only after the job reports failed.
Using it
import { writeFile } from "node:fs/promises";
import { VideoClient, AmbiguousCreate } from "./video-client.js";
const client = new VideoClient();
const ctl = new AbortController();
process.on("SIGINT", () => ctl.abort());
try {
const job = await client.create({
model: "kling-v3.0-std",
prompt: "a paper airplane gliding over a city, slow tracking shot",
duration_secs: 5,
aspect_ratio: "16:9",
}, ctl.signal);
const done = await client.wait(job.id, 900_000, 5_000, ctl.signal);
const res = await fetch(done.data![0].url);
await writeFile("airplane.mp4", Buffer.from(await res.arrayBuffer()));
} catch (e) {
if (e instanceof AmbiguousCreate) {
// log payload + timestamp; check your dashboard before re-submitting
} else throw e;
}
For large files, pipe res.body to a write stream instead of buffering. And treat the URL as a delivery link: copy the file to your own storage promptly.
Before production
- Persist the job id before the first poll, so a process restart can resume instead of re-creating.
- Cap concurrency with a small pool; an unbounded
Promise.allover a catalogue is a spend incident. - Validate
aspect_ratioandresolutionresults on the downloaded file; unsupported combinations are ignored, not rejected. - Never ship the key to a browser. Run the client server-side.
The matching error policy is spelled out in the error cookbook, and a worker architecture that uses this client lives in the pipeline article. Create a key with a small monthly cap before running it.
Frequently asked questions
Do I need an SDK to call a video API from Node.js?
No. VideoRouter's documentation shows plain fetch calls to POST /videos and GET /videos/{id}; a small typed wrapper adds retries, polling and cancellation.
Why does the client not retry network errors on job creation?
Creation is billed once. If the connection drops after the server accepted the job, a blind retry could create and charge a second job, so the client surfaces an ambiguous-create error instead.
How do I cancel polling cleanly?
Pass an AbortSignal through every request and sleep. Aborting stops the local loop only; a job that was already created continues and was already billed at creation.
Keep reading
- Async Video Jobs Explained — Polling, Timeouts and Retries
- Video API Authentication and Spend Controls: Keys, Caps, Limits
- Video API Error Handling Cookbook: Retries, Backoff, Duplicates
- Production Video Generation Pipeline: Queue, Workers, Python
VideoRouter puts it next to dozens of other video and image models behind one API key, so you can compare providers, prices and fail over automatically. Compare providers on VideoRouter →