Skip to content
KONIGI

AI Assistants / Limits and failure / Generation error

1 of 3

Generation error

The stream died halfway and left a half-written paragraph on the screen.

Updated September 12, 2026

Problem

An answer stops mid-sentence. The viewer can’t tell whether the connection dropped, the model failed, a quota ran out, or the response is simply still coming, and the partial text sitting on screen looks identical in all four cases.

Solution

Name the failure and keep what arrived. Both halves matter, and products routinely ship one.

Keeping the partial response is the part most often wrong. Wiping the turn on failure discards work the viewer could use, and where a response had streamed four paragraphs before dying, three of them were probably fine. Leave them, mark the turn as incomplete, and make sure the incompleteness travels with the text when it’s copied or when the model reads the history on the next turn.

Naming the failure means distinguishing cases that call for different responses. A network interruption is the viewer’s to retry. An overloaded model is worth retrying in a moment. An exhausted quota won’t improve by retrying and belongs to the usage meter. A content filter stopping mid-stream is a refusal wearing an error’s clothes, and treating it as a transient fault sends the viewer into a retry loop that can’t succeed. NN/g’s guidance on error messages applies unchanged here: say what happened in plain language, and say what to do about it.

Retry needs to be the obvious next control, and it needs to be honest about what it does. Resuming a stream from where it stopped is rarely possible, so retry usually means regenerating from the start, and the viewer shouldn’t be surprised when paragraph one comes back different. Where the failure was transient, an automatic single retry before showing anything at all removes most of these events from the viewer’s experience entirely.

The client side is unglamorous and decides how this feels. An aborted fetch, a closed event stream and a silently stalled connection are three different conditions, and the third is the one that hangs an interface. A timeout on the gap between deltas catches a stream that stopped arriving without closing, which is the failure that otherwise leaves a spinner running forever.

Distinguishing this state from stop generation is worth explicit design. The two produce almost identical screens, a truncated answer with a control beneath it, and they mean opposite things: one is the viewer’s decision—the other is the product failing. A viewer who can’t tell them apart concludes the product is broken every time they interrupt something.

Use when

Responses stream, which is to say always.

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

Keeping partial output preserves value and leaves a truncated answer in the permanent record that later turns will read as context. Specific error messages are far more useful and leak information about infrastructure, which is why so many products fall back to a generic apology that helps nobody. Automatic retry hides transient failures and doubles the cost of a request that was going to fail anyway. Telling an error apart from a stop requires two visual states for what’s physically the same screen.

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 is the mechanism whose failure this describes. Stop generation produces a nearly identical screen for an opposite reason. Refusal is the case where nothing broke. Usage meter owns the quota version of this state. Tool call trace is where a mid-chain failure becomes visible.

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 captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.