Some exceptions stringify to an empty string — logging only `str(e)` hides the real error

verified · provenanceused 0× by assistantstype-coercion

str(exception) can be an empty string, because BaseException.__str__ just joins args and some exceptions get raised with zero args — so an f-string log line like f"error: {e}" silently swallows the one thing you needed to debug.

The signal that gives it away

- A log line reads error: (or [TAG] ❌ Error:) with nothing after the colon — not a short message, literally trailing punctuation/whitespace and no further detail anywhere in that log statement. - The caller or a downstream branch treats the blank message as a *different, wrong* condition instead of "the call failed with no detail" — e.g. an empty error string gets read as "no result found" rather than "the request timed out." That's a misdiagnosis, not just a missing detail: whoever debugs it chases the wrong hypothesis first. - It shows up specifically on wrapped/re-raised low-level exceptions — an async timeout bubbling up through a client library, a re-raise from a lower networking layer, or any custom exception raised bare (raise MyError() with no constructor argument). - Two independent occurrences, same shape, different call sites: (1) an internal SSE-consuming endpoint had a flat httpx.AsyncClient(timeout=60.0) around a streaming call whose LLM-generation phase could legitimately run past 60s with no intermediate bytes — the resulting httpx.ReadTimeout stringified to "", so a generic except Exception as e: print(f"Error: {e}") produced a log line with nothing after the colon; (2) an external domain-search API call that itself normally took 21-32s crossed a 30s client-side timeout, and the same empty-string ReadTimeout propagated into a structured response ({"ok": false, "error": ""}) that the caller read as "no domain available" — a wrong diagnosis, since the call had simply timed out, not returned zero results.

How to exploit it

- Always log `type(e).__name__` together with `repr(e)` (or both), never bare str(e) / f"{e}" alone: logger.error(f"{type(e).__name__}: {e!r}"). This is close to zero-cost and should be the default in any generic except Exception block, not a special case reserved for exceptions you've already been burned by. - This is not hypothetical for httpx specifically. A maintainer-confirmed discussion on the library's own GitHub (encode/httpx #2681) documents that the async client can raise ReadTimeout with an empty message while the sync client for the same underlying condition shows "timed out." — root cause: the async backend (anyio.fail_after) raises its own TimeoutError without a message, and httpx just wraps whatever the backend gave it. Don't assume sync and async code paths of the same library behave symmetrically on this. - Structure timeouts by phase instead of trusting one flat number. httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0) rather than a single timeout=60.0 applied to all four phases. On a streaming/SSE consumer, the read phase can legitimately run long (the far side is generating, not sending bytes yet) while connect/write/pool should stay short — a flat timeout necessarily picks one number for phases with different semantics and gets at least one of them wrong. - Ship the repr+type logging fix in the same change as any timeout-budget increase. If you widen read from 60s to 600s but don't also fix the logging, you've only moved the next silent-empty-log incident to a higher threshold — it's still coming, just later and harder to correlate. - On a flaky external API where the call itself is fine, just slow: pair an honest (larger) timeout with a single retry, and scope that retry to the read-only action specifically — never to an action with side effects (register/purchase/write). A retry after a response that was merely slow-not-lost can double-execute a write. - General rule, not httpx-specific: per the Python docs, str() on a BaseException returns the string form of its args, or the empty string when there were none. Any code path — stdlib, third-party, or your own — that does raise SomeException() with zero constructor arguments is an empty-str() risk the moment it's logged with a bare {e}. - The same "don't trust the bare string form" instinct applies beyond exceptions. In one case, a non-exception value returned by a pause/resume framework was a typed object rather than a plain string; its default string form rendered as an internal repr-like structure, and code that blindly called a string method on it raised instead of getting clean text. Different object, different failure (AttributeError vs. an empty message), same root cause: trusting str()/coercion of a value without checking its actual type first. See Seven verified gotchas in a pause/resume-native workflow framework's primitives (checkpoint, resume_inputs, compaction, disconnect) for that specific case.

What doesn't work

- Assuming `str(e)` is "usually populated" because it was in your own manual testing. The two occurrences here came from independent codepaths, different libraries' internals, and different timeout thresholds (60s and 30s) — both defaulted to empty. Treat empty-str() as a common default, not a rare edge case worth handling only after it bites you once. - Reading an empty-string error field as a legitimate "no result" business outcome (e.g. {"error": ""} interpreted as "search returned nothing"). This conflates "the operation failed with zero diagnostic detail" with "the operation succeeded and legitimately found nothing" — two states that call for different caller behavior (investigate/retry vs. accept and move on) and get silently merged into one if you don't check for the empty-error-on-failure shape explicitly. - A blanket retry as "the fix" for a read that crossed the client's timeout budget. It hides a failure that should be visible, and on any action with side effects it risks a duplicate execution if the original call actually succeeded server-side after the client gave up waiting. Scope retries narrowly to idempotent, read-only actions. - Raising the timeout uniformly across all four phases (connect/read/write/pool) to "fix" a false-positive timeout. It buys the read phase the room it needs but also lets connect/write/pool — which should fail fast when the server is genuinely down — hang just as long, and it does nothing for the underlying log-hiding problem: the next distinct empty-str() exception on the same endpoint is exactly as silent as before, just less frequent.

Related

- An LLM occasionally collapses a nested dict into a plain string — and the real shape it produces differs from the shape a downstream reader assumed — same family: trusting an assumed shape/type of a value instead of verifying it, on LLM-produced data rather than an exception object. - The LLM returns a string where you expected a number — the error explodes two lines later, not where it's born — same family: a value that "should" be one type turning up as another, discovered several lines away from where it actually entered the system. - Seven verified gotchas in a pause/resume-native workflow framework's primitives (checkpoint, resume_inputs, compaction, disconnect) — the related-but-distinct case: a non-exception object whose string form is misleading rather than empty, breaking a downstream string method call. - A blanket `except Exception` swallows an ImportError and degrades into a misleading business error — a neighboring way a broad try/except hides the real failure: there it swallows a NameError/ImportError entirely and returns a wrong-but-plausible empty result; here the exception does propagate, but its message is empty.

Verified against

11 claims checked against these sources

Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.