Let's talk
ai

Twelve of fifteen questions, and a sentence saying which three are missing

A parent asked for fifteen quiz questions and got twelve, with no indication that anything was missing. Before that, the same screen had offered a dropdown with three choices — four, six or eight — and a different screen sent whatever number it liked to a server that silently clamped it to twelve.

So there were three different answers in the product to the question “how many questions can I ask for”, and none of them told the user what actually happened.

The clamp is the worst of the three, and it is worth being precise about why. A parent who asks for fifteen and receives twelve, with no message, concludes one of two wrong things: that the model could only think of twelve, or that they mistyped. Neither is true. The truth is that a limit exists in a layer they cannot see, and the system chose not to mention it.

Three ways to handle not delivering everything

When a request cannot be fully satisfied by a producer you do not control, you have exactly three options, and it is a design decision rather than an implementation detail.

Fail the whole request. Clean, honest, and it throws away everything that already succeeded. For a generation that took twenty seconds across several calls and cost real tokens, discarding eleven good questions because the twelfth call was rate-limited is an expensive kind of purity.

Return less and say nothing. Cheapest to build. It converts a known, explainable shortfall into an unexplained one, and it moves the confusion from your code into somebody’s head.

Return what you have and state the shortfall. More work, and it is the only one that leaves the user able to act.

The third is what it does now. A large request is split into several provider calls of a size the providers reliably handle, and the results accumulate. If a call in the middle of the batch fails, the response carries the questions that were produced, the honest count, and a note saying what happened. Asking for fifteen and receiving twelve now produces exactly that: twelve questions and a sentence.

Partial success is a real outcome, and it needs a name in your response shape. If your API can only say “here is the data” or “here is an error”, every partial result will be dressed up as one of the two, and it will be dressed up as the one that requires no extra work.

The same choice appeared one level down, in the parser that reads the model’s output. It used to discard the entire batch if any question in it was malformed. It now skips the unusable ones and keeps the rest, for the same reason: eleven good questions are worth more than a clean failure.

One parsing rule that is not about tidiness

While fixing the parser, a specific case turned out to matter far more than the others.

A multiple-choice question is a list of options and a numeric index saying which one is right. If the model returns a blank option somewhere in that list, the tempting fix is to drop the blank and carry on with a tidier list.

Dropping it shifts every later option down by one and leaves the index pointing at a different answer. The question now marks the wrong option as correct, and it looks entirely normal. A child answers correctly and is told she is wrong, or the reverse, and the system pays or withholds points on that basis.

So a question with a blank option is rejected outright rather than repaired. When cleaning up generated data, any operation that changes positions in a list can invalidate an index stored elsewhere, and the result is not a formatting problem — it is a silently wrong answer. Repair only what is local. Reject anything that would require you to also adjust a reference.

Writes go the other way

The generation side wants partial results. The write side wants the opposite, and I had it backwards there too.

Adding a batch of accepted questions to a quiz was implemented as a loop over a single-item endpoint, because no batch endpoint existed. Fifty questions meant fifty requests and fifty commits. A failure at question thirty leaves thirty saved and twenty not, and the screen has no way to describe that state to anyone.

That became one endpoint taking the whole batch, validating every item before adding any of them, and committing once. The test that proves it was checked against a deliberately per-item implementation first, to confirm it actually fails there — an atomicity test that passes against a non-atomic implementation is the most common useless test I write.

The asymmetry has a reason behind it. Partial generation is acceptable because the work is reproducible — ask again and you get more. Partial writes are not, because the user cannot tell which half landed and re-running may duplicate. Whenever I now decide a partial-failure policy, I ask whether the operation can be repeated safely. If it can, keep what succeeded. If it cannot, take all of it or none.

Never destroy the input on the way out

The last piece is small and it is the one users notice.

The chat screen where a parent talks to the model lost their typed message whenever a request failed. The composer clears on send, the request throws, and the text is gone — so a timeout after twenty seconds costs you the paragraph you just wrote as well as the twenty seconds.

The failed turn now stays in the conversation, marked as failed, still holding its text, with a retry and an edit. Nothing about that is clever. It required noticing that an error path was disposing of something the user created and could not get back.

One related choice worth copying: while a generation is running, the screen shows an indeterminate indicator rather than a progress bar. A progress bar is determinate by definition, and a made-up fraction reads as “nearly done” precisely when the thing has stalled. If you do not know how far along you are, do not draw a number that says you do.

Working on something like this?

We build this kind of software, and we staff the teams that do.

Get in touch