vibeapp.win

What brokeUpdated 3 October 2026

Pairing crashed with a 500 when the local model was down

The wine-pairing feature in our wine app returned a 500 whenever the local embedding model was unavailable, because the recommendation function called the embedder with no protection. The fix lets pairing fall back to the rule engine alone, flagged as degraded, with the weights renormalised.

The short version

What went wrongThe pairing recommendation function called the local embedder with no protection. When the host slept or the model was unloaded, the call raised and the user got a 500.

How we found itHenning reported an error in the app on the food-and-wine pairing. The fix commit traced it to the unprotected embedder call.

The rule nowEnrichment must never block. When it fails, degrade with degraded=True, tell the user, and renormalise the remaining weights so scores and thresholds keep their meaning.

This post documents something Claude got wrong. The claim rests on a commit or a file, not on memory. All of Claude's mistakes

Henning reported an “error in the app” on the food-and-wine pairing in our wine cellar app, which suggests a bottle from your own cellar. This time it gave an error instead.

The root cause was one line at the start of the recommendation flow. Here is what happened, how we fixed it, and the rule it broke.

What went wrong

The commit that fixed it states the cause directly: the recommendation function began with an unprotected call to the embedder, which runs on a local model in LM Studio. If the host machine had gone to sleep, or LM Studio had unloaded the model, that call raised an exception. Nothing caught it, so it went straight up to the user as a 500.

The commit message calls this a breach of the project’s first rule: enrichment must never be blocking. The local model makes pairings better. It is not supposed to decide whether you get a pairing at all. In this code path it did.

Henning writes no code himself, so the unprotected call was Claude’s. The fix commit records what went wrong, why it counted as a rule violation, and what changed. That is what this post covers.

Why a missing embedding is not fatal

The pairing is ranked by a deterministic rule engine. The embedding adds a vector for the dish. That vector is used for a cosine-similarity search over the cellar and for boosts based on similar past meals. It is useful, but the engine can rank bottles without it. So the right response to a failed embedding is a worse ranking, not an error page.

That sounds simple. Removing the vector, though, changed several things further down the pipeline, and each one needed a decision.

The fix, step by step

All of this is in one commit, dated 4 August 2026.

Catch the failure and say so. An embedding error now sets degraded=True on the response instead of raising. The response also carries a reason, and the pairing page shows a short, discreet line.

Widen the candidate pool. With a vector, the search returns the bottles most similar to the dish. Without one, a similarity cut means nothing, because there is nothing to sort on. The degraded path fetches the whole filtered cellar instead. It also drops the requirement that a bottle must have a flavour profile. As the commit message puts it, without cosine a bottle with no embedding is just as usable for the rule engine. The user’s filters now live in one shared helper, so the normal path and the degraded path can’t drift apart.

Renormalise the weights. This is the part that is easy to miss. The final score mixes several components, and one of them depends on the vector. If you simply drop that component, every score falls by about 25 percent, according to the commit message. That would break the thresholds the interface uses to decide whether a pick is a good match or a compromise from the cellar. In degraded mode the remaining weights, for the rule engine and the drinking window, are rescaled so they add up to 1, and scores stay on the same scale.

Skip the history boosts, don’t guess them. The boosts from earlier meals measure cosine similarity to dishes you’ve had before. With no vector there is nothing to measure. The commit sets them to neutral and explains why: neutral is the correct value here, not a guess.

The same commit, a second change

The same commit added something else: a language layer in which Claude, through the Agent SDK, rewrites the pairing’s explanation so it reads like a sommelier talking. It is relevant here because it follows the same rule.

The LLM sees the wine that has already been chosen, with the alternatives only as context. It can return four text fields and nothing else. The ranking stays with the deterministic engine. The project documentation calls this the safety model: a bad LLM answer can at worst produce a clumsy sentence, never the wrong bottle. Two guards back this up. An answer is thrown away if the prose mentions an alternative but not the top pick. The “ideal wine” field is only filled in if the engine already set it, because the frontend uses that field to label a pick as a compromise. If the LLM could add or remove it, that would be a ranking change disguised as wording.

The layer is a chain, and each link has its own error handling: Claude first, then the local model in LM Studio, then the original deterministic text. An empty quota or an expired token falls through to the local model, not to an error message. It runs as a background job, so the pairing request returns as fast as before. The page then polls for the result and swaps in the new text when it arrives. If the job fails, the rule-based text stays. The same dish with the same top pick is served from a content cache without a new LLM call.

It runs in the background because a project code rule forbids synchronous LLM calls in a request handler. A live test with a real payload took 15.4 seconds with Opus (about 19k prompt tokens) and 15.9 seconds with the local Qwen model.

How it was checked

According to the commit message, 392 backend tests passed, 32 of them new. Linting and type checks were clean on the changed files, and the frontend type check and build were clean. The commit message also says the change was not yet deployed when it was written.

What we took from it

The bug was not a hard one. The local model is a dependency whose host can sleep and whose model can be unloaded, and one call to it had no guard. What took the real work was everything after the try/except. Dropping the vector changed the candidate pool, the score scale and the history signal. According to the commit message, without renormalising the weights all scores would fall about 25 percent and the compromise thresholds in the interface would be wrong. That kind of quiet error is worse than a 500.

The rule

Enrichment must never block. When an enriching service such as the local embedding model fails, the feature degrades instead of failing: it marks the result degraded=True, tells the user, and keeps working on the rules alone. Any score that loses a component has its weights renormalised, so the numbers and the thresholds built on them still mean the same thing.

Corrections

  1. Attribution corrected: an earlier version said the material did not show who wrote the code and did not put the mistake on Claude. Henning writes no code himself; the code was written by Claude.