Skip to content

[OPIK-8240] [BE] fix: split provider-error retryability by status, resume XAUTOCLAIM from its cursor - #8137

Draft
thiagohora wants to merge 1 commit into
mainfrom
thiagohora/OPIK-8240-scoring-retry-split-and-claim-cursor
Draft

[OPIK-8240] [BE] fix: split provider-error retryability by status, resume XAUTOCLAIM from its cursor#8137
thiagohora wants to merge 1 commit into
mainfrom
thiagohora/OPIK-8240-scoring-retry-split-and-claim-cursor

Conversation

@thiagohora

Copy link
Copy Markdown
Contributor

Details

Two independent defects in the online-scoring consumer, both found while investigating a customer's stuck scoring backlog. Each one leaves a permanently failing message cycling instead of retiring it, and they sit at different layers, so neither fix substitutes for the other.

1. A permanent provider 4xx was retried as if it were transient

ChatCompletionService.scoreTrace answered every provider failure it could not map with a blanket InternalServerErrorException. That type sits outside BaseRedisSubscriber.NON_RETRYABLE_EXCEPTIONS, so the subscriber replayed the message maxRetries times, once per pendingMessageDuration, before dropping it.

The real status was available the whole time — the HttpException in the cause chain carries statusCode() — but went unread, because the existing recovery path (failHandlingLLMProviderError) classifies by family: it maps all of 4xx to ClientErrorException, which would also make transient 408/429 non-retryable and drop a rate-limited evaluation after one attempt. That is exactly why scoreTrace skipped status recovery altogether; the comment there said so.

The fix is to classify by status code rather than family:

Status Classification Rationale
400, 401, 403, and the rest of 4xx non-retryable the identical request is rejected identically however often it is replayed
408, 425, 429 retryable "not now", not "not ever" — langchain4j models the latter two as RetriableException
all 5xx retryable the textbook retry case
anything else retryable matches BaseRedisSubscriber's own "unknown defaults to retryable for safety"

scoreTrace gets its own mapping, so create() and the streaming handler keep returning the provider's status verbatim to HTTP callers — they answer an HTTP client, where a 429 should stay a 429.

This also closes a pre-existing over-eager drop on the parseable branch: a JSON-parseable 429 already reached failHandlingLLMProviderError and became ClientErrorException, i.e. dropped after one attempt rather than retried. Same bug, opposite direction, reached through the other branch.

2. XAUTOCLAIM never scanned past the first ~100 pending entries

claimPendingMessages passed StreamMessageId.MIN as the scan start on every call and discarded the cursor Redis returns (AutoClaimResult.getNextId()).

Redis caps each XAUTOCLAIM at COUNT * 10 PEL entries examined (not claimed), so at the default consumerBatchSize of 10 a call only ever inspects the first 100 entries of the pending list. Restarting at MIN every time means anything past that window is never examined at all — any backlog above ~100 grows a permanently unreachable tail, whatever the retry or decode behaviour downstream.

Verified against a real Redis before writing the fix: with PEL positions 1–100 made ineligible and 101–150 eligible, COUNT 10 claims nothing and returns cursor 101-1; COUNT 20 claims 20. That matches the customer's XPENDING data exactly — a cycling head of ~100 entries at delivery-count in the thousands, in front of ~664 entries frozen at delivery-count 1, idle 14–20 days.

The fix carries getNextId() forward, resetting to MIN on Redis's 0-0 end-of-pass reply so the oldest-first bias the original code intended is preserved. A failed scan deliberately does not advance the cursor, so its window is retried rather than skipped.

One subtlety worth flagging for review. The end-of-pass sentinel is compared numerically, not against StreamMessageId.MIN/.ALL. Those are wire sentinels serializing to - and 0, and neither is equals() to the StreamMessageId(0, 0) that Redisson parses Redis's literal 0-0 into — checked against redisson 4.7.0:

MIN = -      ALL = 0      MIN.equals(ALL) = true
new StreamMessageId(0,0).equals(MIN) = false
new StreamMessageId(0,0).equals(ALL) = false

Matching on the constants would therefore never fire, the cursor would park at the end of the PEL, and the scan would stop finding anything — strictly worse than the always-MIN behaviour it replaces. There is a dedicated test pinning this.

Change checklist

  • User facing
  • Documentation update

Behaviour change: a provider rejection that can never succeed is now dropped on the first delivery instead of after maxRetries * pendingMessageDuration; transient failures are unaffected. Separately, a pending backlog deeper than ~100 entries is now fully reachable. No new configuration keys.

Issues

  • OPIK-8240

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 5 (1M context)
  • Scope: investigation, both fixes, the tests, the mutation checks, the end-to-end runs, and this description.
  • Human verification: every result below was produced by running the listed commands and reading the output. The scope limits are stated because they were measured, not assumed.

Testing

Unit

cd apps/opik-backend
mvn -o surefire:test -Dtest='ChatCompletionServiceTest,BaseRedisSubscriberUnitTest,BaseRedisSubscriberCursorTest,OnlineScoringLlmAsJudgeScorerTest,OnlineScorePublisherTest,RedisStreamCodecTest'
# Tests run: 132, Failures: 0, Errors: 0

New coverage:

Test Asserts
scoreTrace__whenPermanentClientError__thenNonRetryable 400/401/403/404/413/422 each become ClientErrorException carrying the real status, built from a plain-text body exactly like the real gateway rejection
scoreTrace__whenTransientStatus__thenStaysRetryable 408/425/429/500/502/503 stay InternalServerErrorException, i.e. outside NON_RETRYABLE_EXCEPTIONS
scoreTrace__whenParseableProviderError__thenSplitByStatusToo the parseable branch gets the same split — a parseable 429 is no longer dropped after one attempt
isPermanentFailureSplitsTheFamilyCorrectly the classifier itself, including the family boundary and the transient carve-outs
shouldResumeTheNextScanFromTheReturnedCursor successive XAUTOCLAIM calls resume at the returned cursor, captured from the actual start argument
shouldWrapBackToTheStartAfterAFullPass 0-0 wraps back to MIN rather than parking at the end
shouldRetryTheSameWindowAfterAFailedScan a failed scan does not advance the cursor, so its window is not skipped
BaseRedisSubscriberCursorTest (3) the pure cursor mapping, incl. the sentinel-vs-numeric trap above

The existing scoreTrace__whenProviderErrorUnparsed__thenStayRetryable asserted the old blanket-500 contract for every row, including 400 and 401. It is updated rather than deleted — same production-shaped data provider, assertion now keyed on isPermanentFailure(expectedStatus), so both halves of the split are exercised by the shapes that actually occurred in production.

Mutation-checked, both fixes, both directions:

Mutation Result
Collapse the split to the whole 4xx family (drop the transient carve-out) 5 tests fail
Make isPermanentFailure always false (revert to blanket 500) 7 tests fail
Revert the cursor to always-MIN 3 tests fail

Each restored and re-verified green afterwards.

End-to-end

Local Docker Compose stack, backend image built from this branch, real Redis, real BaseRedisSubscriber, with a mock gateway returning the exact production error bodies.

Fix 1, permanent 400 — mock gateway returns the verbatim redaction rejection (status code: 400 ... redaction input text is too large ... equal or smaller than: 1000000):

Message received with messageId '1788460019519-0'
Unexpected error calling LLM provider
Non-retryable error for messageId '1788460019519-0', removing from stream
! Causing: jakarta.ws.rs.ClientErrorException: ... status code: 400 ... redaction input text is too large ...

Dropped on the first delivery, one gateway call, ~400 ms. On the pre-fix build the same message logged Retryable ... deliveryCount '1', deliveryCount '2', then Max retries reached — three deliveries, three gateway calls, three minutes.

Fix 1, transient 429 — same stack, gateway switched to a 429: 10 × Retryable error for messageId, 0 × Non-retryable. The carve-out holds in the direction that matters most; this is the case a naive "all 4xx is permanent" fix would have broken.

Fix 2, deep backlog — 130 traces posted against the 429 gateway to push the PEL to 142 entries, past the 100-entry scan budget. Captured from redis-cli MONITOR:

XAUTOCLAIM ... "60000" "1788460210595-0" "COUNT" "10"
XAUTOCLAIM ... "60000" "1788460211175-0" "COUNT" "10"
XAUTOCLAIM ... "60000" "1788460211424-0" "COUNT" "10"
XAUTOCLAIM ... "60000" "1788460211631-0" "COUNT" "10"
XAUTOCLAIM ... "60000" "1788460211840-0" "COUNT" "10"

The start argument walks forward instead of being - on every call, and the full 142-entry backlog drained to XLEN=0 pending=0 — including the ~42 entries past the scan window that the old code could never have reached.

Also confirmed the wrap: with a PEL small enough to scan in one pass, the argument stays -, which is the correct 0-0MIN reset rather than a regression.

Not run, with reason:

  • Full mvn verify (Testcontainers suite) — relying on CI.
  • No e2e against a backlog large enough to need multiple wrap-arounds; 142 entries exercises one overflow of the scan budget, not sustained cycling at production depth.

Relationship to other work

  • OPIK-8164 (merged) — codec init ordering; raised the oversized-string decode ceiling.
  • OPIK-8192 / [OPIK-8192] [BE] fix: drop undecodable scoring stream messages instead of wedging the stream #8089 (in review) — undecodable payloads reach the subscriber with an id instead of throwing below it. A different population from this PR: entries whose decode fails inside Redisson, which never reach processMessage at all and so are invisible to any retry-classification fix.
  • This PR — the two remaining defects: retry classification, and reachability of the pending list itself.

All three are needed, and they compose rather than overlap. #8089 makes undecodable entries visible and retirable; this PR stops permanent failures from cycling, and makes a backlog beyond ~100 entries reachable at all.

🤖 Generated with Claude Code

…sume XAUTOCLAIM from its cursor

Two independent defects that each leave a permanently failing online-scoring
message cycling instead of retiring it.

1. A permanent provider 4xx was retried as if transient.

ChatCompletionService.scoreTrace answered every unmappable provider failure
with a blanket InternalServerErrorException, which sits outside
BaseRedisSubscriber.NON_RETRYABLE_EXCEPTIONS -- so a request that can never
succeed was replayed maxRetries times, once per pendingMessageDuration. The
real status was available the whole time on the HttpException in the cause
chain; it went unread because the existing recovery path classifies by family,
and mapping all of 4xx to ClientErrorException would drop transient 408/429
after one attempt. Hence the blanket 500.

Classify by status code instead: 400/401/403 and the rest of 4xx are
permanent, 408/425/429 and all 5xx stay retryable. scoreTrace gets its own
mapping so create() and the streaming handler keep returning the provider's
status verbatim to HTTP callers. This also closes a pre-existing over-eager
drop on the parseable branch, where a JSON-parseable 429 already became
ClientErrorException and was dropped after one attempt.

2. XAUTOCLAIM never scanned past the first ~100 pending entries.

claimPendingMessages passed StreamMessageId.MIN as the scan start every call
and discarded the cursor Redis returns. Redis caps each XAUTOCLAIM at
COUNT * 10 PEL entries *examined*, so at consumerBatchSize=10 a call inspects
only the first 100 -- and restarting at MIN means nothing past that window is
ever examined. Any backlog above ~100 grows a permanently unreachable tail,
whatever the retry or decode behaviour downstream.

Carry getNextId() forward, resetting to MIN on Redis's 0-0 end-of-pass reply
so the oldest-first bias is preserved. A failed scan deliberately does not
advance the cursor, so its window is retried rather than skipped.

The end-of-pass sentinel is compared numerically, not against
StreamMessageId.MIN/.ALL: those are wire sentinels serializing to "-" and "0",
and neither is equals() to the StreamMessageId(0, 0) Redisson parses 0-0 into
(verified against redisson 4.7.0), so matching on the constants would never
fire and the scan would park at the end of the PEL.

Tests: 132 green across the affected suites. Both fixes mutation-checked --
collapsing the status split to the whole 4xx family fails 5 tests, removing it
entirely fails 7; reverting the cursor to always-MIN fails 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added java Pull requests that update Java code Backend tests Including test files, or tests related like configuration. labels Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📋 PR Linter Failed

Missing Section. The description is missing the ## Documentation section.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
☕ spotless — java backend Format Java code 4.94s
🛡️ semgrep — java backend sql Block SQL injection-prone string formatting 1.81s
Total (2 ran) 6.75s
⏭️ 42 skipped (no matching files changed)
Hook Description Result
🐍 trim trailing whitespace — python sdk Strip trailing whitespace ⏭️
🐍 fix end of files — python sdk Ensure files end in a newline ⏭️
🐍 ruff — python sdk Lint + autofix Python (ruff) ⏭️
🐍 ruff-format — python sdk Format Python code (ruff) ⏭️
🐍 mypy — python sdk Static type check ⏭️
🤖 trim trailing whitespace — optimizer Strip trailing whitespace ⏭️
🤖 fix end of files — optimizer Ensure files end in a newline ⏭️
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
🤖 check for added large files — optimizer Block large files (>1MB) ⏭️
🔐 detect private key — optimizer Block committed private keys ⏭️
🤖 check for merge conflicts — optimizer Block merge-conflict markers ⏭️
🤖 check for case conflicts — optimizer Block case-only name clashes ⏭️
🤖 pyupgrade — optimizer Modernize Python syntax ⏭️
🤖 ruff — optimizer Lint + autofix Python (ruff) ⏭️
🤖 ruff-format — optimizer Format Python code (ruff) ⏭️
🤖 mypy — optimizer Static type check ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🔤 codespell — optimizer Fix common misspellings ⏭️
📊 radon cc — optimizer Cyclomatic-complexity gate ⏭️
📊 radon raw — optimizer Raw size metrics gate ⏭️
📊 xenon — optimizer Fail on complexity thresholds ⏭️
📊 lizard — optimizer Cyclomatic-complexity gate ⏭️
🧹 vulture — optimizer Find dead code ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
⚓ helm-docs Regenerate Helm chart README ⏭️
block non-public FE plugins Block non-public FE plugins ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
🧪 rebaseline script tests Self-test the changelog re-baseline script ⏭️
🌐 eslint — frontend Lint + autofix JS/TS ⏭️
🌐 typecheck — frontend Whole-project tsc type check ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
⚙️ actionlint — github workflows Lint GitHub Actions workflows ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️
🌈 zizmor — github workflows security Security-scan GitHub Actions workflows ⏭️

Comment on lines +713 to +724
@Test
@DisplayName("A parseable provider error is split the same way, not just the unparseable path")
void scoreTrace__whenParseableProviderError__thenSplitByStatusToo() {
var chatRequest = ChatRequest.builder().messages(UserMessage.from("score this")).build();
var modelParameters = podamFactory.manufacturePojo(LlmAsJudgeModelParameters.class);
var workspaceId = "test-workspace-id";
var providerFailure = new RuntimeException("provider said no");

when(llmProviderFactory.getLanguageModel(anyString(), any())).thenReturn(chatModel);
when(chatModel.chat(any(ChatRequest.class))).thenThrow(providerFailure);
when(llmProviderFactory.getService(anyString(), anyString())).thenReturn(llmProviderService);
// A parseable 429 used to reach failHandlingLLMProviderError and become a ClientErrorException,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing mapper-boundary retryability coverage

scoreTrace__whenParseableProviderError__thenSplitByStatusToo mocks getLlmProviderError directly, so it bypasses the mapper/provider path and can't catch precedence when a mapper fallback should lose to a nested HttpException, and the matrix skips OpenAI 500 and Custom 400 fallbacks against opposite retryability categories — should we add scoreTrace seam cases that exercise the real provider path and assert both exception type and Redis retryability (ClientErrorException non-retryable vs InternalServerErrorException retryable)?

Severity

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
In
`apps/opik-backend/src/test/java/com/comet/opik/domain/llm/ChatCompletionServiceTest.java`
around lines 713-724, update
`scoreTrace__whenParseableProviderError__thenSplitByStatusToo` so it exercises the
actual provider mapper path instead of mocking `getLlmProviderError` to return an
`ErrorMessage` directly. Add focused cases covering the OpenAI fallback to 500 and
Custom fallback to 400, each paired with an opposite retryability category, including
nested `HttpException` precedence. Assert both the resulting exception type/status and
the Redis subscriber classification (`ClientErrorException` non-retryable versus
`InternalServerErrorException` retryable).

}

@Test
@DisplayName("A parseable provider error is split the same way, not just the unparseable path")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Malformed test display name

The test label A parseable provider error is split the same way reads awkwardly — should we rename it to A parseable provider error is classified the same way?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
In
`apps/opik-backend/src/test/java/com/comet/opik/domain/llm/ChatCompletionServiceTest.java`
around line 714, update the
`scoreTrace__whenParseableProviderError__thenSplitByStatusToo` test's `@DisplayName`,
replacing “A parseable provider error is split the same way” with “A parseable
provider error is classified the same way”.

Comment on lines +786 to +797
if (ChatCompletionService.isPermanentFailure(expectedStatus)) {
// Retrying cannot change the outcome — hand the subscriber a type it drops immediately.
assertThat(thrown)
.isInstanceOf(ClientErrorException.class)
.isNotInstanceOf(InternalServerErrorException.class);
assertThat(((WebApplicationException) thrown).getResponse().getStatus()).isEqualTo(expectedStatus);
} else {
// Transient (or unknown) — stay outside NON_RETRYABLE_EXCEPTIONS so maxRetries is honoured.
assertThat(thrown)
.isInstanceOf(InternalServerErrorException.class)
.isNotInstanceOf(ClientErrorException.class);
assertThat(((WebApplicationException) thrown).getResponse().getStatus()).isEqualTo(500);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conditional assertions obscure retryability coverage

ChatCompletionService.isPermanentFailure(expectedStatus) makes the parameterized test switch between two assertion flows, so failures are harder to scan and localize — should we split permanent and retryable cases into separate tests, or parameterize the expected exception/status and keep one unconditional flow?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-backend/src/test/java/com/comet/opik/domain/llm/ChatCompletionServiceTest.java`
around lines 786-797, refactor
`scoreTrace__whenProviderErrorUnparsed__thenRetryabilityFollowsStatus` so it no longer
uses an `if` to select two different assertion flows. Split permanent and retryable
statuses into separate parameterized tests, or add expected exception type and response
status to the test parameters and use one unconditional assertion sequence.

Comment on lines +158 to +160
int status = providerError.map(ErrorMessage::getCode)
.filter(ChatCompletionService::isErrorStatus)
.or(() -> findProviderHttpStatus(runtimeException))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synthetic statuses cause wrong retry/drop behavior

The status selection at apps/opik-backend/src/main/java/com/comet/opik/domain/llm/ChatCompletionService.java:158-161 trusts fallback provider mappings over valid nested HTTP statuses, so unknown OpenAI codes hide upstream 400s and Custom LLM fallback 400s hide upstream 500/503s, misclassifying failures as retryable or immediately removing them — should we prefer the cause-chain status and add both conflicting-status scoreTrace regressions?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-backend/src/main/java/com/comet/opik/domain/llm/ChatCompletionService.java`
around lines 158-161, fix the `scoreTrace` status-selection logic so a valid nested
`HttpException` status takes precedence over mapped provider codes, since OpenAI and
Custom LLM mappers may return synthetic fallback 500 or 400 values. Use the
provider-mapped status only when no valid cause-chain HTTP status exists, while
preserving the existing retryability split. Add `scoreTrace` regression tests covering
OpenAI synthetic 500 with nested HTTP 400 and Custom LLM synthetic 400 with nested HTTP
500/503.

Comment on lines +165 to +171
if (isPermanentFailure(status)) {
// Non-retryable: the subscriber acks and removes on the first delivery instead of burning
// maxRetries x pendingMessageDuration on a request whose outcome cannot change. This is the
// OPIK-8193 case - a provider rejecting an oversized/invalid request rejects every retry of
// it identically.
throw new ClientErrorException(buildDetailedErrorMessage(runtimeException), status,
runtimeException);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixed thread failures lose retryable work

OnlineScoringTraceThreadLlmAsJudgeScorer re-emits only errors.getFirst() after flatMap, so arrival order can select ClientErrorException; when it arrives first, BaseRedisSubscriber acknowledges and removes the whole message, dropping the retryable sibling — should we split/requeue thread IDs independently or make the aggregate retryable whenever any sibling is retryable?

Severity web_search

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-backend/src/main/java/com/comet/opik/domain/llm/ChatCompletionService.java`
around lines 165-171, review the `scoreTrace` permanent-4xx exception path: emitting a
`ClientErrorException` can cause `OnlineScoringTraceThreadLlmAsJudgeScorer` to retain
only the arrival-order first error and acknowledge a Redis message containing retryable
sibling threads. Refactor the error propagation and/or caller handling so trace-thread
IDs are split and requeued independently, or produce a deterministic aggregate that
remains retryable whenever any sibling failure is retryable. Add coverage for mixed
permanent and transient failures arriving in either order.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend java Pull requests that update Java code 🟠 size/L tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant