[OPIK-8262] [BE] fix: classify provider errors by status and stop collapsing trace-thread fan-out - #8162
Conversation
⏱️ pre-commit per-hook timing
⏭️ 40 skipped (no matching files changed)
|
69ce71d to
ec71262
Compare
ec71262 to
472017a
Compare
a8d9059 to
781e292
Compare
781e292 to
01393c5
Compare
01393c5 to
a8dd855
Compare
a8dd855 to
db3d52c
Compare
db3d52c to
6745dbd
Compare
|
This change looks worth a test. Both halves land on online scoring and the e2e estate asserts neither. On the classification: online-evaluation-python-metric-errors.spec.ts already proves a python evaluator 400 is terminal and called exactly once, but it never reaches ChatCompletionService — a provider 401 that kept burning onlineScoring.maxRetries would pass it unchanged. On the fan-out: no spec drives a thread-scoped rule at all (rule-scope-thread-span is the taxonomy's own open gap), so if the per-thread split lost an id, or the >1 branch migrated an entry it should have scored, threads would go silently unscored. Both look reachable hermetically: the suite's mock gateway already answers 401 with per-model counters, and POST /v1/private/manual-evaluation/threads enqueues N thread ids on demand, so neither needs a real provider key. The legacy multi-id migrate branch is not reachable on a fresh install and is out of scope for a permanent test. Would target What it would check
Deploying a test environment for this PR and exploring it — results will follow in a comment. Not testable yet. The complement of the classification — 408/425/429 staying retryable — is the direction that loses evaluations if it is wrong (a rate-limited judge acked and dropped instead of retried), but the mock gateway only ever answers 401, so no transient provider status can be provoked on this estate. The VertexAI/GAX branch is separately unreachable without Google Cloud credentials. also touches Backend (Java API / internal) Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review. Re-checked after a push on 04 Sep 17:35 UTC. |
|
🔄 Test environment deployment process has started Phase 1: Deploying base version You can monitor the progress here. |
…read fan-out Two coupled fixes to online scoring. They ship together because the second only becomes a correctness problem once the first lands. Retry classification. scoreTrace answered every provider failure with a blanket InternalServerErrorException, so a request the provider can never accept -- an oversized body rejected with a plain-text 400 -- was replayed maxRetries times, one delivery per pendingMessageDuration, before being retired. It now classifies by status code rather than by 4xx/5xx family: 400/401/403 and friends become ClientErrorException, which BaseRedisSubscriber.NON_RETRYABLE_EXCEPTIONS retires on the first delivery, while 408/425/429 and every 5xx stay a retryable 500. Family alone is too blunt -- 408/425/429 are "not now", not "not ever". Permanence is decided only from a status the provider actually put on the wire. The provider mappers are not consulted, not even as a fallback: they synthesize a code when they cannot parse the body (CustomLlm 400, OpenAi 500), and nothing downstream can tell a parsed 400 from that default, so trusting them would drop every unparseable CustomLlm failure on its first delivery. Needlessly retrying a permanent error costs maxRetries attempts; dropping an unknown failure loses it forever, so an absent wire status stays retryable. VertexAI was the one provider this could not reach. The Google Cloud SDK raises GAX ApiException, which carries no HttpException and which langchain4j does not model as NonRetriableException, so a permanent Vertex failure was retried in-process and then redelivered by the subscriber. Its status now comes from GAX's own transport-neutral translation, identical for the gRPC and HTTP-JSON transports, and a permanent status additionally fails fast inside the retry action so the in-process attempts are skipped too. An exception GAX itself marks retryable yields no status, so the mapping can only ever prevent a drop, never cause one. Thread fan-out. The two trace-thread scorers took a message carrying a list of thread ids, fanned out, and collapsed all per-thread errors into one arbitrary re-emitted error. That was harmless only while every provider failure was retryable; after the split a fan-out can mix a permanent 400 with a transient 429, and an arbitrary pick either drops retryable work or replays threads that already succeeded. Rather than choose a better victim, remove the collapse: the subscriber acks and removes per stream entry, so OnlineScorePublisher now writes one entry per thread id and failure granularity matches ack granularity. A retry re-runs only the thread that failed, so no sibling is ever replayed. Entries left by the previous build carry several ids. Those are migrated, not scored: the consumer republishes them as N single-id entries and completes, which is what acks the original. A failed republish does not ack, so the entry redelivers and the split retries; a successful republish whose ack fails splits twice, and the duplicate scores are absorbed by feedback_scores being a ReplacingMergeTree. The branch is a shim, deletable once no pre-deploy entry can be in flight. The pipeline lives in OnlineScoringBaseScorer so it cannot drift between the two scorers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6745dbd to
c20c9e0
Compare
|
✅ Test environment is now available! To configure additional Environment variables for your environment, run [Deploy Opik AdHoc Environment workflow] (https://github.com/comet-ml/comet-deployment/actions/workflows/deploy_opik_adhoc_env.yaml) Access Information
The deployment has completed successfully and the version has been verified. |
|
Explored this PR on its own test environment. Worked all 5 items on the triage Per-item results
2 flows look worth a permanent test:
Writing the spec now; a draft PR will follow. Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review. |
|
Proposed a permanent test for this change. Test proposal — opik PR #8162 (OPIK-8262)Draft PR: #8167 Draft, no reviewers requested, based on Specs proposed: 1
Supporting changes in the same PR: a
Candidates dropped: 1"A permanent provider failure is attempted once; a transient one is retried" — the
The prerequisite is small — teach Incidental finding, not part of the PRRunning the whole Separately, Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review. |
Details
Two fixes to online scoring, in one PR because the second is only a correctness problem once the first lands.
1.
ChatCompletionService.scoreTraceclassifies provider errors by status code, not by 4xx/5xx familyscoreTraceanswered every provider failure with a blanketInternalServerErrorException. That type is absent fromBaseRedisSubscriber.NON_RETRYABLE_EXCEPTIONS, so a request the provider can never accept was replayedmaxRetriestimes, once perpendingMessageDuration, before being retired.The obvious fix — recover the provider status verbatim, as
create()and the streaming handler do — is wrong here, and the old code says so in a comment.NON_RETRYABLE_EXCEPTIONSmatches onClientErrorException, so recovering the status would drop the whole 4xx family on first failure, including 408 and 429, which langchain4j models asRetriableException. So the split is by status code:ClientErrorExceptionInternalServerErrorException(500)maxRetriesInternalServerErrorException(500)maxRetries408/425/429 say "not now", not "not ever", which is exactly why family is too blunt a discriminator. An unrecognised status defaults to retryable, matching
BaseRedisSubscriber's own stance on unknown exception types.Permanence is decided only from a real wire status. The status comes from the cause-chain
HttpExceptionviafindProviderHttpStatus. The provider mappers are deliberately not consulted for this decision, not even as a fallback.This is stronger than the first revision of this PR, and the change came out of review (thanks @baz-reviewer — see the thread on
ChatCompletionService.java). That revision fell back toproviderError.getCode()when no wire status was found. But the mappers synthesize a code when they cannot parse the body —CustomLlmErrorMessagedefaults to 400,OpenAiErrorMessageto 500 — and nothing downstream can tell a genuinely parsed 400 from that default. So any unparseable failure of a CustomLlm provider (connection reset, proxy hiccup, truncated body) would have been classified permanent and dropped on its first delivery. That is silent loss of an evaluation a retry might well have completed — the same synthetic-outranks-reality defect the precedence rule was meant to prevent, just in the other direction.The asymmetry settles it: needlessly retrying a genuinely permanent error wastes at most
maxRetriesattempts, whereas dropping an unknown failure loses it forever. So an absent wire status falls through to the retryable 500, matchingBaseRedisSubscriber's own "unknown defaults to retryable for safety". The mapper's code still shapes the error message; it just has no say in retryability. Both directions are pinned by tests and mutation-checked below.2. Trace-thread scorers no longer collapse a fan-out — one thread id per stream entry
OnlineScoringTraceThreadLlmAsJudgeScorerandOnlineScoringTraceThreadUserDefinedMetricPythonScorereach took a message carrying a list of thread ids, fanned out over them, collected every error, and re-emitted one:That single error decides the fate of the whole entry, so per-thread outcomes are lost.
Why this is coupled to fix 1, since reviewers asked. Before the status split every provider failure was a blanket 500, so all siblings under an entry were retryable and an arbitrary pick could not pick wrong. After the split, one fan-out can mix a permanent 400 with a transient 429 — and then an arbitrary pick either drops retryable work or replays threads that already succeeded. Shipping the split alone would introduce that regression in both trace-thread scorers. They belong in one change.
The decision, and the thing to review. @andrescrz's note was that "the base subscriber can have granularity to ack and remove only permanent failures, as long as the right processing result is emitted for each message… this is a problem in subscribers where they have an implementation that doesn't leverage the capabilities of the base subscriber and how it was designed to work."
Taken literally,
BaseRedisSubscriber's ack/remove granularity is per stream entry. So rather than pick a less-bad victim,OnlineScorePublisher.enqueueThreadMessagenow publishes one entry per thread id. Failure granularity then matches ack granularity by construction, the collapse disappears instead of being managed, and a retry re-runs exactly the thread that failed. Two consequences worth naming:streamMaxLenand trimmed non-strictly, so this trades stream volume for correct retry scope. On the fan-in side each entry is smaller, since it carries one thread's worth of work instead of the batch's.threadIdslist now publishes nothing, where it used to publish one entry whosethreadIdsviolated the record's own@NotEmpty.Rolling-upgrade handling: split-on-read. During the deploy, entries written by the OLD build still carry several thread ids and a NEW consumer must handle them. It does not score them. It republishes the entry as N single-id entries and lets the original be acked away:
TraceThreadToScoreLlmAsJudge.threadIds(and the Python equivalent) stays a@NotEmpty List<String>. Narrowing it to a singleStringwould make in-flight entries undecodable in exactly the deploy that ships this.Why this replaced the earlier rule. The first revision scored multi-id entries in place and, on partial failure, surfaced the retryable error in preference to a permanent one. That worked, but it meant maintaining a second set of semantics for the legacy format — a rule for reducing N per-thread outcomes into the one verdict the entry gets, which must mis-serve somebody whenever a permanent failure and a retryable one land together. Split-on-read deletes the question instead of answering it: migrate the entry to the new format, and there is no verdict to pick between siblings, no amplification, and nothing lost.
Ack is implicit, and that is what makes the failure modes right. The scorer returns the republish itself, so
BaseRedisSubscriber's normal success/failure handling supplies the ack:feedback_scoresis aReplicatedReplacingMergeTreeversioned onlast_updated_at(migration000017), so the second score overwrites rather than accumulating a duplicate row. The cost is wasted provider calls, not wrong data.On amplification. The concern that prompted dropping the earlier rule is moot on the new path anyway: one thread id per entry means a retry re-runs only that thread and no sibling is ever replayed, and
maxRetriescaps deliveries per entry regardless.The branch is temporary. It can only fire for entries written before the deploy that ships OPIK-8262. Once no such entry can be in flight — one full
streamMaxLenturnover past the rollout, at the latest — the branch and theList<String>shape ofthreadIdsare both deletable. Said so in the javadoc so a future reader knows.Shared, not duplicated. Per review, the whole per-entry pipeline (migrate-or-score branching) lives in
OnlineScoringBaseScorer.migrateOrScoreThreadIds, taking a per-thread scoring callback and a message-copy function. Message construction and each scorer's own logging stay local. Drift between the two trace-thread handlers is exactly what a migration path must not have.BaseRedisSubscriber.javais untouched. An earlier revision widenedisRetryableExceptiontoprotected staticso the collapse rule could ask the base class which sibling error was retryable. With no verdict to pick, that is no longer needed and the change was reverted — removing this PR's only file overlap with #8137.Call sites checked for anything assuming one message per rule:
ManualEvaluationService(bothenqueueThreadMessagesites) andTraceThreadOnlineScorerPublisher. None depended on the message count; their "enqueued N threads" logs now say N entries, one per thread id.Customer impact
A real customer hit this. Their gateway rejects oversized scoring requests with a plain-text 400 (
redaction input text is too large... equal or smaller than: 1000000) — no JSON envelope, sogetLlmProviderErrorcannot parse it and the old code fell through to the blanket 500. It affects roughly 6% of their traces.The main cost is how long a doomed entry stays resident. At production defaults (
pendingMessageDuration10m,maxRetries3) a permanently-failing entry is delivered at t≈0, again at t≈10m, again at t≈20m, and only then retired. It occupies the pending list for ~20 minutes instead of clearing on the first attempt. Multiply that by a sustained failure rate and the queue takes far longer to catch up than the failure rate alone would suggest.And it is worst exactly where it hurts most — the large payloads. Requests that trip a size-based rejection like the redaction limit are by definition the big ones. In the incident that prompted this, judge entries averaged ~12 MB and user-defined-metric entries ~56 MB, against a Redis footprint that reached 19.66 GiB. Those are the entries held ~20 minutes longer than necessary, so the population with the worst memory cost per entry is precisely the population lingering longest. Dropping them on the first attempt cuts both the residency and the peak memory they contribute, and lets the queue drain closer to its real throughput.
Secondary, but real: each doomed request costs 3 calls against the customer's own rate-limited gateway instead of 1. At their volume that wasted load can itself provoke 429s for legitimate traffic, so the retries make the problem worse rather than merely slower.
Stated precisely, and please do not conflate these: this fix does not unblock a stuck queue. A permanent failure was already clearing after 3 attempts. What it removes is queue-catch-up delay, the memory residency of the largest entries, wasted gateway load, and head-of-line occupancy. The separate cursor fix (OPIK-8240, PR #8137) is the one that makes an unreachable backlog reachable. Different defect, different fix.
Change checklist
Behaviour change: a permanently-failing provider response is dropped on its first delivery instead of after ~20 minutes and 3 attempts; a batch thread enqueue becomes N stream entries instead of 1. No new configuration keys.
Issues
AI-WATERMARK
AI-WATERMARK: yes
Testing
New coverage:
ChatCompletionServiceTest.isPermanentFailure__classifiesTheStatus…scoreTrace__whenPermanentClientError__thenNonRetryableClientErrorException, status carried through…scoreTrace__whenTransientStatus__thenStaysRetryable…scoreTrace__whenPermanentGaxStatus__thenNonRetryable…scoreTrace__whenTransientGaxStatus__thenRetryableRESOURCE_EXHAUSTED/DEADLINE_EXCEEDED/UNAVAILABLE/INTERNAL/UNKNOWNstay retryable…scoreTrace__whenGaxSaysRetryable__thenRetryableABORTED/CANCELLED…scoreTrace__whenHttpExceptionAndGaxInSameChain__thenHttpExceptionWinsHttpExceptionprecedence…whenPermanentGaxStatus__thenNotRetriedInProcess/…whenTransientGaxStatus__thenStillRetriedInProcess…scoreTrace__whenPermanentHttpStatus__thenNotRetriedInProcessHttpException(not aNonRetriableException) at 400/403/404/413/422 is attempted exactly once, so only the status classification can be stopping it…scoreTrace__whenTransientHttpStatus__thenStillRetriedInProcessOnlineScorePublisherIntegrationTest(new, real Redis)containsExactlyInAnyOrderElementsOf, per.agents/skills/opik-backend/testing.md) against independently built expectations, so a droppeduserName/code/projectIdis caught, not just a wrong thread id…scoreTrace__whenNoWireStatus__thenStaysRetryableWhateverTheMapperSays…whenTransientWireStatusBehindSyntheticPermanent__thenStaysRetryable…whenPermanentWireStatusBehindSyntheticTransient__thenStaysNonRetryableOnlineScorePublisherTest.ThreadMessageFanOutTests.shouldPublishOne{LlmAsJudge,Python}EntryPerThreadId…shouldPublishNothingForAnEmptyThreadIdList@NotEmpty-violating entryOnlineScoringTraceThreadLlmAsJudgeScorerTest.SplitOnReadTests.multiIdEntryIsSplitAndNotScored…failedRepublishIsNotAcked…singleIdEntryIsScoredNotSplit…migrationDoesNotLogScoringSuccess(both scorers)Processed trace threadline and does emit a distinctMigrated 'N' legacy thread idsline…scoringLogsScoringSuccessOnlineScoringTraceThreadUserDefinedMetricPythonScorerTest.SplitOnReadTestsMutation checks
Every behavioural claim was broken deliberately, the failure observed, then restored and re-run green.
scoreTrace__whenNoWireStatus__thenStaysRetryableWhateverTheMapperSaysat 400, 401 and 403, i.e. exactly the statusesCustomLlmErrorMessagecan synthesizemultiIdEntryIsSplitAndNotScoredandfailedRepublishIsNotAckedmultiIdEntryIsSplitAndNotScoredandfailedRepublishIsNotAckedisPermanentFailure→return falseisPermanentFailure__classifiesTheStatus,scoreTrace__whenPermanentClientError__thenNonRetryable,…whenPermanentProviderErrorUnparsed…,…whenPermanentWireStatusBehindSyntheticTransient…shouldPublishOneLlmAsJudgeEntryPerThreadId(all 3 rows) andshouldPublishNothingForAnEmptyThreadIdListmigrationDoesNotLogScoringSuccessandscoringLogsScoringSuccessmigrationDoesNotLogScoringSuccessin both scorer testsmultiIdEntryIsSplitAndNotScored(Python) — the callback the shared pipeline cannot verify for itselfcanonicalStatusOf(reverts the Vertex fix)scoreTrace__whenPermanentGaxStatus__thenNonRetryableand…thenNotRetriedInProcessisRetryable()safety valve droppedscoreTrace__whenGaxSaysRetryable__thenRetryableat all four codesfailFastOnPermanentFailurewrapper removedscoreTrace__whenPermanentGaxStatus__thenNotRetriedInProcessHttpExceptionscoreTrace__whenHttpExceptionAndGaxInSameChain__thenHttpExceptionWinsand two pre-existingcreate/stream tests — the precedence guard is realInvalidRequestException, already aNonRetriableException, so they did not discriminate; the new raw-HttpExceptioncases doenqueueThreadMessageWritesOneEntryPerThreadIdagainst real RedisuserNameenqueueThreadMessageWritesOneEntryPerThreadIdsingleThreadIdCopydropsuserNamemultiIdEntryIsSplitAndNotScored(unit layer — the integration test builds its own copies, so it does not cover this)enqueueMessageclobbersworkspaceNamein transitworkspaceName, so this passed beforetoDefinedMetricPythondrops the wholecodeobjectenqueueThreadMessageWritesOnePythonEntryPerThreadIdtoDefinedMetricPythonreplaces the nestedmetricstringNot verified
mvn verify(the whole Testcontainers suite) was not run — relying on CI. Only the six classes listed above were executed.threadIdsis still aList<String>— which is inspection, not measurement.ReplacingMergeTreeproperty below; I did not construct a failing ack against a real stream to watch the duplicate resolve.feedback_scoresis aReplicatedReplacingMergeTreeversioned onlast_updated_at, migration000017line 152), not observed end to end.Stream volume and partial enqueue failure
Raised in review on
OnlineScorePublisher. My first reply conceded more than the code supports; corrected here.The knobs (
config.yml,onlineScoring):streamMaxLen10,000 (REDIS_SCORING_STREAM_MAX_LEN),streamTrimLimit100 (REDIS_SCORING_STREAM_TRIM_LIMIT),consumerBatchSize10. EveryXADDgoes out asMAXLEN ~ 10000 LIMIT 100viaRedisStreamUtils.buildAddArgs→trimNonStrict(), so trimming is approximate and capped at 100 evictions perXADD.Trim exposure. Previously one rule's close-batch wrote 1 entry regardless of thread count; now it writes N. A single
TraceThreadOnlineScorerPublisher.publishwould need on the order of 10,000 sampled threads for one rule to churn the cap alone. The cap is per stream and shared across rules and producers, so concurrent batches lower that bar proportionally. A real ceiling worth knowing about; not one this change puts anyone against today.Partial enqueue failure — not a new failure class.
enqueueMessageisFlux.fromIterable(messages).flatMap(stream::add), i.e. N separateXADDs with partial-failure exposure and no reconciliation. That is already how the main scoring paths work and long predates this PR — every one of these call sites already passes a multi-element list:OnlineScoringSamplerSupport.publishSampled(List<?> messages, used by the trace and span samplers)TestSuiteAssertionSampler:151ManualEvaluationService:240, 264, 330, 353This PR moves the trace-thread path onto the shape everything else already had.
And on the thread payload it reduces per-incident loss. Before, a failed single
XADDlost all N thread ids at once; now a partial failure loses a subset. Same silent-unscored outcome, strictly smaller blast radius. Not overclaiming in the other direction: partial failure on the thread path specifically is newly possible, because it was oneXADDbefore. The point is that it is neither a new class of failure nor a worse outcome.Nor is it silent.
OnlineScorePublisher:161-164incrementsonline_scoring_enqueue_totalwithresult=error(tagged by evaluator type and workspace) and logs at ERROR on every failedXADD.Note the split-on-read shim is not exposed to it at all: a partially-failed republish errors, the entry is not acked, it redelivers and splits again, and the duplicates are absorbed by
feedback_scoresbeing aReplicatedReplacingMergeTree.Still worth a follow-up ticket, scoped as "online-scoring enqueue has no partial-failure reconciliation, across all publishers" — not as something this PR introduced. Doing it properly means per-id reconciliation/an outbox or all-or-nothing
enqueueMessagesemantics, applying to all seven streams.Provider retry ordering — investigated, no change needed
Review asked whether the permanent-status conversion happening in the
catchblock means a permanent 400 is retried byretryPolicy.withRetrybefore we ever classify it. Investigated against langchain4j 1.9.1 / 1.13.0 sources rather than assumed. It cannot, and the reason is mechanical:RetryUtils.RetryPolicy.withRetrycatchesNonRetriableExceptionand rethrows it immediately without retrying.RetryUtils.withRetryMappingExceptions, which iswithRetry(() -> ExceptionMapper.DEFAULT.withExceptionMapper(action))— so the mapping happens inside the model, below our retry policy. Verified inOpenAiChatModel:149,AnthropicChatModel:496,GoogleAiGeminiChatModel:50, and our ownOpikOpenAiChatModel:125.ExceptionMapper.DefaultExceptionMapper.mapHttpStatusCodeconverts everyHttpExceptioninto a typed exception, and that split lines up exactly withisPermanentFailure:isPermanentFailureInvalidRequestExceptionNonRetriableExceptionAuthenticationExceptionNonRetriableExceptionModelNotFoundExceptionNonRetriableExceptionTimeoutExceptionRetriableExceptionRateLimitExceptionRetriableExceptionInternalServerExceptionRetriableExceptionSo converting inside the retry action would be redundant: everything
isPermanentFailurecalls permanent is already aNonRetriableExceptionthat neither langchain4j's inner retry nor our outer policy will retry. Provider coverage: OpenAI, OpenRouter, CustomLLM, Ollama and FreeModel all useOpikOpenAiChatModel/OpenAiChatModel; Anthropic and Gemini use their own mapped models; VertexAI uses the Google Cloud SDK and never produces anHttpExceptionat all. There is no Bedrock provider in this codebase.VertexAI — the one real gap, now fixed here
Investigating the above surfaced a genuine hole, and it is now fixed in this PR rather than deferred: it is the same defect class (permanent failures consuming retry budget and holding queue residency) in the same function.
VertexAI raises GAX
ApiException, which is neither anHttpExceptionnor aNonRetriableException. So a permanent Vertex failure was retried by langchain4j's inner policy and by ours, then fell through to the retryable 500 and was redelivered by the subscriber.Signal chosen:
StatusCode.Code.getHttpStatusCode(), GAX's own translation — not a hand-written gRPC→HTTP table, and notisRetryable()as the primary. Three reasons:GrpcStatusCodeandHttpJsonStatusCodeboth reduce to the sameStatusCode.Codeenum, so the concern about gRPC vs HTTP-JSON does not arise — there is one code space, and GAX owns the translation.INVALID_ARGUMENT→400,UNAUTHENTICATED→401,PERMISSION_DENIED→403,NOT_FOUND→404,RESOURCE_EXHAUSTED→429,DEADLINE_EXCEEDED→504,UNAVAILABLE→503, plusFAILED_PRECONDITION/OUT_OF_RANGE→400,ALREADY_EXISTS/ABORTED→409,UNIMPLEMENTED→501,CANCELLED→499.isRetryable()is used, but only as a one-way safety valve. Where the gRPC→HTTP translation disagrees with retry semantics —ABORTED→409 andCANCELLED→499 both look permanent by status but can be worth retrying — an exception GAX marks retryable yields no status and therefore falls through to the retryable 500. It can only ever prevent a drop, never cause one, so it never lets a synthetic verdict override a real wire status.Precedence is untouched:
HttpExceptionis still searched across the whole chain before any typed exception, GAX included. Pinned byscoreTrace__whenHttpExceptionAndGaxInSameChain__thenHttpExceptionWinsand by mutation V4.Also fails fast in-process. A permanent status now short-circuits inside the retry action via
failFastOnPermanentFailure, mirroring the existingfailFastOnUnsupportedFeature. Scoped toscoreTraceonly —create()answers an HTTP caller and narrowing its retry behaviour is not this change's business.No build widening. GAX is already compile-scope through the directly-declared
google-cloud-vertexai:1.52.0(com.google.api:gax:2.76.0), so the import compiles with no pom change; I did not add a direct dependency, and class-name matching was therefore unnecessary. Worth a reviewer's eye: this is the first direct GAX import inopik-backend, and it puts a provider-specific type indomain.llm. I think it is justified —canonicalStatusOfalready knows langchain4j's exception zoo and its entire job is "recover a status from whatever the provider threw" — but it is a coupling call, not a mechanical one.Review follow-ups in this revision
XADDs from expandingthreadIdsOnlineScoringBaseScorer.migrateOrScoreThreadIdswith a per-thread callbackTRANSIENT_CLIENT_ERRORSmixes a jakarta constant with bare literalsisRetryableExceptionBaseRedisSubscriber.javais untouchedSecond review round
scoreOneThreadIdPerEntrypromises scoring but the multi-id path only republishesmigrateOrScoreThreadIds; javadoc names both branches and warns that completion does not mean "scored"Migrated 'N' legacy thread ids …; no scoring performedline. Tested both directions, mutations M6/M7Response.Status.TOO_MANY_REQUESTSexists in jakarta.ws.rs 4.0.0 and is now used directly. Only 425 needs a named constant; javadoc correctedThird review round
retrieveFullThreadContextjavadocTRANSIENT_CLIENT_ERRORSInvalidRequestException. We diverge deliberately per RFC 8470; harmless because retryability reads the wire status, not the mapped typeList.copyOforList.of, both of which reject null elements, and these ids never cross a user-facing DTO boundary. Declined to add unreachable validationTracesResource:815/866,TraceThreadListener:126,OnlineScoringBaseScorer:212. Redaction is a codebase-wide policy call, not this PR's scopeFourth review round
InvalidRequestException, already non-retriable. Added raw-HttpExceptioncases asserting exactly-once. Mutation V3 kill count 1 → 6withRetryMappingExceptions. OpenAI Responses is unreachable fromscoreTrace—getLanguageModelreturns the langchain4j model unconditionally; the Responses SDK is only behindgetService(), used solely forgetLlmProviderError. A mapper fallback stays refused:CustomLlmErrorMessagereturnsDEFAULT_STATUS=400on every branch and nothing distinguishes parsed from defaultedcode/status. Switching would reduce the diagnosticscoreTracehas no HTTP caller: it reaches the app log and the workspace's own evaluator log (ClickHouseAppender). Also unchanged by this PR — only a new call siteOnlineScorePublisherIntegrationTest(real Redis, mutation-checked) for the cross-layer halfAGENTS.md:45actually calls for. Declined subscriber ACK/XDEL/redelivery: that isBaseRedisSubscriber's logic, untouched here and already covered byBaseRedisSubscriberTestusingRecursiveFieldByFieldElementComparatorJsonNodeleaf has deepequals), so the comparator was redundant. Switched to plaincontainsExactlyInAnyOrderElementsOf; re-ran F1 and F3 and both kills survive unchanged, so no coverage was traded for the simplificationcode.metricCI note:
Python SDK E2E Tests 3.10failed once on commit781e292a. Not this change — the same commit also has a passing run of that workflow, the failure was a 60s container health timeout (running but not healthy (health=starting), no crash), and the uploaded backend log artifact is empty. Verified locally that Guice wiring is intact by booting the full Dropwizard app:TraceThreadOnlineScoringSamplerListenerIntegrationTestpasses 6/6 with MySQL + ClickHouse + Redis containers, which would fail outright on a circular dependency from injectingOnlineScorePublisherinto the two@EagerSingletonscorers.Documentation
No documentation change. No new configuration keys.
🤖 Generated with Claude Code