Skip to main content
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.

Two separate time limits

There are two clocks running independently during an interactive session:
LimitDefaultWhat it coversWhat happens when it’s reached
Per-stream150 secondsA single startStream()endStream() cycleStream ends, but you can start another one
Per-connection60 minutesThe entire connect()disconnect() sessionConnection 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.

What the error looks like

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:
[OdysseySDK] Full stream error:
{type: 'interactive_stream_error', reason: 'session_timeout',
 message: "Stream ended: credit lease expired"}
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.

Automatically restarting streams

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.

React example

If you’re building with React, you can wrap the reconnect logic in a hook:
import { useEffect, useRef, useCallback } from "react";
import { Odyssey } from "@odysseyml/odyssey";

function useOdysseyStream(apiKey: string, prompt: string) {
  const clientRef = useRef<Odyssey | null>(null);
  const videoRef = useRef<HTMLVideoElement>(null);

  const startStream = useCallback(async () => {
    const client = clientRef.current;
    if (!client) return;

    try {
      const mediaStream = await client.connect({
        onStreamError: async (reason) => {
          if (reason === "session_timeout") {
            await startStream();
          }
        },
      });

      if (videoRef.current) {
        videoRef.current.srcObject = mediaStream;
      }

      await client.startStream({ prompt });
    } catch (err) {
      console.error("Failed to start stream:", err);
    }
  }, [prompt]);

  useEffect(() => {
    clientRef.current = new Odyssey({ apiKey });
    startStream();

    return () => {
      clientRef.current?.disconnect();
      clientRef.current = null;
    };
  }, [apiKey, startStream]);

  return videoRef;
}

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 stream
const WARMUP_LEAD_TIME = 20;    // start the next connection this many seconds before expiry

class 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);
  }
}

// Usage
const 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.

Things to keep in mind

  • 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.

Connection vs. Stream

How connections and streams relate to each other.

Session Management

Managing session lifecycle, cleanup, and concurrent limits.