ADR-018: No third-party text in the error envelope#
Status#
Accepted
Context#
Third-party exception strings were reaching both the HTTP response body and
the logs from three sites: litellm provider exceptions
(LiteLLMClient._complete_once), pydantic ValidationError on a malformed
LLM response, and SQLAlchemy connectivity errors in
SqlAlchemyUsageRepository. All three call str(exc) and either return it
in a response message or log it verbatim.
That text is provider-controlled and unbounded. litellm embeds the
api_base/deployment URL, model name and Azure content-filter verdicts;
pydantic v2 ValidationError embeds input_value — the model’s raw
response text, which docs/operations/observability.md lists as a hard
prohibition; SQLAlchemy connectivity errors routinely embed the DSN,
password included when it is in the URL.
Two complications rule out a narrow fix:
Sanitizing the message is not sufficient. Every translation site raises
... from exc, so the third-party exception stays attached as__cause__. Any handler that logs withexc_info=Trueorlogger.exceptionemits the chained traceback, which carries the original text regardless of what the message says.DEBUG-gating is not protective.
LOG_LOGLEVELdefaults toDEBUGfor application packages, so “log the detail at DEBUG” ships on by default.
Removing the free text also removes real signal: no handler was registered
for LLMRateLimitError, LLMBadRequestError or
LLMContentPolicyViolationError — all three collapsed into a generic 502,
so the provider string was the only way a caller could distinguish “the
content filter rejected this” from “the provider is down.”
Decision#
Error signal derives from the exception type, never from third-party text. A third-party exception string may be read exactly once, at the adapter boundary, to choose a domain error class. It is never stored on the error, returned to a caller, or logged verbatim.
Response bodies and logs get asymmetric treatment. No provider text crosses the trust boundary into a response, ever — response messages for provider-derived errors are fixed, hand-written constants. Logs keep diagnostic detail (exception type, provider status code, model), because ops needs it to triage a 502.
The log sink inherits the corpus’s data classification.
raise ... from excis kept for debuggability, which means a traceback emitted viaexc_info=True/logger.exceptionmay still contain provider-controlled text through__cause__. That is accepted deliberately: log output is in scope for data classification and must not be exported to third-party log analytics without review.A handler may echo
str(exc)only when the message was authored in this repo.AnalysisError/AnalysisTimeoutErrormessages are all literal constants (or a literal plus a formatted float) raised fromqfa.services, so_handle_analysis_errorechoingstr(exc)stays safe. Provider-derived classes (LLMErrorand subclasses,PromptInjectionDetectedError,UsageRepositoryUnavailableError) never get this treatment — even their in-repo domain-error message is not echoed by the corresponding handler, so a diagnostic string kept for the logs (e.g. the injection pattern name) can’t leak into a response by a future edit.Classified scalars replace free text where callers need signal.
LLMErrorcarriesprovider_status: int | None;LLMRateLimitErroradditionally carriesretry_after: int | None, read only from the provider’sRetry-Afterresponse header — never from exception text.PromptInjectionDetectedError(AnalysisError)gives prompt-injection detection its own type instead of a substring match on the message.The Azure content-filter sniff is the one sanctioned read of a provider string.
"filtered" in msg and "content management policy" in msginspects the litellmBadRequestErrormessage to choose betweenLLMContentPolicyViolationErrorandLLMBadRequestError— but the string itself is never propagated into either error.
Rejected options#
Replacing the Azure content-filter sniff with structural detection (litellm classification or
innererror.code) — couples the adapter to provider internals for a bigger test surface, for a problem the string sniff already solves; out of scope for this change.Denylist/regex scrubbing of provider strings, in either sink — this is pattern-filtering of untrusted, unbounded, provider-controlled text and fails open on anything unanticipated.
A “verbose errors” opt-in setting that echoes provider detail behind a flag — a switch that can be, and eventually will be, left on in production.
Severing the
from excchain — the debuggability loss is not worth the containment, and it breaks the exception-chaining idiom used elsewhere in the codebase.
Consequences#
New HTTP mapping:
LLMContentPolicyViolationError→ 422content_policy_violation;LLMRateLimitError→ 429llm_rate_limited(withRetry-After);LLMTimeoutError→ 504llm_timeout;LLMBadRequestErrorandLLMError→ 502llm_error;PromptInjectionDetectedError→ 422prompt_injection_detected. Seedocs/architecture/04-crosscutting.mdfor the authoritative table._handle_analysis_error’s"injection" in str(exc).lower()routing is deleted — it was also a latent bug, since rewording the injection message would have silently turned a 422 into a 502.No new setting or environment variable:
RATE_LIMIT_RETRY_AFTER_FALLBACK_SECONDSis a module constant inqfa.api.app, not a field onqfa.settings.No response body field other than
error.messagechanges shape, and no success-path response changes at all.
When to revisit#
If a fourth translation site starts propagating third-party text, apply the same pattern: fixed message, classified scalars,
from exckept.If the Azure content-filter signal needs to become more precise than a substring match, that is the trigger to revisit the “rejected: structural detection” option above — not to loosen the propagation rule.
Participants#
Marius