CtrlK
CommunityDocumentationLog inGet started
Tessl Logo

tessl/maven-org-springframework-ai--spring-ai-autoconfigure-retry

Spring Boot auto-configuration for AI retry capabilities with exponential backoff and intelligent HTTP error handling

Overview
Eval results
Files

edge-cases.mddocs/examples/

Edge Cases and Advanced Scenarios

Advanced scenarios, edge cases, and corner cases when using Spring AI Retry Auto Configuration.

Edge Case 1: Empty Response Body

Scenario

API returns error status but empty response body.

Behavior

ResponseErrorHandler uses placeholder text:

HTTP 503 - No response body available

Handling

@Bean
public ResponseErrorHandler customErrorHandler() {
    return new ResponseErrorHandler() {
        @Override
        public void handleError(ClientHttpResponse response) throws IOException {
            int status = response.getStatusCode().value();
            
            String body = readBody(response);
            if (body == null || body.trim().isEmpty()) {
                body = "No response body available";
            }
            
            String message = "HTTP " + status + " - " + body;
            
            if (status >= 500) {
                throw new TransientAiException(message);
            } else {
                throw new NonTransientAiException(message);
            }
        }
        
        private String readBody(ClientHttpResponse response) throws IOException {
            try (InputStream is = response.getBody()) {
                return new String(is.readAllBytes(), StandardCharsets.UTF_8);
            } catch (IOException e) {
                return null;
            }
        }
    };
}

Edge Case 2: Conflicting HTTP Code Configuration

Scenario

Same code in both onHttpCodes and excludeOnHttpCodes.

Configuration

spring.ai.retry.on-http-codes=429,503
spring.ai.retry.exclude-on-http-codes=503,401

Behavior

onHttpCodes takes precedence (highest priority).

Result:

  • 429: Retry (in onHttpCodes)
  • 503: Retry (in both, but onHttpCodes wins)
  • 401: No retry (in excludeOnHttpCodes only)

Best Practice

Avoid conflicts by keeping lists mutually exclusive:

spring.ai.retry.on-http-codes=429
spring.ai.retry.exclude-on-http-codes=401,403
# 503 handled by default (5xx = retry)

Edge Case 3: WebFlux Not on Classpath

Scenario

WebClientRequestException class doesn't exist.

Behavior

Auto-configuration silently skips WebFlux support:

try {
    Class<?> webClientRequestEx = Class.forName(
        "org.springframework.web.reactive.function.client.WebClientRequestException"
    );
    retryTemplateBuilder.retryOn(webClientRequestEx);
} catch (ClassNotFoundException ignore) {
    // Silently skip - no error or warning
}

Result:

  • With WebFlux: Retries on WebClientRequestException
  • Without WebFlux: Doesn't retry on WebClientRequestException (class doesn't exist)

Testing

@Test
void testWebFluxDetection() {
    boolean webFluxAvailable = false;
    try {
        Class.forName("org.springframework.web.reactive.function.client.WebClient");
        webFluxAvailable = true;
    } catch (ClassNotFoundException e) {
        // WebFlux not available
    }
    
    if (webFluxAvailable) {
        // Test WebClient retry behavior
    } else {
        // Test without WebFlux
    }
}

Edge Case 4: Very Large Response Body

Scenario

Error response body exceeds memory limits.

Problem

Reading entire response into memory can cause OOM.

Solution

Limit response body size:

@Bean
public ResponseErrorHandler safeSizeErrorHandler() {
    return new ResponseErrorHandler() {
        private static final int MAX_BODY_SIZE = 4096; // 4KB
        
        @Override
        public void handleError(ClientHttpResponse response) throws IOException {
            int status = response.getStatusCode().value();
            String body = readBodySafely(response, MAX_BODY_SIZE);
            
            String message = "HTTP " + status + " - " + body;
            
            if (status >= 500) {
                throw new TransientAiException(message);
            } else {
                throw new NonTransientAiException(message);
            }
        }
        
        private String readBodySafely(ClientHttpResponse response, int maxSize) 
                throws IOException {
            try (InputStream is = response.getBody()) {
                byte[] buffer = new byte[maxSize];
                int bytesRead = is.read(buffer);
                
                if (bytesRead == -1) {
                    return "No response body";
                }
                
                String body = new String(buffer, 0, bytesRead, StandardCharsets.UTF_8);
                
                // Check if there's more data
                if (is.read() != -1) {
                    body += "... (truncated)";
                }
                
                return body;
            }
        }
    };
}

Edge Case 5: Retry Count in Context

Scenario

Need to track retry attempts across multiple calls.

Implementation

public String trackRetries(String prompt) {
    AtomicInteger totalAttempts = new AtomicInteger(0);
    
    return retryTemplate.execute(context -> {
        int attemptNumber = totalAttempts.incrementAndGet();
        int retryCount = context.getRetryCount();
        
        log.info("Total attempts: {}, Retry count: {}", 
                 attemptNumber, retryCount);
        
        // Store in context for recovery callback
        context.setAttribute("totalAttempts", attemptNumber);
        
        return callApi(prompt);
    }, context -> {
        Integer attempts = (Integer) context.getAttribute("totalAttempts");
        log.error("Failed after {} total attempts", attempts);
        return "Fallback";
    });
}

Edge Case 6: Non-Standard HTTP Status Codes

Scenario

API returns non-standard codes (e.g., 460, 599).

Behavior

Classification follows standard logic:

  • 4xx range: Non-transient (unless onClientErrors=true)
  • 5xx range: Transient
  • Explicit codes in lists: As configured

Example

spring.ai.retry.on-http-codes=460,599
spring.ai.retry.exclude-on-http-codes=461

Results:

  • 460: Retry (in onHttpCodes)
  • 461: No retry (in excludeOnHttpCodes)
  • 462: No retry (4xx, default)
  • 599: Retry (in onHttpCodes)
  • 598: Retry (5xx, default)

Edge Case 7: Zero Max Attempts

Scenario

Set max-attempts to 0.

Configuration

spring.ai.retry.max-attempts=0

Behavior

No retries - immediate failure on first error.

Use Case

Disable retries temporarily for debugging:

@Profile("debug")
@Configuration
class NoRetryConfig {
    @Bean
    public RetryTemplate retryTemplate() {
        return RetryTemplate.builder()
            .maxAttempts(1)  // Or use 0 depending on implementation
            .build();
    }
}

Edge Case 8: Multiplier of 1 (Fixed Backoff)

Scenario

Set multiplier to 1 for fixed backoff.

Configuration

spring.ai.retry.backoff.multiplier=1
spring.ai.retry.backoff.initial-interval=2s
spring.ai.retry.backoff.max-interval=2s

Behavior

All retries wait exactly 2 seconds.

Calculation

Attempt 1: 2 × 1^0 = 2s
Attempt 2: 2 × 1^1 = 2s
Attempt 3: 2 × 1^2 = 2s
...

Use Case

Predictable timing for integration tests or APIs with fixed rate windows.

Edge Case 9: Concurrent Retry Contexts

Scenario

Multiple threads using same RetryTemplate.

Behavior

Each thread gets its own RetryContext - thread-safe.

Verification

@Test
void testConcurrentRetries() throws InterruptedException {
    RetryTemplate retryTemplate = createRetryTemplate();
    
    ExecutorService executor = Executors.newFixedThreadPool(10);
    CountDownLatch latch = new CountDownLatch(10);
    
    for (int i = 0; i < 10; i++) {
        final int threadId = i;
        executor.submit(() -> {
            try {
                retryTemplate.execute(context -> {
                    // Each thread has independent context
                    log.info("Thread {} retry count: {}", 
                             threadId, context.getRetryCount());
                    return performOperation();
                });
            } finally {
                latch.countDown();
            }
        });
    }
    
    latch.await(30, TimeUnit.SECONDS);
    executor.shutdown();
}

Edge Case 10: Exception in Recovery Callback

Scenario

RecoveryCallback itself throws exception.

Behavior

Exception propagated to caller.

Handling

public String safeRecovery(String prompt) {
    return retryTemplate.execute(
        context -> callApi(prompt),
        context -> {
            try {
                return getExpensiveFallback();
            } catch (Exception e) {
                log.error("Recovery failed", e);
                // Return simple fallback instead of throwing
                return "Fallback unavailable";
            }
        }
    );
}

Edge Case 11: Negative or Very Large Multiplier

Scenario

Multiplier set to invalid value.

Configuration (Invalid)

spring.ai.retry.backoff.multiplier=-1  # Invalid
spring.ai.retry.backoff.multiplier=1000  # Extreme

Validation

Spring Boot validation should catch this, but implement defensive checks:

@ConfigurationProperties("spring.ai.retry")
@Validated
public class SpringAiRetryProperties {
    
    @Min(1)
    private int multiplier = 5;
    
    public void setMultiplier(int multiplier) {
        if (multiplier < 1) {
            throw new IllegalArgumentException("Multiplier must be >= 1");
        }
        if (multiplier > 100) {
            log.warn("Very large multiplier ({}), backoff may be excessive", multiplier);
        }
        this.multiplier = multiplier;
    }
}

Edge Case 12: Null or Malformed Duration

Scenario

Duration property set to invalid value.

Configuration (Invalid)

spring.ai.retry.backoff.initial-interval=invalid
spring.ai.retry.backoff.max-interval=

Handling

Spring Boot throws PropertyBindingException on startup.

Defensive Code

public void setInitialInterval(Duration initialInterval) {
    if (initialInterval == null) {
        throw new IllegalArgumentException("Initial interval cannot be null");
    }
    if (initialInterval.isNegative() || initialInterval.isZero()) {
        throw new IllegalArgumentException("Initial interval must be positive");
    }
    this.initialInterval = initialInterval;
}

Edge Case 13: Bean Ordering with Multiple RetryTemplates

Scenario

Multiple RetryTemplate beans defined.

Problem

Which one gets injected?

Solution

Use qualifiers:

@Configuration
class MultipleRetryConfig {
    
    @Bean
    @Primary
    public RetryTemplate defaultRetryTemplate() {
        return RetryTemplate.builder()
            .maxAttempts(10)
            .exponentialBackoff(2000, 5, 180000)
            .build();
    }
    
    @Bean
    @Qualifier("aggressive")
    public RetryTemplate aggressiveRetryTemplate() {
        return RetryTemplate.builder()
            .maxAttempts(20)
            .exponentialBackoff(500, 3, 60000)
            .build();
    }
}

@Service
class MyService {
    private final RetryTemplate defaultRetry;
    private final RetryTemplate aggressiveRetry;
    
    public MyService(
            RetryTemplate defaultRetry,  // Injects @Primary
            @Qualifier("aggressive") RetryTemplate aggressiveRetry) {
        this.defaultRetry = defaultRetry;
        this.aggressiveRetry = aggressiveRetry;
    }
}

Edge Case 14: Retry During Application Shutdown

Scenario

Retry in progress when application shuts down.

Problem

May cause incomplete operations or hung threads.

Solution

Implement graceful shutdown:

@Component
class GracefulRetryShutdown implements DisposableBean {
    
    private final AtomicBoolean shuttingDown = new AtomicBoolean(false);
    private final Set<CompletableFuture<?>> inFlightRetries = ConcurrentHashMap.newKeySet();
    
    public String executeWithShutdownAwareness(Supplier<String> operation) {
        if (shuttingDown.get()) {
            throw new IllegalStateException("Application is shutting down");
        }
        
        CompletableFuture<String> future = CompletableFuture.supplyAsync(operation);
        inFlightRetries.add(future);
        
        try {
            return future.get();
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            inFlightRetries.remove(future);
        }
    }
    
    @Override
    public void destroy() throws Exception {
        log.info("Graceful shutdown initiated. {} retries in flight", 
                 inFlightRetries.size());
        
        shuttingDown.set(true);
        
        // Wait for in-flight retries (with timeout)
        CompletableFuture.allOf(inFlightRetries.toArray(new CompletableFuture[0]))
            .orTimeout(30, TimeUnit.SECONDS)
            .join();
        
        log.info("All retries completed. Shutdown complete.");
    }
}

Edge Case 15: Retry with Stateful Operations

Scenario

Operation has side effects that persist across retries.

Problem

Retries may cause duplicate operations.

Solution

Use idempotency keys:

public String idempotentRetry(String request) {
    String idempotencyKey = generateIdempotencyKey(request);
    
    return retryTemplate.execute(context -> {
        // Check if operation already completed
        Optional<String> cached = checkIdempotencyCache(idempotencyKey);
        if (cached.isPresent()) {
            log.info("Operation already completed, returning cached result");
            return cached.get();
        }
        
        // Perform operation with idempotency key
        String result = performOperation(request, idempotencyKey);
        
        // Cache result
        cacheIdempotentResult(idempotencyKey, result);
        
        return result;
    });
}

private String generateIdempotencyKey(String request) {
    return DigestUtils.md5Hex(request + System.currentTimeMillis());
}

Edge Case 16: Timeout Shorter Than Backoff

Scenario

Total timeout < max retry backoff interval.

Configuration (Problematic)

spring.ai.retry.backoff.max-interval=60s
# But application timeout is 30s

Problem

Later retries never execute due to timeout.

Solution

Ensure backoff fits within timeout budget:

# If timeout is 30s total:
spring.ai.retry.max-attempts=5
spring.ai.retry.backoff.initial-interval=1s
spring.ai.retry.backoff.multiplier=2
spring.ai.retry.backoff.max-interval=5s
# Total: 1 + 2 + 4 + 5 + 5 = 17s (fits in 30s)

Edge Case 17: Custom Exception in Retry Chain

Scenario

Custom exception that should be retried but doesn't extend TransientAiException.

Solution

Create custom RetryTemplate:

@Bean
public RetryTemplate customExceptionRetryTemplate() {
    return RetryTemplate.builder()
        .maxAttempts(10)
        .retryOn(TransientAiException.class)
        .retryOn(CustomRetryableException.class)  // Custom exception
        .retryOn(ResourceAccessException.class)
        .exponentialBackoff(2000, 5, 180000)
        .build();
}

class CustomRetryableException extends RuntimeException {
    public CustomRetryableException(String message) {
        super(message);
    }
}

Edge Case 18: Metrics Collection Performance Impact

Scenario

Heavy metrics collection during retries impacts performance.

Solution

Use sampling or async metrics:

@Configuration
class PerformantMetricsConfig {
    
    @Bean
    public RetryListener sampledMetricsListener(MeterRegistry registry) {
        Counter retryCounter = registry.counter("ai.retry.sampled");
        ThreadLocalRandom random = ThreadLocalRandom.current();
        
        return new RetryListener() {
            @Override
            public <T, E extends Throwable> void onError(
                    RetryContext context,
                    RetryCallback<T, E> callback,
                    Throwable throwable) {
                // Sample 10% of retries
                if (random.nextDouble() < 0.1) {
                    retryCounter.increment();
                    // Record detailed metrics
                    recordDetailedMetrics(context, throwable);
                }
            }
        };
    }
    
    private void recordDetailedMetrics(RetryContext context, Throwable throwable) {
        // Async metrics recording
        CompletableFuture.runAsync(() -> {
            // Heavy metrics work here
        });
    }
}

Best Practices for Edge Cases

  1. Validate Configuration: Always validate properties at startup
  2. Set Reasonable Limits: Cap backoff intervals and attempts
  3. Handle Null/Empty: Check for null/empty responses
  4. Thread Safety: Ensure custom code is thread-safe
  5. Graceful Degradation: Always have fallback strategies
  6. Test Edge Cases: Include edge case tests in test suite
  7. Monitor Anomalies: Alert on unusual retry patterns
  8. Document Assumptions: Document expected behavior for edge cases

Testing Edge Cases

@SpringBootTest
class EdgeCaseTests {
    
    @Autowired
    private RetryTemplate retryTemplate;
    
    @Test
    void testEmptyResponseBody() {
        // Test with mock that returns empty body
    }
    
    @Test
    void testConflictingCodes() {
        // Test precedence rules
    }
    
    @Test
    void testZeroMaxAttempts() {
        // Test immediate failure
    }
    
    @Test
    void testConcurrentRetries() {
        // Test thread safety
    }
    
    @Test
    void testExceptionInRecovery() {
        // Test recovery failure handling
    }
}

Next Steps

  • Review real-world scenarios
  • Check configuration reference
  • Explore auto-configuration details
tessl i tessl/maven-org-springframework-ai--spring-ai-autoconfigure-retry@1.1.1

docs

examples

edge-cases.md

real-world-scenarios.md

index.md

tile.json