Skip to content

Commit 2b96045

Browse files
thiagohoraclaude
andcommitted
[OPIK-8240] [BE] fix: split provider-error retryability by status, resume 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. 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. Carry getNextId() forward, resetting to MIN on Redis's 0-0 end-of-pass reply. A failed scan deliberately does not advance the cursor, so its window is retried rather than skipped. The sentinel is compared numerically: StreamMessageId.MIN/.ALL serialize 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. Addressed from review: - Status precedence was backwards. The provider mappers synthesize a status when they cannot read one off the body (CustomLlmErrorMessage defaults to 400, OpenAiErrorMessage to 500), and preferring the mapped code let a synthetic 400 mask a real upstream 503 -- classifying a transient failure as permanent and dropping it on first delivery. The cause-chain HttpException now wins, with the mapped code as fallback, matching the precedence findProviderHttpStatus already documents for its own chain walk. - Sibling aggregation could discard retryable work. The trace-thread scorers re-emit one error for a message that fans out over many thread ids, and errors.getFirst() made that an arrival-order race. Harmless while every failure was a blanket 500; not once permanent and transient were split, as a ClientErrorException arriving first would ack and remove the entry with its retryable siblings. Both scorers now share representativeError(), which prefers a retryable sibling: a bounded replay is recoverable, silently dropped work is not. - Test-only: unconditional assertion flow in the parameterized status test, real-mapper-path coverage for the precedence rule, display-name wording. Tests: 145 green across the affected suites. Mutation-checked -- collapsing the status split to the whole 4xx family fails 5, removing it fails 7, reverting the cursor to always-MIN fails 3, inverting the status precedence fails 4, reverting aggregation to getFirst() fails 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4ef186c commit 2b96045

9 files changed

Lines changed: 582 additions & 28 deletions

apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/events/BaseRedisSubscriber.java

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,27 @@ public abstract class BaseRedisSubscriber<M> implements Managed {
127127
private final LongHistogram listPendingTime;
128128
private final LongCounter unexpectedErrors;
129129

130+
/**
131+
* Resume point for the next {@code XAUTOCLAIM} scan.
132+
*
133+
* <p>Redis caps each {@code XAUTOCLAIM} at {@code COUNT * 10} PEL entries <em>examined</em> (not
134+
* claimed), so with the default {@code consumerBatchSize} of 10 a single call only ever inspects the
135+
* first 100 entries of the pending list. Restarting every scan at {@link StreamMessageId#MIN} —
136+
* which this class used to do, discarding the cursor Redis hands back — means anything past that
137+
* first window is never examined at all: a backlog above ~100 entries develops a permanently
138+
* unreachable tail, whatever the retry or decode behaviour further down. Carrying the returned
139+
* cursor forward walks the whole PEL instead, a window at a time.
140+
*
141+
* <p>Redis returns {@code 0-0} once a pass has covered the entire pending list; that resets this
142+
* back to {@link StreamMessageId#MIN} so the next pass starts over from the oldest entry, keeping
143+
* the oldest-first bias the original code intended.
144+
*
145+
* <p>Volatile rather than synchronized: {@code concatMap} in {@link #setupStreamListener()} already
146+
* serializes claim calls, so this is only ever written by one claim at a time; volatile just
147+
* publishes that write to whichever scheduler thread runs the next one.
148+
*/
149+
private volatile StreamMessageId claimCursor = StreamMessageId.MIN;
150+
130151
private volatile RStreamReactive<String, M> stream;
131152
private volatile Disposable streamSubscription;
132153
private volatile Scheduler timerScheduler;
@@ -398,11 +419,11 @@ private Mono<Map<StreamMessageId, Map<String, M>>> claimPendingMessages() {
398419
consumerId,
399420
config.getPendingMessageDuration().toJavaDuration().toMillis(),
400421
TimeUnit.MILLISECONDS,
401-
StreamMessageId.MIN, // Start from the beginning of pending list
422+
claimCursor, // Resume where the previous scan stopped; see claimCursor
402423
config.getConsumerBatchSize())
403424
.subscribeOn(consumerScheduler)
404425
.filter(Objects::nonNull)
405-
.map(AutoClaimResult::getMessages)
426+
.map(this::advanceCursorAndExtractMessages)
406427
.filter(Objects::nonNull)
407428
.doOnSuccess(claimedMessages -> {
408429
claimedMessages = Objects.requireNonNullElse(claimedMessages, Map.of());
@@ -412,14 +433,48 @@ private Mono<Map<StreamMessageId, Map<String, M>>> claimPendingMessages() {
412433
.onErrorResume(throwable -> {
413434
claimErrors.add(1);
414435
log.error("Error claiming pending messages", throwable);
436+
// A failed scan leaves the cursor where it was, so the next attempt retries the same
437+
// window rather than skipping it. Except on NOGROUP: the group is being recreated, so
438+
// any cursor into the old group's PEL is meaningless.
415439
if (isNoGroupError(throwable)) {
440+
claimCursor = StreamMessageId.MIN;
416441
return recoverFromNoGroup();
417442
}
418443
return Mono.just(Map.of());
419444
})
420445
.doFinally(signalType -> claimTime.record(System.currentTimeMillis() - startMillis));
421446
}
422447

448+
/**
449+
* Advances {@link #claimCursor} to where Redis says the next scan should resume, then returns the
450+
* batch this scan actually claimed.
451+
*
452+
* <p>A {@code null} or {@code 0-0} next-id means the pass reached the end of the pending list, so the
453+
* cursor goes back to {@link StreamMessageId#MIN} and the following pass starts from the oldest entry
454+
* again.
455+
*/
456+
private Map<StreamMessageId, Map<String, M>> advanceCursorAndExtractMessages(AutoClaimResult<String, M> result) {
457+
claimCursor = nextCursor(result.getNextId());
458+
return result.getMessages();
459+
}
460+
461+
/**
462+
* The cursor to use for the next scan, given the next-id Redis returned.
463+
*
464+
* <p>Compared numerically rather than against {@link StreamMessageId#MIN} / {@link StreamMessageId#ALL}:
465+
* those are wire sentinels that serialize to {@code -} and {@code 0}, and neither is
466+
* {@code equals()} to the {@code StreamMessageId(0, 0)} that Redisson parses Redis's literal
467+
* {@code 0-0} end-of-pass reply into. Matching on the constants would therefore never fire, and the
468+
* scan would run off the end of the PEL and stay there instead of wrapping.
469+
*/
470+
// Package-private for unit tests.
471+
static StreamMessageId nextCursor(StreamMessageId nextId) {
472+
if (nextId == null || (nextId.getId0() == 0 && nextId.getId1() == 0)) {
473+
return StreamMessageId.MIN;
474+
}
475+
return nextId;
476+
}
477+
423478
private Mono<Map<StreamMessageId, Map<String, M>>> readMessages() {
424479
var startMillis = System.currentTimeMillis();
425480
var streamReadGroupArgs = StreamReadGroupArgs.neverDelivered()

apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/events/OnlineScoringBaseScorer.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import com.comet.opik.infrastructure.OnlineScoringStreamConfigurationAdapter;
1717
import com.comet.opik.infrastructure.auth.RequestContext;
1818
import jakarta.validation.constraints.NotNull;
19+
import jakarta.ws.rs.ClientErrorException;
1920
import lombok.NonNull;
2021
import org.apache.commons.lang3.StringUtils;
2122
import org.redisson.api.RedissonReactiveClient;
@@ -154,6 +155,34 @@ protected List<Span> getSpansFromPreloadAndLogOverflow(@NonNull ThreadSpanPreloa
154155
* scoring chain (feedback-score persistence reads it). Per-message throughput and error metrics are
155156
* attributed automatically by {@link BaseRedisSubscriber} from {@link #messageContext(Object)}.
156157
*/
158+
/**
159+
* Picks which of several sibling failures represents the batch, when one message fans out over many
160+
* thread ids and more than one of them fails.
161+
*
162+
* <p><b>Retryable wins.</b> The whole batch travels as a single stream entry, so the type re-emitted
163+
* here decides the fate of every sibling in it: a {@code ClientErrorException} tells
164+
* {@code BaseRedisSubscriber} to ack and remove, taking any retryable sibling down with it, unretried.
165+
* Picking arbitrarily -- {@code errors.getFirst()}, i.e. whichever the {@code flatMap} happened to
166+
* emit first -- made that a race. It was harmless while every provider failure was a blanket 500 and
167+
* the choice could not change retryability; it stopped being harmless once OPIK-8240 split permanent
168+
* from transient.
169+
*
170+
* <p>The asymmetry is deliberate. Preferring retryable costs a bounded replay of the permanent sibling
171+
* ({@code maxRetries} caps it) and the permanent one is dropped for good on the final attempt.
172+
* Preferring non-retryable costs recoverable work, silently and permanently. Only one of those is
173+
* recoverable, so the tie goes to retrying.
174+
*
175+
* @param errors the sibling failures, never empty
176+
* @return the first retryable failure if any, otherwise the first failure
177+
*/
178+
// Package-private for unit tests.
179+
static Throwable representativeError(@NonNull List<Throwable> errors) {
180+
return errors.stream()
181+
.filter(error -> !(error instanceof ClientErrorException))
182+
.findFirst()
183+
.orElseGet(errors::getFirst);
184+
}
185+
157186
@Override
158187
protected final Mono<Void> processEvent(M message) {
159188
var workspaceName = StringUtils.defaultIfBlank(message.workspaceName(), message.workspaceId());

apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/events/OnlineScoringTraceThreadLlmAsJudgeScorer.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ protected Mono<Void> score(@NonNull TraceThreadToScoreLlmAsJudge message) {
124124
.then(Mono.<Throwable>empty())
125125
.onErrorResume(Mono::just))
126126
.collectList()
127-
.flatMap(errors -> errors.isEmpty() ? Mono.<Void>empty() : Mono.error(errors.getFirst()))
127+
.flatMap(errors -> errors.isEmpty() ? Mono.<Void>empty() : Mono.error(representativeError(errors)))
128128
.contextWrite(context -> context.put(RequestContext.WORKSPACE_ID, message.workspaceId())
129129
.put(RequestContext.USER_NAME, message.userName())
130130
.put(RequestContext.VISIBILITY, Visibility.PRIVATE))

apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/events/OnlineScoringTraceThreadUserDefinedMetricPythonScorer.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ protected Mono<Void> score(@NonNull TraceThreadToScoreUserDefinedMetricPython me
116116
.then(Mono.<Throwable>empty())
117117
.onErrorResume(Mono::just))
118118
.collectList()
119-
.flatMap(errors -> errors.isEmpty() ? Mono.<Void>empty() : Mono.error(errors.getFirst()))
119+
.flatMap(errors -> errors.isEmpty() ? Mono.<Void>empty() : Mono.error(representativeError(errors)))
120120
.contextWrite(context -> context.put(RequestContext.WORKSPACE_ID, message.workspaceId())
121121
.put(RequestContext.USER_NAME, message.userName())
122122
.put(RequestContext.VISIBILITY, Visibility.PRIVATE))

apps/opik-backend/src/main/java/com/comet/opik/domain/llm/ChatCompletionService.java

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import java.nio.channels.ClosedChannelException;
3737
import java.util.List;
3838
import java.util.Optional;
39+
import java.util.Set;
3940
import java.util.concurrent.Callable;
4041
import java.util.function.Consumer;
4142

@@ -147,16 +148,38 @@ public ChatResponse scoreTrace(@NonNull ChatRequest chatRequest,
147148

148149
Optional<ErrorMessage> providerError = provider.getLlmProviderError(runtimeException);
149150

150-
providerError
151-
.ifPresent(llmProviderError -> failHandlingLLMProviderError(runtimeException, llmProviderError));
151+
// Deliberately NOT failHandlingLLMProviderError here, unlike create() and the streaming handler.
152+
// Those two answer an HTTP caller, so they want the provider's status verbatim - a 429 upstream
153+
// should be a 429 downstream. This method answers the online-scoring subscribers instead, where
154+
// the thrown type decides retryability: BaseRedisSubscriber.NON_RETRYABLE_EXCEPTIONS matches on
155+
// ClientErrorException, so the whole 4xx family would be dropped on the first failure - taking
156+
// genuinely transient 408s and 429s with it. Route through the retryability split instead, which
157+
// separates "this request can never succeed" from "try again later" inside that family.
158+
// Cause-chain HttpException FIRST, provider mapping only as fallback. The mappers synthesize
159+
// a status when they cannot read one off the body -- CustomLlmErrorMessage defaults to 400,
160+
// OpenAiErrorMessage to 500 -- and a synthetic value must never outrank the code the provider
161+
// actually put on the wire. Getting this backwards is not cosmetic: a real upstream 503 behind
162+
// an unparseable body would surface as CustomLlm's synthetic 400, be classified permanent, and
163+
// have a retryable failure dropped on its first delivery. Same precedence, and same reasoning,
164+
// as findProviderHttpStatus applies within its own chain walk.
165+
int status = findProviderHttpStatus(runtimeException)
166+
.or(() -> providerError.map(ErrorMessage::getCode).filter(ChatCompletionService::isErrorStatus))
167+
.orElse(Response.Status.INTERNAL_SERVER_ERROR.getStatusCode());
152168

153-
// No failIfProviderReportedHttpStatus here, unlike create() and the streaming handler. This method is
154-
// called only by the online-scoring subscribers, never from a resource, so a recovered status reaches no
155-
// HTTP client — while BaseRedisSubscriber.NON_RETRYABLE_EXCEPTIONS lists ClientErrorException, so turning
156-
// a rate limit into a 429 or a provider timeout into a 408 would make the subscriber ack and drop the
157-
// evaluation instead of honouring onlineScoring.maxRetries. Both are RetriableException upstream, so the
158-
// blanket 500 is what keeps them retryable.
159169
log.warn(UNEXPECTED_ERROR_CALLING_LLM_PROVIDER, runtimeException);
170+
171+
if (isPermanentFailure(status)) {
172+
// Non-retryable: the subscriber acks and removes on the first delivery instead of burning
173+
// maxRetries x pendingMessageDuration on a request whose outcome cannot change. This is the
174+
// OPIK-8240 case - a provider rejecting an oversized/invalid request rejects every retry of
175+
// it identically.
176+
throw new ClientErrorException(buildDetailedErrorMessage(runtimeException), status,
177+
runtimeException);
178+
}
179+
// Everything else stays a blanket 500 so it lands outside NON_RETRYABLE_EXCEPTIONS and honours
180+
// onlineScoring.maxRetries. Note this keeps transient 4xx (408/429) retryable even though their
181+
// real status is a client error, which is exactly the distinction ClientErrorException cannot
182+
// express on its own.
160183
throw new InternalServerErrorException(buildDetailedErrorMessage(runtimeException), runtimeException);
161184
} finally {
162185
// Close the Vertex client (reused across retries) to release its GAX threads; other providers self-reclaim.
@@ -292,6 +315,35 @@ private static boolean isErrorStatus(int status) {
292315
return family == Response.Status.Family.CLIENT_ERROR || family == Response.Status.Family.SERVER_ERROR;
293316
}
294317

318+
/**
319+
* Statuses in the 4xx family that a retry could still clear, so they must NOT be treated as permanent.
320+
*
321+
* <p>{@code 408 Request Timeout}, {@code 425 Too Early} and {@code 429 Too Many Requests} all say
322+
* "not now", not "not ever" - langchain4j models the latter two as {@code RetriableException} upstream.
323+
* They sit in the client-error family purely by HTTP numbering, which is why family alone is too blunt
324+
* a discriminator for retryability.
325+
*/
326+
private static final Set<Integer> TRANSIENT_CLIENT_ERRORS = Set.of(
327+
Response.Status.REQUEST_TIMEOUT.getStatusCode(),
328+
425, // Too Early - no jakarta.ws.rs.core.Response.Status constant
329+
429); // Too Many Requests - no jakarta.ws.rs.core.Response.Status constant
330+
331+
/**
332+
* Whether a provider status means "this exact request can never succeed", so the caller should give up
333+
* rather than retry.
334+
*
335+
* <p>True only for the client-error family minus {@link #TRANSIENT_CLIENT_ERRORS}: a malformed,
336+
* oversized, unauthorised or forbidden request is rejected identically however many times it is
337+
* replayed. Server errors are excluded - a 5xx is the provider having a bad moment, and that is the
338+
* textbook retry case. An unrecognised status is treated as retryable, matching
339+
* {@code BaseRedisSubscriber}'s own "unknown defaults to retryable for safety" stance.
340+
*/
341+
// Package-private for unit tests.
342+
static boolean isPermanentFailure(int status) {
343+
return familyOf(status) == Response.Status.Family.CLIENT_ERROR
344+
&& !TRANSIENT_CLIENT_ERRORS.contains(status);
345+
}
346+
295347
/**
296348
* The status langchain4j's own exception types stand for, used for providers whose clients raise them without an
297349
* {@link HttpException} in the chain. {@code ContentFilteredException} is covered by its
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.comet.opik.api.resources.v1.events;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.junit.jupiter.api.Test;
5+
import org.redisson.api.stream.StreamMessageId;
6+
7+
import static org.assertj.core.api.Assertions.assertThat;
8+
9+
/**
10+
* Pure-function coverage for {@link BaseRedisSubscriber#nextCursor(StreamMessageId)}, the mapping that
11+
* decides where the next {@code XAUTOCLAIM} scan resumes (OPIK-8240).
12+
*
13+
* <p>Separate from {@link BaseRedisSubscriberUnitTest} because these need no Redis mocks at all, and
14+
* living under that class's Mockito setup would trip strict stubbing.
15+
*/
16+
@DisplayName("BaseRedisSubscriber claim-cursor mapping")
17+
class BaseRedisSubscriberCursorTest {
18+
19+
@Test
20+
@DisplayName("Redis's 0-0 end-of-pass reply wraps back to the start of the pending list")
21+
void endOfPassWrapsToStart() {
22+
// Compared numerically on purpose. StreamMessageId.MIN and .ALL are wire sentinels that serialize
23+
// to "-" and "0", and NEITHER is equals() to the StreamMessageId(0, 0) that Redisson parses Redis's
24+
// literal 0-0 into -- verified against redisson 4.7.0. Matching on the constants would therefore
25+
// never fire, the cursor would stick at the end of the PEL, and the scan would stop finding
26+
// anything at all: strictly worse than the always-MIN behaviour this replaces.
27+
assertThat(BaseRedisSubscriber.nextCursor(new StreamMessageId(0, 0))).isEqualTo(StreamMessageId.MIN);
28+
}
29+
30+
@Test
31+
@DisplayName("A missing next-id is treated as end-of-pass rather than propagating a null")
32+
void nullNextIdWrapsToStart() {
33+
assertThat(BaseRedisSubscriber.nextCursor(null)).isEqualTo(StreamMessageId.MIN);
34+
}
35+
36+
@Test
37+
@DisplayName("A mid-scan position is carried forward unchanged, which is what walks past the first window")
38+
void midScanPositionIsCarriedForward() {
39+
// Redis caps each XAUTOCLAIM at COUNT * 10 entries EXAMINED, so without carrying this forward the
40+
// scan only ever sees the first ~100 pending entries and anything behind them is never reclaimed.
41+
var midScan = new StreamMessageId(1787891543627L, 0);
42+
assertThat(BaseRedisSubscriber.nextCursor(midScan)).isSameAs(midScan);
43+
}
44+
}

0 commit comments

Comments
 (0)