Skip to content
KONIGI Patterns

AI Assistants / Limits and errors / Generation error

1 of 3 ›

Generation error

People need to know when an answer has failed and how to try again.

Updated September 12, 2026

Problem

An answer stops partway through, and people cannot tell whether it is still loading or has failed.

Solution

Keep the text that arrived and label the answer as incomplete. Explain the failure in plain language and offer an action that fits the cause.

For a temporary connection problem, offer Retry. Say whether it starts the answer again. For a usage limit, show when access returns or what alternatives remain. Do not send people into a retry loop for a request the assistant has refused.

Detect stalled responses so loading indicators do not run indefinitely. Distinguish an error from a response the person chose to stop. Preserve the incomplete status when the conversation is reused or exported.

Use when

An answer can fail before it finishes, especially when text appears as it is generated.

Don’t use when

The system declined rather than failed. A decline is a refusal, and dressing a policy decision up as a technical fault sends people to retry when they should be rephrasing.

Trade-offs

Partial text can still be useful, but readers may mistake it for a complete answer. Automatic retries can hide brief failures and consume additional usage. Error details should explain the next step without exposing private system information.

Checklist

  • Does the partial response survive the failure?
  • Is the turn marked incomplete, and does that mark travel with the text?
  • Are transient failures, quota exhaustion, and content stops distinguished?
  • Does the message say what to do next?
  • Is there a timeout on the gap between deltas, or only on the request?
  • Does retry regenerate from the start, and does the viewer know that?
  • Is a transient failure retried once automatically before anything is shown?
  • Does the model see that the previous turn failed?
  • Can this state be told apart from a deliberate stop?
  • What happens to a tool call that was in flight?

Compare

ChatGPT leaves the partial text in place with a retry control attached to the turn, so a failure is recoverable in one click without losing what arrived. Claude distinguishes overload from other failures explicitly, which tells the viewer whether waiting is a strategy or a waste of time. Perplexity fails earlier in the pipeline more often, at the search step rather than the generation step, so its equivalent state is about sources rather than a truncated answer. GitHub Copilot mostly fails silently and produces no suggestion at all. Invisible is right for an ambient feature. It also means a persistent outage can go unnoticed for a long time.

Streaming response handles text as it arrives. Stop generation marks an intentional interruption. Refusal explains a request the assistant will not or cannot fulfill.

Usage meter handles exhausted allowances. Tool call trace shows failures in actions or lookups.

Generation error anatomy A stream that died mid-sentence with the partial text kept and labelled, three causes distinguished because each calls for a different response, and a delta timeout catching a connection that stopped arriving without closing. Broken, not declined, and the screen looks the same Connection lost. Retry from the start? Transient retried once, silently before anything showed Quota retrying cannot help belongs to the meter Content stop a refusal wearing an error's clothes gap between deltas, then time out 1 3 2 4 1 KEEP WHAT ARRIVED Four paragraphs streamed and three were probably fine. Label it, do not wipe it. 2 THREE DIFFERENT CAUSES Each calls for a different next move, and a generic apology helps with none of them. 3 TIME THE GAP, NOT THE CALL A stream that stopped arriving without closing is what hangs a spinner forever. 4 TELL IT FROM A STOP Both draw a truncated answer with a control under it and mean the opposite. Retry usually regenerates from the start, so paragraph one comes back different. Say so.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

Broken rather than declined, and the screen looks the same either way. The partial text stays and is labelled, a gap between deltas catches a connection that stopped arriving without closing, and three causes each want a different response.

shadcn
npx shadcn@latest add alert button
npm
lucide-react
Tokens
--card--card-foreground--status-critical

The lease runs five years from 1 March 2024 at £42,000 a year. There is a tenant-only break at the end of year two on six months' written notice, conditional on the rent being paid up to date.

The rent review at year three is upward-only, which means the rent can rise to the open market figure but never fall below £42,000. If the two sides cannot agree, the

Incomplete. The response failed after 2 paragraphs.

GenerationError.tsxKeeps what arrived, marks it incomplete inside the text's own container, and gives each of the three causes its own message and control.

import { RotateCcw } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";

/**
 * Three causes, three next moves. The set is closed because each one wants a
 * different control: a transient fault is retried, a spent quota belongs to
 * the meter, and a content stop is a refusal that retrying cannot undo. A
 * generic apology fits all three and helps with none.
 */
export type Cause = "transient" | "quota" | "content-stop";

const COPY: Record<Cause, { message: string; action: string }> = {
  transient: { message: "Connection lost. Retry from the start?", action: "Retry" },
  quota: { message: "This month's allowance is used up. Retrying will not help.", action: "See usage" },
  "content-stop": { message: "Stopped by the content filter. Rephrase rather than retry.", action: "Edit prompt" },
};

export function GenerationError({ paragraphs, cause, onRetry, onOpenMeter, onEdit }: {
  /** What arrived before the stream died. Kept, never wiped: the earlier
   *  paragraphs were probably fine. */
  paragraphs: string[];
  cause: Cause;
  /** Regenerates from the start. Resuming mid-stream is rarely possible, and
   *  the copy says so rather than letting paragraph one surprise anyone. */
  onRetry: () => void;
  onOpenMeter?: () => void;
  onEdit?: () => void;
}) {
  const { message, action } = COPY[cause];
  const act = cause === "quota" ? onOpenMeter : cause === "content-stop" ? onEdit : onRetry;

  return (
    <div>
      {paragraphs.map((p, i) => (
        <p key={i} className={`text-sm leading-relaxed text-card-foreground ${i ? "mt-3" : ""}`}>{p}</p>
      ))}
      {/* The mark sits inside the text's own container so it travels with a
          copy and the model sees it in the history on the next turn. */}
      <p className="mt-2 text-xs text-status-critical">Incomplete. The response failed after {paragraphs.length} paragraph{paragraphs.length === 1 ? "" : "s"}.</p>

      <Alert className="mt-3 border-status-critical/50 bg-status-critical/10 py-2 text-status-critical">
        <AlertDescription className="flex flex-wrap items-center gap-3 text-xs">
          <span>{message}</span>
          <Button variant="outline" size="sm" className="ml-auto h-7 text-xs" onClick={act} disabled={!act}>
            {cause === "transient" && <RotateCcw className="size-3" />}
            {action}
          </Button>
        </AlertDescription>
      </Alert>
    </div>
  );
}

stallTimeout.tsWraps a stream of deltas and throws when one is followed by silence. A timeout on the request never fires for a stream that stopped without closing.

/**
 * Time the gap, not the call. A stream that stops arriving without closing
 * never rejects, so a timeout on the whole request never fires either. This
 * wraps a stream of deltas and throws when one delta is followed by silence.
 */
export class StallError extends Error {
  constructor(public gapMs: number) {
    super(`no delta for ${gapMs}ms`);
  }
}

export async function* withStallTimeout<T>(deltas: AsyncIterable<T>, gapMs: number): AsyncGenerator<T> {
  const it = deltas[Symbol.asyncIterator]();
  for (;;) {
    let timer: ReturnType<typeof setTimeout> | undefined;
    const stall = new Promise<never>((_, reject) => {
      timer = setTimeout(() => reject(new StallError(gapMs)), gapMs);
    });
    const next = await Promise.race([it.next(), stall]).finally(() => clearTimeout(timer));
    if (next.done) return;
    yield next.value;
  }
}

demo.tsxHow it is called: a turn that died in its second paragraph on a lost connection. Retry regenerates from the start.

import { useState } from "react";
import { GenerationError } from "./GenerationError";

/**
 * Starts in the failed state: one paragraph landed, the second stopped mid
 * sentence, and the cause was a stalled connection. Retry regenerates from
 * the start, and the first paragraph comes back worded differently.
 */
const FIRST = [
  "The lease runs five years from 1 March 2024 at £42,000 a year. There is a tenant-only break at the end of year two on six months' written notice, conditional on the rent being paid up to date.",
  "The rent review at year three is upward-only, which means the rent can rise to the open market figure but never fall below £42,000. If the two sides cannot agree, the",
];
const SECOND = [
  "Five-year term from 1 March 2024 at £42,000 a year. The tenant can break at the end of year two on six months' notice, provided the rent is paid up to date.",
  "The year-three review is upward-only: the rent can rise to the open market figure but never fall below £42,000. Where the parties disagree, an independent surveyor decides as an expert and the decision binds both.",
];

export default function Demo() {
  const [failed, setFailed] = useState(true);
  return (
    <div className="rounded-lg border bg-card p-4">
      {failed ? (
        <GenerationError paragraphs={FIRST} cause="transient" onRetry={() => setFailed(false)} />
      ) : (
        SECOND.map((p, i) => (
          <p key={i} className={`text-sm leading-relaxed text-card-foreground ${i ? "mt-3" : ""}`}>{p}</p>
        ))
      )}
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No screenshots yet for this pattern. Browse Products to see other examples.