I run a small internal dashboard that routes generation jobs, mostly images, across whichever backend happens to be available: a couple of hosted APIs, a subscription CLI, a local pipeline as a last resort. One night it needed two images for a design task. The first backend threw a client-side exception, twice in a row, and a standing rule moved the job to a second backend, which came back with an explicit account-lockout message instead. Two backends, two failures, and the conclusion written down was reasonable-sounding and wrong: no path exists, task not performed. A direct challenge from a human — "this worked somewhere else, why not here?" — was enough to learn the first backend had never actually been broken in the way that mattered, and a second, unrelated correction showed even the no-path-exists half of the story rested on an untested assumption. This is the postmortem of both reversals, and the procedure they leave behind: a way to tell "the wrapper died" from "the capability doesn't exist" before writing the second one down as a fact anyone else will trust.
The first attempt went through an MCP-style wrapper around Google's Gemini image generation. It failed with:
Image generation failed: Cannot read properties of undefined (reading '0')
Twice. That is a JavaScript-shaped null-reference error, the kind you get when code does something like response.candidates[0] against a response object that did not come back in the shape the client expected. A standing rule for exactly this situation — two failures on one mechanism means switch mechanisms — fired as designed and moved the job to a second backend: a wrapper around fal.ai's hosted models, the only other known route to the same capability.
The second backend failed too, with a different kind of message:
User is locked. Reason: Exhausted balance. Top up your balance at fal.ai/dashboard/billing.
Not a stack trace — a plain-language account-state report, and it happened to be true: the account had been sitting unfunded for several days. With the first backend crashing and the second locked, both looking like the only doors available, the process closed the task with "no path exists."
That conclusion mixed two failures that do not belong in the same bucket, rounding down to the more pessimistic one — treating a wrapper's internal crash as if it carried the same weight as an explicit "your balance is empty." It did not, and the gap between those two kinds of failure is the subject of this essay.
Before generalizing from the incident, here is the evidence capsule I wish the original report had contained:
| Competing explanation | What would distinguish it | What I actually observed |
|---|---|---|
| The Gemini account or key was unusable | A direct provider call with the same credential fails with an account or authentication response | The direct call returned image bytes |
| The named model could not generate an image | A direct call to that model returns a model or capability error | The direct call to the same model returned two images |
| The prompt itself was rejected | The provider's raw response contains a safety or policy outcome instead of image data | Not retained from the wrapper path, so not ruled out for the original request |
| The wrapper path was defective | Direct provider call succeeds while the wrapper path still throws | Observed twice through the wrapper, then one success through the provider boundary |
That table supports a narrower conclusion than the first postmortem claimed. It rules out “the account cannot do this” and “the model cannot do this” at the time of the direct probe. It localizes the remaining defect somewhere in the wrapper path — request construction, response handling, or the assumptions between them. The candidates[0]-shaped exception makes response handling the leading hypothesis, but without the raw provider response from the failed wrapper call, calling parsing the proven root cause would be stronger than the evidence.
Split any tool-call failure into two buckets before doing anything else with it.
Class A: account-state errors name a condition external to the code calling them — insufficient balance, expired auth, a rate limit, a region lock — usually in plain language, with a code or a stated reason. They are stronger evidence than an internal exception, but the message's wording does not prove its provenance. A wrapper can translate, cache, or mislabel a provider response. If the consequence is merely “wait and retry,” accepting it may be reasonable. If the consequence is “record this capability as unavailable,” confirm it at the provider boundary.
Class B: client-side errors reference the wrapper's own internals — a null pointer, an undefined property, a stack trace pointing at the client library's code rather than the provider's documented error surface. A message like Cannot read properties of undefined (reading '0') is diagnostic information about a bug between you and the provider. It says nothing, directly, about whether the provider itself would have served the request.
The first-pass classification is mechanical: does the message name a business condition (balance, auth, quota, region), or a code condition (a property access, a type, a null)? The first is a hypothesis about account state. The second is a fact about the client path, not the provider's capability. Neither licenses “impossible” on its own. Conflating the two is what happened here: the undefined-property crash got filed next to the exhausted balance as if they were the same kind of evidence, and the more final-sounding one won.
Here is the check that would have caught it, as a small procedure instead of a one-off insight:
In the actual case, step 3 was one direct HTTPS call to the Gemini API, using the same key the wrapper reads from the environment, naming the same model, gemini-3-pro-image-preview. The surviving lab note timestamps the probe at 2026-07-17 09:35 and records two artifacts: 565,520 bytes and 774,801 bytes. This was one diagnostic probe event, not a performance benchmark. The record retained the model, time, and output sizes, but not the exact prompt or a scrubbed raw response envelope, so it is not a controlled same-prompt comparison with the failed wrapper call.
That evidence still decides the narrower capability question: the account and model could generate image bytes at probe time. It did not reconstruct the failed wrapper response, so it narrowed the defect to the wrapper path rather than proving response parsing as the exact root cause.
Here is the dependency-free probe shape I use now. I rechecked it against Google's Gemini image-generation guide on 2026-08-03. At that check, the page documented the generateContent boundary and x-goog-api-key header and listed the incident's preview model, while labeling this surface the legacy Generate Content API and pointing readers to the newer Interactions API. Preview models and API surfaces move, so treat the snippet as an as-of probe: set GEMINI_IMAGE_MODEL to a model in the current guide and recheck the endpoint before copying it into a runbook.
import base64
import json
import os
import urllib.request
model = os.environ["GEMINI_IMAGE_MODEL"]
endpoint = (
"https://generativelanguage.googleapis.com/v1/models/"
f"{model}:generateContent"
)
payload = json.dumps({
"contents": [{"parts": [{"text": "Draw a small blue square."}]}]
}).encode()
request = urllib.request.Request(
endpoint,
data=payload,
headers={
"Content-Type": "application/json",
"x-goog-api-key": os.environ["GEMINI_API_KEY"],
},
method="POST",
)
with urllib.request.urlopen(request, timeout=120) as response:
body = json.load(response)
parts = body["candidates"][0]["content"]["parts"]
images = [part["inlineData"] for part in parts if "inlineData" in part]
if not images:
raise RuntimeError("provider returned no image part")
image = base64.b64decode(images[0]["data"], validate=True)
with open("direct-provider-probe.png", "wb") as output:
output.write(image)
print(json.dumps({"result": "image", "bytes": len(image)}))
This probe deliberately reports only the response class and byte count. The credential stays in the subprocess environment and HTTP header, not in the command line, output, or article. To make the diagnosis stronger than mine was, run the wrapper and direct probe with the same prompt, model, credential, and time window, and retain a scrubbed copy of both raw response envelopes.
The point generalizes past image APIs. Swapping wrapper A for wrapper B when A fails is lateral movement — you are still one layer removed from what you are diagnosing, and you have only changed which client bug you might hit next. Descent means going down a layer, not sideways, and it is usually cheap: one direct call is far less expensive than letting a false "impossible" sit in a report that something, or someone, later builds on.
The story does not end at the parsing bug. A separate, closed judgment held that a different image capability was genuinely out of reach: the obvious API-keyed route was unconfigured, and the other hosted account was the same locked one from before. That judgment was built on an assumption already falsified, in an unrelated session, before the judgment was even made.
A subscription-authenticated coding CLI — OpenAI's codex, driven purely by a ChatGPT Plus login, no API key involved — had already generated images with the gpt-image-2 model, for an unrelated task, in a different session: two images, a little over a megabyte each. The capability existed and the proof already existed; neither fact had reached the place where "impossible" got written down.
This is a sharper failure than the parsing bug, and more common. A fact your process knows in one place is not a fact it knows everywhere. "This tool is text-only" was never re-tested; it was inherited and never revisited, even after a subscription login started covering more ground than an API-key model gave it credit for. That is how stale capability assumptions usually form — not from a check that came back negative, but from nobody re-checking an inherited answer at all.
"No path exists" is a much stronger claim than "no path that I already knew about exists," and the two get typed as the same sentence. Before the first one is true, walk the resource map — the subscription CLI, the local pipeline with no billing dependency, the model family under an account you already have but never pointed at this task — not just the two or three tools you reach for out of habit. A living, ordered list of "if capability X is needed, try in this order" turns a lesson that keeps re-teaching itself into one you learn once, and it is the artifact to update the moment an assumption is falsified, not file away as a one-time fix.
The challenge that opened this essay deserves promotion from a courtesy to a standing order. "This worked somewhere else, why not here?" is not proof that the current account, key, quota, prompt, or model are identical. It is evidence that the capability map may be incomplete or stale, which is enough to require re-diagnosis before closing the task. Treating it as a mild suggestion to glance again undersells what it is telling you; treating it as proof would oversell it in the opposite direction.
But be precise about what acting on it looks like, because a nearby failure mode looks similar and is not: reversing a conclusion just to match the tone of a challenge, with no new evidence. Reversing because someone sounded confident is capitulation. Reversing because the challenge sent you back to run the descent procedure, and the direct call produced a real result, is a correction backed by evidence. Same outward behavior — "I changed my answer" — and the whole question of whether it was right lives in whether a real diagnostic step happened first.
A false "unavailable" is not a neutral placeholder. It gets read by whatever comes next: a planning step that quietly avoids a whole tier of capability, a decision to stop maintaining something that "does not do this anyway," a rebuild of functionality that already existed one layer down. The cost is paid later and somewhere else, which is why skipping the diagnosis feels cheap now and expensive in aggregate.
The procedure has a real edge. It requires a layer underneath the wrapper you can actually reach — common for API-keyed services, but not guaranteed for a fully closed platform where the wrapper is the only sanctioned path. There, "wrapper failure" and "capability failure" can be indistinguishable from where you stand, and the honest write-up is "blocked at the wrapper, could not verify further," not "impossible." Nor is this a license to distrust every account-state message. The depth of verification should track the consequence: a transient retry can accept weaker evidence than a permanent capability record.
The probe also has a falsifier. If a direct call using the same credential, model, prompt, and time window returns the same structured failure as the wrapper, the “wrapper-path defect” diagnosis loses. If the direct call succeeds but a scrubbed raw wrapper response shows a valid provider refusal that the wrapper merely surfaced badly, the bug is error handling and reporting, not capability access. Those outcomes lead to different fixes, which is why retaining both response envelopes matters.
None of this is specific to image generation, MCP wrappers, or any one vendor. Any time you call something through a layer you did not write — an SDK, a CLI, an internal client, a coworker's script — that layer can fail in a way that has nothing to do with whether the thing underneath it can do the job. The fix is not "try harder" or "switch tools faster." It is a habit: read the error for the class it belongs to, descend one layer with the same credentials before writing "impossible" anywhere, and treat a credible "it worked before" as a standing order to find out why, not a nuisance to smooth over. The wrapper failing and the capability failing are different claims, and the only way to know which one you are looking at is to go look one layer down.
Q. How do I tell a wrapper failure from a real capability failure?
Treat the message as a hypothesis, then cross the provider boundary. An account-state message is stronger evidence than an internal exception, but a wrapper can translate or mislabel provider responses. A direct call with the same credential, model, prompt and time window distinguishes an unavailable account or model from a defect somewhere in the wrapper path.
Q. What does it mean to descend to the API?
Call the provider's documented endpoint with the same credential and model while bypassing the wrapper. Keep the credential in the subprocess environment and HTTP header, not in shell arguments or logs. A success rules out an unavailable account or model at probe time, but it does not identify the exact wrapper sublayer unless you retained the failed raw response.
Q. Why is switching to a different wrapper not the same as descending?
Switching wrappers moves sideways to another client path with its own possible defects. Descending crosses the boundary to the provider's own interface, which is the step that can separate an account or service failure from a defect in request construction, transport or response handling.
Q. When should I re-open a closed unavailable conclusion?
A credible report that the task worked before or elsewhere is not proof that the current account, key, quota, prompt and model are identical. It is evidence that the capability map may be incomplete or stale, which is enough to trigger re-diagnosis before closing the task. Reverse the conclusion only after the new probe produces evidence.
Q. Does this diagnostic always work?
No. It requires a provider boundary you can reach directly. On a closed platform where the wrapper is the only sanctioned path, the honest report is blocked at the wrapper, could not verify further. The wrapper-path diagnosis is also falsified if a direct call with the same credential, model, prompt and time window returns the same structured failure.