How per-stream duration limits work, what the ‘credit lease expired’ error means, and how to keep streaming without interruption.
If you’ve seen an error like Stream ended: credit lease expired or session_timeout in your console, you’re hitting the per-stream duration limit.
This is expected — each stream has a maximum lifetime, but your application can start a new one immediately to keep going.
There are two clocks running independently during an interactive session:
Limit
Default
What it covers
What happens when it’s reached
Per-stream
150 seconds
A single startStream() → endStream() cycle
Stream ends, but you can start another one
Per-connection
60 minutes
The entire connect() → disconnect() session
Connection is closed, you’ll need to reconnect
The per-stream limit is the one most people hit first.
It applies to every stream individually, regardless of how much total usage your account allows.
Your account’s total usage quota controls how many hours you can stream overall.
The per-stream limit is separate — each individual stream maxes out at 150 seconds before you need to start the next one.
When a stream hits its duration limit, the server sends a stream error with reason session_timeout.
In your browser console, you’ll see something like:
This fires through your onStreamError (JavaScript) or on_stream_error (Python) callback.
The stream is done, but the fix is straightforward: start a new one.
The cleanest way to handle this is to catch the session_timeout error and reconnect.
After a lease expiry, the server closes the underlying connection, so you’ll need to call connect() again before starting the next stream.Here’s the pattern for both SDKs:
import { Odyssey } from "@odysseyml/odyssey";const client = new Odyssey({ apiKey: "ody_your_api_key" });let currentPrompt = "A mountain lake at sunrise";async function startSession() { const mediaStream = await client.connect({ onStreamError: async (reason, message) => { if (reason === "session_timeout") { // The stream expired and the server closed the connection. // Reconnect and pick up where we left off. console.log("Stream duration reached, restarting..."); await client.connect(); await client.startStream({ prompt: currentPrompt }); } else { console.error(`Stream error: ${reason} — ${message}`); } }, onStatusChange: (status, message) => { console.log(`Status: ${status}`, message ?? ""); }, }); // Attach the media stream to a <video> element const video = document.getElementById("video") as HTMLVideoElement; video.srcObject = mediaStream; await client.startStream({ prompt: currentPrompt });}startSession();
There will be a brief visual gap (~1-2 seconds) while the new connection spins up.
If you want to make this feel less jarring, show a loading state or freeze the last frame during the transition.
Zero-downtime streaming with dual-session rollover
The reconnect patterns above work well, but there’s a visible gap while the new connection spins up.
If you need truly seamless, uninterrupted video — for a live demo, a kiosk, or a production experience — you can eliminate the gap entirely by running two client instances and rolling over between them.The idea is simple: you know each stream lasts 150 seconds, so around the 130-second mark, you start warming up a second connection in the background.
When it’s ready, you swap the video source and let the first connection wind down.
The user never sees an interruption.
import { Odyssey } from "@odysseyml/odyssey";const STREAM_DURATION = 150; // seconds per streamconst WARMUP_LEAD_TIME = 20; // start the next connection this many seconds before expiryclass DualSessionStream { private clients: [Odyssey, Odyssey]; private activeIndex = 0; private videoElement: HTMLVideoElement; private prompt: string; private warmupTimer: ReturnType<typeof setTimeout> | null = null; constructor(apiKey: string, videoElement: HTMLVideoElement, prompt: string) { this.clients = [ new Odyssey({ apiKey }), new Odyssey({ apiKey }), ]; this.videoElement = videoElement; this.prompt = prompt; } async start() { await this.startOnClient(this.activeIndex); } stop() { if (this.warmupTimer) clearTimeout(this.warmupTimer); this.clients[0].disconnect(); this.clients[1].disconnect(); } private async startOnClient(index: number) { const client = this.clients[index]; const mediaStream = await client.connect({ onStreamError: async (reason) => { if (reason === "session_timeout") { // The standby client should already be live by now. // If not (e.g. warmup failed), fall back to a cold start. client.disconnect(); if (this.videoElement.srcObject === mediaStream) { await this.startOnClient(index); } } }, }); // Attach video and start streaming this.videoElement.srcObject = mediaStream; await client.startStream({ prompt: this.prompt }); this.activeIndex = index; // Schedule the rollover warmup this.scheduleWarmup(index); } private scheduleWarmup(currentIndex: number) { if (this.warmupTimer) clearTimeout(this.warmupTimer); const warmupDelay = (STREAM_DURATION - WARMUP_LEAD_TIME) * 1000; const nextIndex = currentIndex === 0 ? 1 : 0; this.warmupTimer = setTimeout(async () => { try { // Start the next client in the background await this.startOnClient(nextIndex); // Clean up the old one this.clients[currentIndex].disconnect(); } catch (err) { console.error("Warmup failed, will cold-start on expiry:", err); } }, warmupDelay); }}// Usageconst video = document.getElementById("video") as HTMLVideoElement;const session = new DualSessionStream("ody_your_api_key", video, "A mountain lake at sunrise");session.start();
A few things to note about this pattern:
It uses two concurrent session slots. Make sure your account’s concurrent session limit can accommodate this.
The prompt stays the same across rollovers, so the visual output picks up naturally. If your application changes prompts via interact(), make sure both clients stay in sync.
The 20-second lead time is generous — connection setup usually takes 2-5 seconds, but the extra buffer accounts for cold starts and network variability. Tune this for your environment.
This pattern works best for always-on experiences like kiosks, installations, or live event displays where any visual interruption is unacceptable.
For most interactive applications, the simpler single-session reconnect pattern above is plenty.
Always wire up onStreamError / on_stream_error. Without it, your video feed goes dead when the stream expires and the user has no idea what happened.
Hold onto the current prompt. When you start the next stream, pass the same prompt so the visual picks up where it left off.
Add a short delay before reconnecting (200–500ms is plenty) in the single-session pattern. This avoids slamming the server if something goes wrong and the error fires in a tight loop.
Show the user something during the gap if you’re using the single-session pattern. A brief spinner or “Restarting stream…” message goes a long way compared to a frozen or black frame.