Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cs25-service/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ dependencies {
implementation "org.springframework.ai:spring-ai-starter-mcp-client:1.0.0"
implementation "org.springframework.ai:spring-ai-starter-mcp-client-webflux:1.0.0"

// resilience4j
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.3.0'
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.3.0'
implementation 'io.github.resilience4j:resilience4j-retry:2.3.0'
implementation 'io.github.resilience4j:resilience4j-reactor:2.3.0'

//JavaMailSender
implementation 'jakarta.mail:jakarta.mail-api:2.1.0'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.example.cs25service.domain.ai.exception.AiException;
import com.example.cs25service.domain.ai.exception.AiExceptionCode;
import com.example.cs25service.domain.ai.resilience.AiResilience;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
Expand All @@ -11,18 +12,23 @@
public class ClaudeChatClient implements AiChatClient {

private final ChatClient anthropicChatClient;
private final AiResilience resilience;

public ClaudeChatClient(@Qualifier("anthropicChatClient") ChatClient anthropicChatClient) {
public ClaudeChatClient(@Qualifier("anthropicChatClient") ChatClient anthropicChatClient,
AiResilience resilience) {
this.anthropicChatClient = anthropicChatClient;
this.resilience = resilience;
}

@Override
public String call(String systemPrompt, String userPrompt) {
return anthropicChatClient.prompt()
.system(systemPrompt)
.user(userPrompt)
.call()
.content();
return resilience.executeSync("claude", () ->
anthropicChatClient.prompt()
.system(systemPrompt)
.user(userPrompt)
.call()
.content()
);
}

@Override
Expand All @@ -32,13 +38,15 @@ public ChatClient raw() {

@Override
public Flux<String> stream(String systemPrompt, String userPrompt) {
return anthropicChatClient.prompt()
.system(systemPrompt)
.user(userPrompt)
.stream()
.content()
.onErrorResume(error -> {
throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR);
});
return resilience.executeStream("claude", () ->
anthropicChatClient.prompt()
.system(systemPrompt)
.user(userPrompt)
.stream()
.content()
.onErrorResume(error -> {
throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR);
})
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.example.cs25service.domain.ai.exception.AiException;
import com.example.cs25service.domain.ai.exception.AiExceptionCode;
import com.example.cs25service.domain.ai.resilience.AiResilience;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
Expand All @@ -11,19 +12,25 @@
public class OpenAiChatClient implements AiChatClient {

private final ChatClient openAiChatClient;
private final AiResilience resilience;

public OpenAiChatClient(@Qualifier("openAiChatModelClient") ChatClient openAiChatClient) {
public OpenAiChatClient(
@Qualifier("openAiChatModelClient") ChatClient openAiChatClient,
AiResilience resilience) {
this.openAiChatClient = openAiChatClient;
this.resilience = resilience;
}

@Override
public String call(String systemPrompt, String userPrompt) {
return openAiChatClient.prompt()
.system(systemPrompt)
.user(userPrompt)
.call()
.content()
.trim();
return resilience.executeSync("openai", () ->
openAiChatClient.prompt()
.system(systemPrompt)
.user(userPrompt)
.call()
.content()
.trim()
);
}

@Override
Expand All @@ -33,13 +40,15 @@ public ChatClient raw() {

@Override
public Flux<String> stream(String systemPrompt, String userPrompt) {
return openAiChatClient.prompt()
.system(systemPrompt)
.user(userPrompt)
.stream()
.content()
.onErrorResume(error -> {
throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR);
});
return resilience.executeStream("openai", () ->
openAiChatClient.prompt()
.system(systemPrompt)
.user(userPrompt)
.stream()
.content()
.onErrorResume(error -> {
throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR);
})
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.example.cs25service.domain.ai.resilience;

import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator;
import io.github.resilience4j.reactor.retry.RetryOperator;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryRegistry;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;

@Component
@RequiredArgsConstructor
public class AiResilience {

private final CircuitBreakerRegistry cbRegistry;
private final RetryRegistry retryRegistry;

/**
* 동기 호출: Retry → CircuitBreaker 순서
*/
public <T> T executeSync(String name, Supplier<T> supplier) {
CircuitBreaker cb = cbRegistry.circuitBreaker(name);
Retry retry = retryRegistry.retry(name);

Supplier<T> withRetry = Retry.decorateSupplier(retry, supplier);
Supplier<T> withCb = CircuitBreaker.decorateSupplier(cb, withRetry);

return withCb.get();
}

/**
* Flux 스트리밍: RetryOperator → CircuitBreakerOperator 순서
*/
public <T> Flux<T> executeStream(String name, Supplier<Flux<T>> supplier) {
CircuitBreaker cb = cbRegistry.circuitBreaker(name);
Retry retry = retryRegistry.retry(name);

return supplier.get()
.transformDeferred(RetryOperator.of(retry))
.transformDeferred(CircuitBreakerOperator.of(cb));
}
}
21 changes: 20 additions & 1 deletion cs25-service/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,31 @@ spring.ai.mcp.client.enabled=true
spring.ai.mcp.client.type=SYNC
spring.ai.mcp.client.request-timeout=60s
spring.ai.mcp.client.root-change-notification=false
# STDIO Connect: Brave Search
# Brave Search
spring.ai.mcp.client.stdio.connections.brave.command=server-brave-search
spring.ai.mcp.client.stdio.connections.brave.args[0]=--transport
spring.ai.mcp.client.stdio.connections.brave.args[1]=stdio
spring.ai.mcp.client.stdio.connections.brave.env.BRAVE_API_KEY=${BRAVE_API_KEY}
spring.autoconfigure.exclude=org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration
# CircuitBreaker
resilience4j.circuitbreaker.configs.default.slidingWindowType=COUNT_BASED
resilience4j.circuitbreaker.configs.default.slidingWindowSize=10
resilience4j.circuitbreaker.configs.default.failureRateThreshold=50
resilience4j.circuitbreaker.configs.default.slowCallDurationThreshold=5s
resilience4j.circuitbreaker.configs.default.slowCallRateThreshold=70
resilience4j.circuitbreaker.configs.default.minimumNumberOfCalls=6
resilience4j.circuitbreaker.configs.default.waitDurationInOpenState=30m
resilience4j.circuitbreaker.configs.default.permittedNumberOfCallsInHalfOpenState=1
resilience4j.circuitbreaker.configs.default.automaticTransitionFromOpenToHalfOpenEnabled=true
resilience4j.circuitbreaker.instances.openai.baseConfig=default
resilience4j.circuitbreaker.instances.claude.baseConfig=default
# Retry
resilience4j.retry.configs.default.maxAttempts=2
resilience4j.retry.configs.default.waitDuration=200ms
resilience4j.retry.configs.default.enableExponentialBackoff=true
resilience4j.retry.configs.default.exponentialBackoffMultiplier=2
resilience4j.retry.instances.openai.baseConfig=default
resilience4j.retry.instances.claude.baseConfig=default
Comment on lines +93 to +98
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

4xx 클라이언트 오류는 Retry/CB 집계에서 제외하는 것을 강력 권장합니다.

현재 스트림 경로에서 오류가 AiException으로 래핑되어 올라오면 예외 타입 기반 필터링이 어려워집니다(아래 클라이언트 파일 코멘트의 리팩터를 먼저 적용 권장). 그 후 아래와 같이 4xx를 무시하도록 설정하면 불필요한 재시도/CB 오픈을 줄일 수 있습니다. 429(레이트리밋)는 유지해 재시도 대상으로 남기는 편이 좋습니다.

아래 설정 추가를 제안드립니다(예: default config에 4xx 무시):

 resilience4j.retry.configs.default.maxAttempts=2
 resilience4j.retry.configs.default.waitDuration=200ms
 resilience4j.retry.configs.default.enableExponentialBackoff=true
 resilience4j.retry.configs.default.exponentialBackoffMultiplier=2
+resilience4j.retry.configs.default.ignoreExceptions[0]=org.springframework.web.reactive.function.client.WebClientResponseException$BadRequest
+resilience4j.retry.configs.default.ignoreExceptions[1]=org.springframework.web.reactive.function.client.WebClientResponseException$Unauthorized
+resilience4j.retry.configs.default.ignoreExceptions[2]=org.springframework.web.reactive.function.client.WebClientResponseException$Forbidden
+resilience4j.retry.configs.default.ignoreExceptions[3]=org.springframework.web.reactive.function.client.WebClientResponseException$NotFound
 resilience4j.retry.instances.openai.baseConfig=default
 resilience4j.retry.instances.claude.baseConfig=default

CircuitBreaker 쪽도 동일하게 4xx를 무시하도록 설정하면 더 안정적입니다.

 resilience4j.circuitbreaker.configs.default.minimumNumberOfCalls=6
 resilience4j.circuitbreaker.configs.default.waitDurationInOpenState=30m
 resilience4j.circuitbreaker.configs.default.permittedNumberOfCallsInHalfOpenState=1
 resilience4j.circuitbreaker.configs.default.automaticTransitionFromOpenToHalfOpenEnabled=true
+resilience4j.circuitbreaker.configs.default.ignoreExceptions[0]=org.springframework.web.reactive.function.client.WebClientResponseException$BadRequest
+resilience4j.circuitbreaker.configs.default.ignoreExceptions[1]=org.springframework.web.reactive.function.client.WebClientResponseException$Unauthorized
+resilience4j.circuitbreaker.configs.default.ignoreExceptions[2]=org.springframework.web.reactive.function.client.WebClientResponseException$Forbidden
+resilience4j.circuitbreaker.configs.default.ignoreExceptions[3]=org.springframework.web.reactive.function.client.WebClientResponseException$NotFound

참고: 이 설정이 효과를 내기 위해서는 아래 클라이언트 코드에서 onErrorResume로 즉시 AiException을 던지던 부분을 Resilience4j 적용 “이후”로 이동해야 합니다(원인 유지).

🤖 Prompt for AI Agents
In cs25-service/src/main/resources/application.properties around lines 89 to 94,
the Retry (and CircuitBreaker) configuration currently retries on all errors;
add configuration to exclude 4xx client errors from retry and CB evaluation
(keep 429 as retryable) by updating the default retry/circuit breaker configs to
ignore HTTP 4xx status codes (or exceptions that wrap 4xx) and add a specific
rule to treat 429 as retryable; also ensure the client code change (move the
onErrorResume that wraps/throws AiException) is applied so that the original
HTTP status-based exception is visible to Resilience4j (i.e., stop wrapping into
AiException before resilience filters run), allowing the new ignore-4xx rules to
take effect.

#MAIL
spring.mail.host=smtp.gmail.com
spring.mail.port=587
Expand Down