Skip to content

Commit 2ed8478

Browse files
geekypunkclaude
andcommitted
fix: address review — anchor the shrink budget to the payload, not the ceiling
Six issues from review, one of which partly defeated the original fix. 1. The budget was seeded from `maxChars` rather than from the text being sent, so `truncate` (which only cuts when length > budget) made the first halvings byte-identical resends. A document below maxChars/2^4 — 1,875 chars at the default — could never shrink at all: five identical rejected calls and then the same permanent index hole this change exists to close, at 5x the cost. The budget now starts at min(maxChars, longest input). 2. The batch comment claimed one oversized member "cannot cost the short ones any content". Untrue: the budget is per-request, so a halving forced by one text also trims every other member above the new budget. The comment now says so, and seeds from the longest member. Also notes that a provider may reject on the request's aggregate token count, where trimming members is the right lever but the floor may arrive before the batch fits. 3. Intermediate attempts ran through `attempt(..., mayFailOpen=false)`, whose `handleFailure` logs before it rethrows — so every successful shrink emitted "Embedding failed" with a stack trace for a call that then succeeded, and a terminal failure logged twice with contradictory failOpen values. The retry loop is now `attemptOrThrow`, with logging left to the single terminal `handleFailure`. 4. The 4-arg `attempt` overload became unreachable once both callers moved to `embedWithShrink`; removed. 5. `givesUpAfterTheShrinkFloor...` asserted the attempt cap, not the floor — at a 30,000 ceiling the cap is always reached first. Renamed to say what it tests, and a real floor test added at a 1,500 ceiling. 6. The shrink log printed the budget as though it were the payload size, which would mislead exactly the person debugging (1). New tests: shrinksRelativeToTheInputNotTheConfiguredCeiling (regression for 1, fails on the previous commit) and stopsAtTheCharacterFloorRatherThanEmbedding ATokenOfContent (coverage for 5). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f93f483 commit 2ed8478

2 files changed

Lines changed: 104 additions & 18 deletions

File tree

backend/src/main/java/com/dbaagent/service/EmbeddingService.java

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,8 @@ public List<Double> createEmbedding(String text) {
118118
LlmEmbeddingProvider provider = registry.embeddingProvider(credentials.providerId());
119119
return embedWithShrink(provider, credentials,
120120
budget -> provider.embed(truncate(text, budget), credentials),
121-
List.of());
121+
List.of(),
122+
text == null ? 0 : text.length());
122123
}
123124

124125
/**
@@ -140,25 +141,34 @@ public List<Double> createEmbedding(String text) {
140141
* about a rate limit or a bad credential.
141142
*/
142143
private <T> T embedWithShrink(LlmEmbeddingProvider provider, LlmCredentials credentials,
143-
java.util.function.IntFunction<T> call, T failOpenValue) {
144-
int budget = maxChars;
144+
java.util.function.IntFunction<T> call, T failOpenValue,
145+
int longestInput) {
146+
// Anchor to what is actually being sent, not to the configured ceiling. Seeding
147+
// from maxChars makes the first halvings no-ops whenever the input is already
148+
// under it — the same bytes, resent and rejected — and leaves anything below
149+
// maxChars/2^MAX_SHRINK_ATTEMPTS unable to shrink at all, which is the very
150+
// document this method exists to rescue.
151+
int budget = Math.min(maxChars, Math.max(longestInput, 1));
145152
for (int shrink = 0; ; shrink++) {
146153
final int attemptBudget = budget;
147154
try {
148-
// mayFailOpen=false: a CONTEXT_LENGTH rejection has to reach us. Fail-open
149-
// would turn it into an empty vector indistinguishable from a real one.
150-
return attempt(provider, credentials, () -> call.apply(attemptBudget), failOpenValue, false);
155+
// attemptOrThrow, not attempt: a CONTEXT_LENGTH rejection has to reach us
156+
// unlogged. Fail-open would turn it into an empty vector indistinguishable
157+
// from a real one, and the logging path would report a failure for a call
158+
// that is about to succeed.
159+
return attemptOrThrow(provider, credentials, () -> call.apply(attemptBudget));
151160
} catch (RuntimeException e) {
152161
LlmErrorCategory category = provider.classify(e);
153162
if (category != LlmErrorCategory.CONTEXT_LENGTH
154163
|| shrink >= MAX_SHRINK_ATTEMPTS
155164
|| budget <= MIN_EMBEDDING_CHARS) {
156165
// Out of room to shrink, or not a length problem: honour the operator's
157-
// fail-open setting exactly as before this method existed.
166+
// fail-open setting exactly as before this method existed. This is the
167+
// one place a terminal failure is logged.
158168
return handleFailure(category, credentials, e, failOpenValue, failOpen);
159169
}
160170
int next = Math.max(MIN_EMBEDDING_CHARS, budget / 2);
161-
log.warn("Embedding input rejected as too long at {} chars; retrying at {}", budget, next);
171+
log.warn("Embedding rejected as too long at {} chars sent; retrying at {}", budget, next);
162172
budget = next;
163173
}
164174
}
@@ -172,12 +182,23 @@ private <T> T embedWithShrink(LlmEmbeddingProvider provider, LlmCredentials cred
172182
public List<List<Double>> createEmbeddings(List<String> texts) {
173183
LlmCredentials credentials = requireCredentials();
174184
LlmEmbeddingProvider provider = registry.embeddingProvider(credentials.providerId());
175-
// Shrinking the budget only touches texts longer than it, so one oversized member
176-
// of a batch cannot cost the short ones any content.
185+
// The budget is per-request, not per-member, so a halving forced by one oversized
186+
// text also trims every other member above the new budget. That is a real cost and
187+
// not something this method can avoid: the provider rejects the request, not a
188+
// document, and it does not say which member was at fault. Seeding from the
189+
// longest member keeps the first halving meaningful; callers that cannot afford
190+
// collateral truncation should embed individually.
191+
//
192+
// Note also that a provider may reject on the request's AGGREGATE token count, in
193+
// which case trimming members is the right lever but the floor may be reached
194+
// before the batch fits.
195+
int longest = texts.stream().filter(java.util.Objects::nonNull)
196+
.mapToInt(String::length).max().orElse(0);
177197
return embedWithShrink(provider, credentials,
178198
budget -> provider.embedBatch(
179199
texts.stream().map(t -> truncate(t, budget)).toList(), credentials),
180-
Collections.nCopies(texts.size(), List.of()));
200+
Collections.nCopies(texts.size(), List.of()),
201+
longest);
181202
}
182203

183204
/**
@@ -230,10 +251,6 @@ public int dimensions() {
230251
* <p>The decision is {@link LlmErrorCategory#isRetryable()}, not a message substring:
231252
* that taxonomy exists precisely so retry policy stops being provider-specific.
232253
*/
233-
private <T> T attempt(LlmEmbeddingProvider provider, LlmCredentials credentials,
234-
Supplier<T> call, T failOpenValue) {
235-
return attempt(provider, credentials, call, failOpenValue, failOpen);
236-
}
237254

238255
/**
239256
* As above, but with fail-open decided per call site rather than by configuration.
@@ -244,14 +261,33 @@ private <T> T attempt(LlmEmbeddingProvider provider, LlmCredentials credentials,
244261
*/
245262
private <T> T attempt(LlmEmbeddingProvider provider, LlmCredentials credentials,
246263
Supplier<T> call, T failOpenValue, boolean mayFailOpen) {
264+
try {
265+
return attemptOrThrow(provider, credentials, call);
266+
} catch (RuntimeException e) {
267+
return handleFailure(provider.classify(e), credentials, e, failOpenValue, mayFailOpen);
268+
}
269+
}
270+
271+
/**
272+
* The retry loop with no opinion about failure: exhausted retries rethrow.
273+
*
274+
* <p>Separating this from {@link #handleFailure} is what lets {@link #embedWithShrink}
275+
* treat a CONTEXT_LENGTH rejection as a step in a working algorithm rather than an
276+
* incident. Routing intermediate attempts through the logging path made every
277+
* successful shrink emit "Embedding failed" and a stack trace for a call that then
278+
* succeeded — noise that would fire any alert keyed on that string, and log a terminal
279+
* failure twice with contradictory failOpen values.
280+
*/
281+
private <T> T attemptOrThrow(LlmEmbeddingProvider provider, LlmCredentials credentials,
282+
Supplier<T> call) {
247283
for (int retries = 0; ; retries++) {
248284
try {
249285
return call.get();
250286
} catch (RuntimeException e) {
251287
LlmErrorCategory category = provider.classify(e);
252288
if (retries >= MAX_RETRY_ATTEMPTS || !category.isRetryable()
253289
|| !backoff(retries, category, credentials, retryAfterHint(provider, e))) {
254-
return handleFailure(category, credentials, e, failOpenValue, mayFailOpen);
290+
throw e;
255291
}
256292
}
257293
}

backend/src/test/java/com/dbaagent/service/EmbeddingServiceTest.java

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -509,7 +509,7 @@ void shrinksTheInputWhenTheProviderRejectsItAsTooLong() {
509509
}
510510

511511
@Test
512-
void givesUpAfterTheShrinkFloorAndStillHonoursFailOpen() {
512+
void givesUpAfterTheAttemptCapAndStillHonoursFailOpen() {
513513
var resolver = mock(LlmConfigResolver.class);
514514
var registry = mock(LlmProviderRegistry.class);
515515
var provider = mock(LlmEmbeddingProvider.class);
@@ -522,7 +522,8 @@ void givesUpAfterTheShrinkFloorAndStillHonoursFailOpen() {
522522

523523
var service = new EmbeddingService(resolver, registry, 30_000, true);
524524

525-
// Shrinking is bounded, so a pathological document cannot loop forever.
525+
// Bounded by MAX_SHRINK_ATTEMPTS, not by MIN_EMBEDDING_CHARS: at a 30,000
526+
// ceiling the cap is reached first. The floor is covered separately below.
526527
assertThat(service.createEmbedding("x".repeat(50_000))).isEmpty();
527528
verify(provider, times(5)).embed(anyString(), any()); // 30k, 15k, 7.5k, 3.75k, 1.875k
528529
}
@@ -545,4 +546,53 @@ void doesNotShrinkForFailuresThatShrinkingCannotFix() {
545546
// just multiply the failed calls.
546547
verify(provider, times(1)).embed(anyString(), any());
547548
}
549+
550+
@Test
551+
void shrinksRelativeToTheInputNotTheConfiguredCeiling() {
552+
var resolver = mock(LlmConfigResolver.class);
553+
var registry = mock(LlmProviderRegistry.class);
554+
var provider = mock(LlmEmbeddingProvider.class);
555+
556+
when(resolver.resolveEmbedding()).thenReturn(creds());
557+
when(registry.embeddingProvider("openai")).thenReturn(provider);
558+
when(provider.embed(anyString(), any())).thenAnswer(call -> {
559+
String sent = call.getArgument(0);
560+
if (sent.length() > 2_000) {
561+
throw new RuntimeException("maximum context length is 8192 tokens");
562+
}
563+
return List.of(0.5);
564+
});
565+
when(provider.classify(any())).thenReturn(LlmErrorCategory.CONTEXT_LENGTH);
566+
567+
// 4,000 chars against a 30,000 ceiling. Seeding the budget from the ceiling would
568+
// make the first halvings no-ops — 30,000 and 15,000 both send the same 4,000
569+
// bytes — and burn the attempt cap on identical rejected calls.
570+
new EmbeddingService(resolver, registry, 30_000, true)
571+
.createEmbedding("x".repeat(4_000));
572+
573+
var captor = ArgumentCaptor.forClass(String.class);
574+
verify(provider, times(2)).embed(captor.capture(), any());
575+
assertThat(captor.getAllValues().get(0)).hasSize(4_000);
576+
assertThat(captor.getAllValues().get(1)).hasSize(2_000);
577+
}
578+
579+
@Test
580+
void stopsAtTheCharacterFloorRatherThanEmbeddingATokenOfContent() {
581+
var resolver = mock(LlmConfigResolver.class);
582+
var registry = mock(LlmProviderRegistry.class);
583+
var provider = mock(LlmEmbeddingProvider.class);
584+
585+
when(resolver.resolveEmbedding()).thenReturn(creds());
586+
when(registry.embeddingProvider("openai")).thenReturn(provider);
587+
when(provider.embed(anyString(), any()))
588+
.thenThrow(new RuntimeException("maximum context length is 8192 tokens"));
589+
when(provider.classify(any())).thenReturn(LlmErrorCategory.CONTEXT_LENGTH);
590+
591+
// A ceiling low enough that MIN_EMBEDDING_CHARS (1,000) is what stops the loop,
592+
// not the attempt cap: 1,500 -> 1,000 -> give up.
593+
var service = new EmbeddingService(resolver, registry, 1_500, true);
594+
595+
assertThat(service.createEmbedding("x".repeat(5_000))).isEmpty();
596+
verify(provider, times(2)).embed(anyString(), any());
597+
}
548598
}

0 commit comments

Comments
 (0)