Blog · August 26, 2026

Stop wrapping AI errors in “something went wrong.”

The moment your AI feature fails in production, the error message decides whether the failure costs the user a minute or costs you a support thread and a one-star review. Most apps handle every failure identically: catch the exception, throw it away, show “Something went wrong. Please try again.” That toast reads polished. It's also the most expensive line of code in the feature.

Six different failures, one identical toast

Call a hosted model long enough and you'll meet at least six distinct failures. A rate limit (HTTP 429) fixes itself in seconds. An exhausted daily quota fixes itself at midnight. A billing or permission problem (403) is yours alone to fix, and no amount of user retrying will touch it. A revoked or mistyped API key (400) — same. An overloaded model (503) is the provider's problem; the fix is falling back to another model, not hammering the same one. And a safety filter isn't an HTTP error at all: the request succeeds and the response politely contains nothing usable — only changing the input fixes that.

Six failures, four different owners, four different remedies. Collapse them into one generic toast and you've just told the user to retry in five situations where retrying cannot possibly work.

The wrapper deletes the diagnosis

The instinct behind the generic message is understandable. Raw provider errors are ugly: JSON envelopes, ALL_CAPS status strings, phrases like RESOURCE_EXHAUSTED that sound like the server is dying. Polish says hide them. But polish is optimizing for how the failure looks instead of what happens next — and what happens next is that a user emails “the AI isn't working” and you have literally nothing to work with. No status code, no provider message, no way to tell a Tuesday-night rate limit from a card that expired last week.

The worst version of this is the billing lapse. Your payment method fails quietly, every AI call starts returning a permission error, and your beautiful generic toast tells each user to “try again.” They do. Five times. Then they conclude the app is broken and say so in a review. The actual fix was a thirty-second card update on your side — which you discover a week later, because the one message that named the problem was the one your catch block discarded.

Classify before you catch

The load-bearing decision happens before any message is shown: is this failure retryable or terminal? Rate limits, 5xx responses, and timeouts are retryable — back off and try again, then fall back to another model. Bad keys and permission errors are terminal — retrying burns latency and goodwill on a failure with a fixed outcome, so fail immediately and say why.

Then there's the third category that catches almost everyone: the empty success. Newer Gemini models spend output tokens on internal reasoning before writing a visible answer; call one without an explicit thinking budget and it can burn the entire output allowance on thoughts, returning 200 OK and an empty candidate. We shipped that bug. No exception fires, nothing logs, the feature just silently produces nothing — an afternoon of debugging that ended in a one-line config fix. Treat an empty or unparseable response as a failure with its own named error, not as success with a blank string.

Fall back before you fail

Most retryable failures shouldn't surface at all. Keep an ordered list of models — newest first, older ones as fallbacks — and when the primary returns a rate limit or an outage, walk down the list before reporting anything. In ShotCanvas, every AI feature imports the same list from one file, so a rate-limited primary rolls over to a fallback within the same request and the user never knows. The majority of would-be “AI is down” moments end this way: invisibly.

The one-file part matters more than it sounds. Sprinkle model names across services and their fallback behavior drifts apart — one feature survives an outage while another faceplants, and upgrading to a new model release means a scavenger hunt. One list, every service reads it, element zero is the newest model. Boring, and exactly why it works.

When you do show an error, show the real one

After the retries and fallbacks are spent, you've earned the right to show an error — so show the actual one. The pattern we settled on: as each attempt fails, record the provider's message verbatim; when the whole chain is exhausted, surface that last real message in the UI under a plain-English headline. “Couldn't generate your description — Gemini says: RESOURCE_EXHAUSTED: quota exceeded” tells the user it's temporary, tells the screenshot they send you everything, and tells you whether tonight's problem is quota, billing, or a key you rotated and forgot.

Two cautions. First, scrub secrets: some SDKs put the API key in the request URL, and an exception's string form can happily include it — never echo a raw URL into a toast or a log. Second, “verbatim” constrains the detail line, not the whole design. A friendly headline with the provider's words beneath it is the goal; paraphrasing the provider's message into mush is just the generic toast with extra steps.

The whole pattern in five lines: classify failures as retryable or terminal before showing anything. Retry with backoff only what can actually succeed. Fall back down a model list that lives in exactly one file. Treat empty responses as failures with names. And when an error finally reaches the user, include the provider's words — minus your secrets.

Errors are a feature surface

Every AI feature in ShotCanvas — headline writing, description generation, listing translation, screenshot analysis — runs through this exact pipeline, because we got tired of debugging “it doesn't work” with no evidence. It's not glamorous engineering. But the difference between an AI feature people trust and one they abandon is rarely the model — it's whether the app behaves honestly on the model's worst day.

Try the AI listing generator Read: our API key leak post-mortem