[OPIK-7835] [BE] Map UnsupportedFeatureException to 400 instead of 500 - #7786
[OPIK-7835] [BE] Map UnsupportedFeatureException to 400 instead of 500#7786thiagohora wants to merge 9 commits into
Conversation
langchain4j raises UnsupportedFeatureException when a request asks for a capability the selected provider doesn't implement -- e.g. ToolChoice.REQUIRED against Vertex AI Gemini. It's raised before the provider is ever called, so getLlmProviderError() has nothing to map and both create() and scoreTrace() fell through to their catch-all InternalServerErrorException. That 500 is wrong twice over: nothing failed server-side, and no amount of retrying can make the call succeed. It also has a concrete cost -- BaseRedisSubscriber.NON_RETRYABLE_EXCEPTIONS holds ClientErrorException but not InternalServerErrorException, so the llm_as_judge consumer classified these as retryable and burned its full retry budget before dropping the message. Throwing BadRequestException instead makes the error honest and, since BadRequestException extends ClientErrorException, lets the consumer drop the message on the first attempt. The message carries a distinct "Unsupported feature for the selected LLM provider" prefix rather than the misleading "Unexpected error calling LLM provider". Also covers the streaming path, which had the same hole -- ErrorMessage(String) defaults to 500. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
⏱️ pre-commit per-hook timing
⏭️ 41 skipped (no matching files changed)
|
Addresses review feedback on the initial 400 mapping. UnsupportedFeatureException extends LangChain4jException, not NonRetriableException, so RetryPolicy.withRetry treated it as transient and burned the full retry budget plus backoff delays before the catch block could convert it to a 400. failFastOnUnsupportedFeature now re-throws it as NonRetriableException inside the retried callable, so withRetry gives up on the first attempt while genuinely transient provider errors stay retryable. The original exception is kept as the cause so downstream classification is unchanged. Also give the unsupported-feature check precedence over the provider-error mapper in the streaming handler, matching create() and scoreTrace() -- a throwable carrying both a provider envelope and an unsupported feature now reports the capability failure consistently across all three paths. Extract the shared containsUnsupportedFeature() helper and rename the test fixture to unsupportedFeatureExceptionCases(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…king causes Addresses the second round of review feedback. The 400 carried no response body. Jersey renders WebApplicationException via exception.getResponse(), and the message-only BadRequestException constructor builds a bodiless response, so callers got a bare 400 and never learned which capability was rejected. The exception now carries an ErrorMessage entity, matching the LlmProviderUnsupportedException and ConflictException idiom. The client message is now built from the UnsupportedFeatureException's own message rather than a root-cause walk, so nothing deeper in the chain can leak into the response, and log.warn no longer attaches the throwable -- at production volumes a stack trace per expected rejection buries the genuine provider failures. Streaming had no try/catch around generateStream, so a provider that rejects up-front threw before it could invoke the error callback: the chunked output was never closed and the SSE client hung with no error event. A synchronous BadRequestException is now routed through the same error handler, which writes the error and then closes. Scoped to BadRequestException; other throwables keep propagating to the resource layer as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…status Verified how each provider implements generateStream: VertexAI and Gemini run inside a boundedElastic task and catch everything, routing it to handleError and handleClose, so streaming clients see HTTP 200 with the error in-stream. OpenAiResponses, OpenAI, CustomLlm and Anthropic run inline, so an exception raised before the provider engages escaped as an HTTP status instead -- notably OpenAiResponses, whose mapper documents langchain4j raising UnsupportedFeatureException from validate(). A synchronous BadRequestException or UnsupportedFeatureException is now handed to the same error handler, giving every provider the same streaming contract. The catch is by exact type rather than RuntimeException: nothing wraps this call, so these arrive unwrapped, and any other failure keeps propagating to the resource layer unchanged -- covered by createAndStreamResponse__whenGenerateStreamThrowsUnrelated__thenPropagates. Request-level validation is untouched: ChatCompletionsResource rejects an unsupported model before this method runs, so that stays a real HTTP 400, as createAndStreamResponseReturnsBadRequestWhenNoModel asserts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The display names conflated the ErrorMessage code with the HTTP status, which is the exact distinction this path turns on: the response stays 200 and the 400 is carried in the stream. Also drops "and closes the stream" from the BadRequestException test name. The handlers are a Mockito mock, so the real handleError body never runs and closure is not asserted here -- the name was claiming more than the test verifies. That guarantee lives in ChunkedOutputHandlers and belongs in its own test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
👋 Review summary What looks good
Overall One smaller thing not worth an inline comment: the 🤖 Review posted via /review-github-pr |
…reaming path The streaming catch was scoped to BadRequestException as well as UnsupportedFeatureException, which swallowed a 400 that propagated before this PR. LlmProviderAnthropic.generateStream calls validateRequest inline and throws BadRequestException(ERROR_EMPTY_MESSAGES) before any provider I/O, and nothing upstream validates messages: ChatCompletionsResource only checks the model, and ChatCompletionRequest is langchain4j's class so @Valid contributes no constraints. Net effect was POST /v1/private/chat/completions with stream: true, an Anthropic model and messages: [] returning 200 with the error only inside the SSE body, where it had returned 400. Scoping the catch to UnsupportedFeatureException alone still achieves the goal of this PR -- the BadRequestException arm was never needed for it -- and keeps every inline provider's pre-flight rejection a real HTTP status. The existing test asserted the swallowing behaviour, so it is replaced by its inverse: a BadRequestException from generateStream propagates and never reaches handleError. Verified non-vacuous by restoring the wider catch locally, where it fails with "Expecting code to raise a throwable". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The baseMessage overload ended up with no caller other than the single-arg version passing one constant: the unsupported-feature path builds its message through buildUnsupportedFeatureMessage instead. Restores the pre-PR shape so the diff carries no unused parameterisation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
createAnthropicValidateMandatoryFields covers the same rejection but pins stream(false), which is why the suite stayed green while the streaming path downgraded the 400 to a 200 carrying the error inside the SSE body. createAndStreamResponseReturnsBadRequestWhenNoMessages is its streaming twin: same Anthropic model, empty messages, stream: true, asserted through createAndStreamError so the check lands on the HTTP status rather than the stream contents. A throwaway API key is enough because LlmProviderAnthropic.generateStream validates messages inline, ahead of any provider I/O. Verified against Testcontainers: green on the fix, and fails with "expected: 400 but was: 200" once the catch is widened back to include BadRequestException. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
👋 Re-review summary — Both findings from my earlier review are resolved. Thanks for re-verifying the chain independently rather than taking it on trust, and for correcting your own earlier note on the thread — the table you posted checking Anthropic's inline throw, the resource's single guard, What changed
Verification I ran (isolated worktree)
Worth calling out: the old unit test No new findings. One thing I checked and am not raising as a defect: a wrapped synchronous Caveat on my own verification: Docker was unavailable in my environment, so I could not execute This is not an approval — a human reviewer should still approve. 🤖 Review posted via /review-github-pr |
|
Following up to close the caveat in my re-review above. I noted I couldn't execute So the guard is confirmed green on the exact SHA, and the static Podam Builder check I described ( 🤖 Review posted via /review-github-pr |
JetoPistola
left a comment
There was a problem hiding this comment.
Verified the streaming 400 regression is fixed at 697fb61.
The narrowed catch (UnsupportedFeatureException only) restores the real HTTP 400 for Anthropic's inline empty-messages validation, confirmed differentially against the merge-base and 4cab848. Unit tests 25/25 locally, and all 71 CI checks pass on this SHA including the Backend Integration groups covering the new resource-layer guard.
Details
UnsupportedFeatureExceptionis raised by langchain4j before the provider is ever called — e.g.ToolChoice.REQUIREDagainst Vertex AI Gemini — sogetLlmProviderError()has nothing to map and bothcreate()andscoreTrace()fell through to their catch-allInternalServerErrorException. That 500 is wrong twice over: nothing failed server-side, and no amount of retrying can make the call succeed. It also has a concrete cost —BaseRedisSubscriber.NON_RETRYABLE_EXCEPTIONSholdsClientErrorExceptionbut notInternalServerErrorException, so thellm_as_judgeonline-scoring consumer classified these as retryable and burned its full retry budget before dropping the message.failIfUnsupportedFeature()runs first in the catch blocks ofcreate()andscoreTrace(), throwingBadRequestException(400). It usesExceptionUtils.indexOfTypeso it matches whether the exception is thrown bare or wrapped by the retry policy.BadRequestException extends ClientErrorException, the consumer now drops the message on the first attempt instead of retrying.Unsupported feature for the selected LLM providerprefix instead of the misleadingUnexpected error calling LLM provider.getErrorHandler), which had the same hole —new ErrorMessage(String)defaults to 500.buildDetailedErrorMessagegained abaseMessageoverload; the existing single-arg version delegates, so all current callers are unchanged.Measured in production over a 6h window before the fix: 17,134
ToolChoice.REQUIRED is not supported, 10,638UnsupportedFeatureException, and 500Max retries reacheddrops onllm_as_judge.There is prior art for treating this as a caller problem:
LlmProviderOpenAiResponsesMapper(lines 387-396) already avoids 500ing on unsupported sampling params for the same reason.Not fixed here: the affected rules still won't score.
OnlineScoringLlmAsJudgeScorerrequestsToolChoice.REQUIREDfor agentic scoring, so every agentic LLM-judge rule pointed at Vertex AI Gemini fails deterministically. This PR makes the failure honest and cheap, not absent — the capability gap needs a separate fix. Tracked as a follow-up on the ticket.Change checklist
Issues
AI-WATERMARK
AI-WATERMARK: yes
NON_RETRYABLE_EXCEPTIONSclaim verified against the source. Production counts independently reproduced via Loki queries.Testing
Commands run, from
apps/opik-backend:ChatCompletionServiceTest: 17/17 pass — 11 pre-existing plus 6 new in anUnsupportedFeatureHandlingnested class:UnsupportedFeatureExceptionthroughcreate()→ 400 (parameterized)UnsupportedFeatureExceptionthroughscoreTrace()→ 400 (parameterized)verify(..., never()))RuntimeExceptions still yield 500Tests re-run after rebasing onto current
main.Not run:
ChatCompletionsResourceTestandOnlineScoringEngineTestare Testcontainers integration tests (ClickHouse/MySQL/Redis). Neither referencesUnsupportedFeatureExceptionorToolChoice, and this change is an additive branch keyed on one exception type, so I judged the risk low rather than spending the container spin-up. Flagging it since they're the ones that would catch a surprise. CI will cover them.Documentation
No documentation change — internal error-mapping behavior. The HTTP status for this failure mode changes from 500 to 400, which is user-visible on the chat-completions endpoints; noted via the "User facing" checkbox above.