Purrx

Fallback models and circuit breakers

Keep generating when a Gemini model is overloaded: a pool that falls back to a second model, sticks with it, and a circuit breaker that skips unhealthy models, wired into the pipeline.

15 min+40 XPHands-on

Retries cover a few seconds of trouble. But models sometimes stay overloaded for minutes, especially new ones at peak hours. Retrying with backoff then just makes users wait longer for the same error. A gets them their deck.

closed

calls go through

open

3 failures in a row: skip this model

closed again

after the cooldown

gemini-flash-latest

503 overloaded

gemini-flash-lite-latest

works: stay here for this deck

A pool tries models in order. Each model has a breaker: after 3 failures in a row it opens and the model is skipped, until a cooldown passes and it gets another chance.

A model pool

Models in order of preference: the best first, a lighter one that's usually less busy second. pool.run(fn):

  1. Starts from the current model and calls fn(model).
  2. On success, remembers that model as current and returns.
  3. On failure, classifies the error. Overloads, rate limits, used-up quotas, timeouts, network errors and missing models are worth another model. Bad requests aren't: they'd fail on any model.
  4. If another model is worth trying and there is one, move on to it. Otherwise rethrow.

Circuit breakers

Each model gets a (written for you): after three failures in a row it opens and the pool skips that model without even trying, until a 30-second cooldown passes. The last model is always tried, because failing without trying anything helps nobody.

Wired into the pipeline

This lesson also updates pipeline.js. Every model call now goes through one function, from the outside in:

src/pipeline.js (call)
pool.run((model) =>                          // fall back between models
  withRetry(() =>                            // retry what's worth retrying
    withTimeout((signal) => fn(model, signal), CALL_TIMEOUT_MS),   // one attempt, bounded
    { retries: 2, deadline, onRetry },
  ),
)
  • Retries are lowered to 2 per model: with a second model available, falling back soon beats waiting long.
  • onEvent reports retries and fallbacks, so a UI can say what's happening.
Going deeper — what the production lab adds

The PPTX Lab discovers which models a key can use at runtime instead of hard-coding names, since models get retired. It also remembers a model's breaker state across requests, and falls back early when a model has been slow for 15 seconds rather than waiting for it to fail. Same ideas, tuned for traffic.

Your project so far

21 files · 2 new or changed in this lesson

src/pipeline.js

// The whole generator, rebuilt on LangChain chat models:
//
//   (optional) research → outline → write the deck → compile → repair what's broken
//
// It replaces generate.js. The steps are the same; what changed is that each
// step receives a `model`, so we can swap in a different model, or a fake one in tests.
//
// Every model call goes through `call()`: a timeout per attempt, retries with
// backoff for errors worth retrying, and a fallback model when one is unhealthy.

import { HumanMessage, SystemMessage } from "@langchain/core/messages";
import { contentText, createModel } from "./llm.js";
import { generateOutline } from "./outline.js";
import { researchTopic } from "./research.js";
import { buildSystemPrompt, extractTag } from "./prompt.js";
import { LIBRARY } from "./library.js";
import { compileDeck } from "./compile.js";
import { withRetry, withTimeout } from "./retry.js";
import { createModelPool } from "./fallback.js";

const MAX_REPAIR_ROUNDS = 2;
const CALL_TIMEOUT_MS = 90_000; // one model call
const DECK_DEADLINE_MS = 240_000; // the whole deck, inside a typical 300s serverless limit

/** The models to use, best first. The second is lighter and usually less busy. */
export function defaultModels() {
  return [createModel(), createModel({ model: "gemini-flash-lite-latest" })];
}

/** Outline → SlideML, following the component library. */
export async function writeDeck(model, outline, { signal } = {}) {
  const reply = await model.invoke(
    [new SystemMessage(buildSystemPrompt(LIBRARY)), new HumanMessage(`Write the deck for this outline:\n${JSON.stringify(outline, null, 2)}`)],
    { signal },
  );
  const xml = extractTag(contentText(reply), "deck");
  if (!xml) throw new Error("The model didn't reply with a <deck> element.");
  return xml;
}

/** Send the deck back with the compiler's problems and ask for a corrected deck. */
export async function repairDeck(model, xml, problems, { signal } = {}) {
  const list = problems.map((problem) => `- Slide ${problem.slide}: ${problem.message}`).join("\n");
  const reply = await model.invoke(
    [
      new SystemMessage(buildSystemPrompt(LIBRARY)),
      new HumanMessage(`This deck has problems:\n${list}\n\nFix them and reply with the whole corrected <deck>.\n\n${xml}`),
    ],
    { signal },
  );
  return extractTag(contentText(reply), "deck");
}

/**
 * options:
 *   models      chat models, best first (default: defaultModels()); or `model` for just one
 *   research    true to search the web first
 *   slideCount  how many slides to plan (default 6)
 *   theme       "light" or "dark"
 *   compile     the compiler to use (tests pass a fake one)
 *   onEvent     called with { type: "retry" | "fallback", … } so a UI can show what's happening
 *   wait        how retries wait (tests pass a fake one)
 */
export async function generateDeck(topic, options = {}) {
  const { research = false, slideCount = 6, theme, compile = compileDeck, onEvent = () => {}, wait } = options;
  const models = options.model ? [options.model] : (options.models ?? defaultModels());

  const pool = createModelPool(models, { onFallback: (info) => onEvent({ type: "fallback", ...info }) });
  const deadline = Date.now() + DECK_DEADLINE_MS;

  // fallback ⟶ retries ⟶ timeout ⟶ the actual call
  const call = (stage, fn) =>
    pool.run((model) =>
      withRetry(() => withTimeout((signal) => fn(model, signal), CALL_TIMEOUT_MS), {
        retries: 2,
        deadline,
        wait,
        onRetry: (info) => onEvent({ type: "retry", stage, ...info }),
      }),
    );

  const findings = research ? await call("research", (model, signal) => researchTopic(model, topic, { signal })) : { notes: "", sources: [] };
  const outline = await call("outline", (model, signal) => generateOutline(model, topic, { slideCount, research: findings.notes, signal }));
  let xml = await call("deck", (model, signal) => writeDeck(model, outline, { signal }));
  let result = compile(xml, theme);
  let repairs = 0;

  while (result.problems.length > 0 && repairs < MAX_REPAIR_ROUNDS) {
    repairs += 1;
    const fixed = await call("repair", (model, signal) => repairDeck(model, xml, result.problems, { signal }));
    if (!fixed) break;
    const next = compile(fixed, theme);
    if (next.problems.length >= result.problems.length) break;
    xml = fixed;
    result = next;
  }

  return { outline, xml, slides: result.slides, problems: result.problems, repairs, sources: findings.sources };
}

Key takeaways

  • When a model is overloaded for minutes, switch to another model instead of retrying forever.
  • Stick with the working model for the rest of the job, so every call doesn't rediscover the outage.
  • A circuit breaker skips a model after repeated failures, and tries it again after a cooldown.

Sign in to run the exercise

Reading is free. Writing code here needs an account so we have somewhere to keep your Gemini key and the +40 XP you are about to earn.