Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,12 @@ 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()
).onErrorMap(e -> 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,12 @@ 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()
).onErrorMap(e -> 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));
}
}
29 changes: 28 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,39 @@ 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
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
# 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.

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
#MAIL
spring.mail.host=smtp.gmail.com
spring.mail.port=587
Expand Down