AI-Driven Development

429 is not an error, it is a schedule

Transient and terminal failures need opposite responses and look identical. How to tell them apart, and why partial progress must be persisted before it is believed.

MobileByteSensei 8 min read
A sweep that succeeds, then returns nothing, with the earlier results still on disk
aitooling

A generator produced four artifacts in a row. The fifth returned nothing.

The interesting question is not why the fifth failed. It is what the correct response is, and that depends entirely on a distinction the failure itself does not make.

The case this post is about

One generation sweep, twelve assets, against a limit of five per minute:

asset-01: persisted
...
asset-05: persisted
asset-06: rate limited, server says wait 47s (attempt 1)
asset-06: persisted
...
asset-11: 401 Unauthorized; stopping with 10 persisted

The version this replaced treated the 429 at asset 6 as a failure. It aborted, and because results were collected in memory and written at the end, it had written nothing — five successful generations discarded along with the retry-after header that said exactly how long to wait.

This post follows that one sweep, because the limit cost 47 seconds and treating it as an error cost five generations and a second full run. The 401 at asset 11 is a real failure, and the difference between the two is the whole subject.

The symptom

A sweep works, and then it does not. The same command, the same arguments, the same inputs — success several times over, then an empty result.

At that moment there are two completely different worlds you might be in:

  • transient — a limit, a quota, a session that expired. Come back later and it works.
  • terminal — the request is wrong. Come back later and it fails identically.

The responses are opposites. For transient, waiting is the fix and changing the request is counterproductive — you will tune a request that was already correct. For terminal, waiting is the worst possible response, because you burn time confirming something that was knowable immediately.

And the failure usually will not tell you which. An empty result is an empty result.

Why the obvious fix failed

The obvious fix is to retry with backoff. If it is transient, the retry succeeds; if it is terminal, you have lost a few seconds.

You have lost more than that. A retry loop against a terminal failure spends the full attempt cost each time — and when the attempt is an expensive generation rather than a cheap HTTP call, three retries is three full prices for a request that could never have worked.

The second attempt is to read the status code. Genuinely useful when there is one, and much of what an agent calls does not return one. A generator that comes back with an empty result set has told you nothing about whether to wait.

The third is to look at the error message, which is the same problem with more words.

The actual mechanism

Run a discriminating request — something cheap that isolates the variable.

When my fifth generation returned nothing, the two hypotheses were the quota is exhausted and this particular brief is bad. Both perfectly consistent with the evidence.

The test was one command: generate a different asset, with a different brief, against the same account.

brief A (retried)   → empty
brief B (different) → artifact produced

The one request that separates the two hypotheses

It worked. So the account was fine, the quota was fine, and the problem was brief A — terminal, not transient. No amount of waiting would have fixed it, and I would have waited.

The discriminator took one call and converted a guess into a fact. That is the entire technique: before choosing between wait and change, spend one cheap request that only one of the hypotheses survives.

The fix

The second half matters more than the first, and it is about what happens to the nine that worked.

Persist each success before attempting the next. Not at the end of the batch, not when the loop completes — immediately, as the very next operation after the result arrives.

That ordering is the whole protection. A sweep that collects results in memory and writes at the end loses everything when the eleventh call fails. A sweep that persists per item loses nothing: the failure is a stopping point rather than a rollback.

Here it means every generated asset writes its file and its provenance record before the next generation starts. When the credential expired mid-session, the assets already produced were on disk with their records intact. The run stopped; it did not unwind.

Both halves — honouring the limit and persisting per item — are one loop, and the retry has to read the header rather than guess:

type Limited = { ok: false; retryAfterMs: number } | { ok: true; asset: Uint8Array };

function readLimit(res: Response): number | null {
  // The server TOLD you when to come back. Two spellings, both common.
  const retryAfter = res.headers.get("retry-after");
  if (retryAfter) {
    const secs = Number(retryAfter);
    return Number.isFinite(secs) ? secs * 1000 : new Date(retryAfter).getTime() - Date.now();
  }
  const reset = res.headers.get("x-ratelimit-reset");
  return reset ? Math.max(0, Number(reset) * 1000 - Date.now()) : null;
}

async function sweep(items: string[], generate: (id: string) => Promise<Response>) {
  const done: string[] = [];
  for (const id of items) {
    let attempt = 0;
    while (true) {
      const res = await generate(id);
      if (res.ok) {
        // PERSIST FIRST. Not after the loop — the next call may be the one that fails.
        await Deno.writeFile(`out/${id}.bin`, new Uint8Array(await res.arrayBuffer()));
        await Deno.writeTextFile(`out/${id}.gen.yaml`, `id: ${id}\nattempts: ${attempt + 1}\n`);
        done.push(id);
        break;
      }
      const waitMs = res.status === 429 ? readLimit(res) : null;
      if (waitMs === null) {
        // A real failure. Stop — do NOT unwind what already succeeded.
        console.error(`${id}: ${res.status} ${res.statusText}; stopping with ${done.length} persisted`);
        return done;
      }
      attempt++;
      console.log(`${id}: rate limited, server says wait ${Math.round(waitMs / 1000)}s (attempt ${attempt})`);
      await new Promise((r) => setTimeout(r, waitMs));
    }
  }
  return done;
}

The run that motivated this, twelve assets against a limit of five per minute:

asset-01: persisted
...
asset-05: persisted
asset-06: rate limited, server says wait 47s (attempt 1)
asset-06: persisted
...
asset-10: persisted
asset-11: 401 Unauthorized; stopping with 10 persisted

Ten files and ten provenance records on disk, and a credential that expired mid-run. The version this replaced treated the 429 at asset 6 as a failure, aborted, and had written nothing — five successful generations discarded along with the retry-after header that said exactly how long to wait. The limit cost 47 seconds; treating it as an error cost five generations and a second full run.

How we proved it

The clearest case was not a rate limit at all, which is what convinced me the distinction is about transience rather than about limits specifically.

Mid-session, a vault session expired. Every generation after that point failed identically:

StitchKeyUnavailable: vault resolve failed

Textbook transient — an expired session is exactly the "come back later" class, except that coming back later would not have helped either, because the session needed a human to re-establish it. So it was transient in mechanism and terminal in practice, and the only correct response was to stop and say so.

Work persisted as it completes, against work held until the end

What made that recoverable was the persistence discipline. The assets generated before the expiry were complete, recorded, and usable. Six articles sat unillustrated until the vault was unlocked, and then the work resumed from exactly where it stopped — because nothing was being held in memory waiting for a batch to finish.

The honest limitation: my discriminator works because the generations are independent. If brief A had failed for a reason that also poisoned the account — a malformed request that tripped something stateful — then brief B succeeding would have told me nothing useful about whether to retry A. I do not have a general way to check for that, and I have been lucky rather than careful.

The third category

Two categories was too few, and the case that broke it is worth naming.

Some failures are transient but not self-resolving: the mechanism is temporary, and it will not clear on its own. An expired credential is the clean example. A lock held by a crashed process is another. Waiting is the natural response to transience and it is exactly wrong here, because nothing in the system is working toward resolution.

The signal is whether anything is acting to clear the condition. A rate limit clears because time passes. An expired session clears because a person does something. If the answer to "what will make this different in ten minutes" is "nothing", it is not a wait, whatever it looks like.

Three responses, separated by what would do the clearing

So the actual taxonomy is three: wait, change the request, or escalate to a human — and the third looks like the first until you ask what would do the clearing.

Why agents get this wrong more than people do

A person hitting an unexplained empty result usually stops and thinks. An agent's default is to try again, and that default is correct often enough to be dangerous.

The asymmetry is in what each has cheaply available. A person carries context about the last hour — that this brief was unusual, that the previous four were ordinary, that nothing about the account changed. An agent re-derives that from whatever is in front of it, and the most available fact is "the call failed", which points at retry.

So the discriminating request is doing something specific: it manufactures the context the agent does not otherwise have. One extra call converts an ambiguous history into a fact present in the current window.

That reframing also tells you when to skip it. If the four preceding successes are in the context — visible, recent, on the same account — you already have the discriminator for free, and the fifth failure is terminal without spending anything. It was only worth a call because I had lost track of whether the account had changed in between.

What this generalizes to

When something that was working stops, resist both instincts. Do not retry blindly and do not start editing the request. Spend one cheap call that only one hypothesis survives, and let it decide which of the two opposite responses is correct.

Then make sure that decision is cheap to be wrong about, by persisting every success at the moment it happens. A failure should cost you the failed item and nothing else — and if it costs you the batch, that was a design choice you made earlier, not something the failure did to you.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS