Skip to content

Commit 62dde4e

Browse files
authored
Feat/414 : Ai피드백 Resilence4j를 활용한 CB,Retry 적용 (#415)
* feat:레질리언스4j 빌드그래들 설정 * feat:레질리언스 appliciation properties 설정 * feat:fallback구조 CB,Retry 데코레이터 패턴 적용 * chore:스트림 오류 매핑 적용 이후로 이동 * chore:4xx 오류 집계 제외
1 parent a34fbb6 commit 62dde4e

5 files changed

Lines changed: 119 additions & 30 deletions

File tree

cs25-service/build.gradle

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ dependencies {
3333
implementation "org.springframework.ai:spring-ai-starter-mcp-client:1.0.0"
3434
implementation "org.springframework.ai:spring-ai-starter-mcp-client-webflux:1.0.0"
3535

36+
// resilience4j
37+
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.3.0'
38+
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.3.0'
39+
implementation 'io.github.resilience4j:resilience4j-retry:2.3.0'
40+
implementation 'io.github.resilience4j:resilience4j-reactor:2.3.0'
41+
3642
//JavaMailSender
3743
implementation 'jakarta.mail:jakarta.mail-api:2.1.0'
3844

cs25-service/src/main/java/com/example/cs25service/domain/ai/client/ClaudeChatClient.java

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.example.cs25service.domain.ai.exception.AiException;
44
import com.example.cs25service.domain.ai.exception.AiExceptionCode;
5+
import com.example.cs25service.domain.ai.resilience.AiResilience;
56
import org.springframework.ai.chat.client.ChatClient;
67
import org.springframework.beans.factory.annotation.Qualifier;
78
import org.springframework.stereotype.Component;
@@ -11,18 +12,23 @@
1112
public class ClaudeChatClient implements AiChatClient {
1213

1314
private final ChatClient anthropicChatClient;
15+
private final AiResilience resilience;
1416

15-
public ClaudeChatClient(@Qualifier("anthropicChatClient") ChatClient anthropicChatClient) {
17+
public ClaudeChatClient(@Qualifier("anthropicChatClient") ChatClient anthropicChatClient,
18+
AiResilience resilience) {
1619
this.anthropicChatClient = anthropicChatClient;
20+
this.resilience = resilience;
1721
}
1822

1923
@Override
2024
public String call(String systemPrompt, String userPrompt) {
21-
return anthropicChatClient.prompt()
22-
.system(systemPrompt)
23-
.user(userPrompt)
24-
.call()
25-
.content();
25+
return resilience.executeSync("claude", () ->
26+
anthropicChatClient.prompt()
27+
.system(systemPrompt)
28+
.user(userPrompt)
29+
.call()
30+
.content()
31+
);
2632
}
2733

2834
@Override
@@ -32,13 +38,12 @@ public ChatClient raw() {
3238

3339
@Override
3440
public Flux<String> stream(String systemPrompt, String userPrompt) {
35-
return anthropicChatClient.prompt()
36-
.system(systemPrompt)
37-
.user(userPrompt)
38-
.stream()
39-
.content()
40-
.onErrorResume(error -> {
41-
throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR);
42-
});
41+
return resilience.executeStream("claude", () ->
42+
anthropicChatClient.prompt()
43+
.system(systemPrompt)
44+
.user(userPrompt)
45+
.stream()
46+
.content()
47+
).onErrorMap(e -> new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR));
4348
}
4449
}

cs25-service/src/main/java/com/example/cs25service/domain/ai/client/OpenAiChatClient.java

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.example.cs25service.domain.ai.exception.AiException;
44
import com.example.cs25service.domain.ai.exception.AiExceptionCode;
5+
import com.example.cs25service.domain.ai.resilience.AiResilience;
56
import org.springframework.ai.chat.client.ChatClient;
67
import org.springframework.beans.factory.annotation.Qualifier;
78
import org.springframework.stereotype.Component;
@@ -11,19 +12,25 @@
1112
public class OpenAiChatClient implements AiChatClient {
1213

1314
private final ChatClient openAiChatClient;
15+
private final AiResilience resilience;
1416

15-
public OpenAiChatClient(@Qualifier("openAiChatModelClient") ChatClient openAiChatClient) {
17+
public OpenAiChatClient(
18+
@Qualifier("openAiChatModelClient") ChatClient openAiChatClient,
19+
AiResilience resilience) {
1620
this.openAiChatClient = openAiChatClient;
21+
this.resilience = resilience;
1722
}
1823

1924
@Override
2025
public String call(String systemPrompt, String userPrompt) {
21-
return openAiChatClient.prompt()
22-
.system(systemPrompt)
23-
.user(userPrompt)
24-
.call()
25-
.content()
26-
.trim();
26+
return resilience.executeSync("openai", () ->
27+
openAiChatClient.prompt()
28+
.system(systemPrompt)
29+
.user(userPrompt)
30+
.call()
31+
.content()
32+
.trim()
33+
);
2734
}
2835

2936
@Override
@@ -33,13 +40,12 @@ public ChatClient raw() {
3340

3441
@Override
3542
public Flux<String> stream(String systemPrompt, String userPrompt) {
36-
return openAiChatClient.prompt()
37-
.system(systemPrompt)
38-
.user(userPrompt)
39-
.stream()
40-
.content()
41-
.onErrorResume(error -> {
42-
throw new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR);
43-
});
43+
return resilience.executeStream("openai", () ->
44+
openAiChatClient.prompt()
45+
.system(systemPrompt)
46+
.user(userPrompt)
47+
.stream()
48+
.content()
49+
).onErrorMap(e -> new AiException(AiExceptionCode.INTERNAL_SERVER_ERROR));
4450
}
4551
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.example.cs25service.domain.ai.resilience;
2+
3+
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
4+
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
5+
import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator;
6+
import io.github.resilience4j.reactor.retry.RetryOperator;
7+
import io.github.resilience4j.retry.Retry;
8+
import io.github.resilience4j.retry.RetryRegistry;
9+
import java.util.function.Supplier;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.stereotype.Component;
12+
import reactor.core.publisher.Flux;
13+
14+
@Component
15+
@RequiredArgsConstructor
16+
public class AiResilience {
17+
18+
private final CircuitBreakerRegistry cbRegistry;
19+
private final RetryRegistry retryRegistry;
20+
21+
/**
22+
* 동기 호출: Retry → CircuitBreaker 순서
23+
*/
24+
public <T> T executeSync(String name, Supplier<T> supplier) {
25+
CircuitBreaker cb = cbRegistry.circuitBreaker(name);
26+
Retry retry = retryRegistry.retry(name);
27+
28+
Supplier<T> withRetry = Retry.decorateSupplier(retry, supplier);
29+
Supplier<T> withCb = CircuitBreaker.decorateSupplier(cb, withRetry);
30+
31+
return withCb.get();
32+
}
33+
34+
/**
35+
* Flux 스트리밍: RetryOperator → CircuitBreakerOperator 순서
36+
*/
37+
public <T> Flux<T> executeStream(String name, Supplier<Flux<T>> supplier) {
38+
CircuitBreaker cb = cbRegistry.circuitBreaker(name);
39+
Retry retry = retryRegistry.retry(name);
40+
41+
return supplier.get()
42+
.transformDeferred(RetryOperator.of(retry))
43+
.transformDeferred(CircuitBreakerOperator.of(cb));
44+
}
45+
}

cs25-service/src/main/resources/application.properties

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,39 @@ spring.ai.mcp.client.enabled=true
6767
spring.ai.mcp.client.type=SYNC
6868
spring.ai.mcp.client.request-timeout=60s
6969
spring.ai.mcp.client.root-change-notification=false
70-
# STDIO Connect: Brave Search
70+
# Brave Search
7171
spring.ai.mcp.client.stdio.connections.brave.command=server-brave-search
7272
spring.ai.mcp.client.stdio.connections.brave.args[0]=--transport
7373
spring.ai.mcp.client.stdio.connections.brave.args[1]=stdio
7474
spring.ai.mcp.client.stdio.connections.brave.env.BRAVE_API_KEY=${BRAVE_API_KEY}
7575
spring.autoconfigure.exclude=org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration
76+
# CircuitBreaker
77+
resilience4j.circuitbreaker.configs.default.slidingWindowType=COUNT_BASED
78+
resilience4j.circuitbreaker.configs.default.slidingWindowSize=10
79+
resilience4j.circuitbreaker.configs.default.failureRateThreshold=50
80+
resilience4j.circuitbreaker.configs.default.slowCallDurationThreshold=5s
81+
resilience4j.circuitbreaker.configs.default.slowCallRateThreshold=70
82+
resilience4j.circuitbreaker.configs.default.minimumNumberOfCalls=6
83+
resilience4j.circuitbreaker.configs.default.waitDurationInOpenState=30m
84+
resilience4j.circuitbreaker.configs.default.permittedNumberOfCallsInHalfOpenState=1
85+
resilience4j.circuitbreaker.configs.default.automaticTransitionFromOpenToHalfOpenEnabled=true
86+
resilience4j.circuitbreaker.instances.openai.baseConfig=default
87+
resilience4j.circuitbreaker.instances.claude.baseConfig=default
88+
resilience4j.circuitbreaker.configs.default.ignoreExceptions[0]=org.springframework.web.reactive.function.client.WebClientResponseException.BadRequest
89+
resilience4j.circuitbreaker.configs.default.ignoreExceptions[1]=org.springframework.web.reactive.function.client.WebClientResponseException.Unauthorized
90+
resilience4j.circuitbreaker.configs.default.ignoreExceptions[2]=org.springframework.web.reactive.function.client.WebClientResponseException.Forbidden
91+
resilience4j.circuitbreaker.configs.default.ignoreExceptions[3]=org.springframework.web.reactive.function.client.WebClientResponseException.NotFound
92+
# Retry
93+
resilience4j.retry.configs.default.maxAttempts=2
94+
resilience4j.retry.configs.default.waitDuration=200ms
95+
resilience4j.retry.configs.default.enableExponentialBackoff=true
96+
resilience4j.retry.configs.default.exponentialBackoffMultiplier=2
97+
resilience4j.retry.instances.openai.baseConfig=default
98+
resilience4j.retry.instances.claude.baseConfig=default
99+
resilience4j.retry.configs.default.ignoreExceptions[0]=org.springframework.web.reactive.function.client.WebClientResponseException.BadRequest
100+
resilience4j.retry.configs.default.ignoreExceptions[1]=org.springframework.web.reactive.function.client.WebClientResponseException.Unauthorized
101+
resilience4j.retry.configs.default.ignoreExceptions[2]=org.springframework.web.reactive.function.client.WebClientResponseException.Forbidden
102+
resilience4j.retry.configs.default.ignoreExceptions[3]=org.springframework.web.reactive.function.client.WebClientResponseException.NotFound
76103
#MAIL
77104
spring.mail.host=smtp.gmail.com
78105
spring.mail.port=587

0 commit comments

Comments
 (0)