|
3 | 3 | import com.potato.balbambalbam.card.cardFeedback.dto.AiFeedbackRequestDto; |
4 | 4 | import com.potato.balbambalbam.card.cardFeedback.dto.AiFeedbackResponseDto; |
5 | 5 | import com.potato.balbambalbam.exception.AiGenerationFailException; |
| 6 | +import com.potato.balbambalbam.exception.AiServerException; |
6 | 7 | import com.potato.balbambalbam.exception.InvalidParameterException; |
| 8 | +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; |
| 9 | +import lombok.RequiredArgsConstructor; |
7 | 10 | import lombok.extern.slf4j.Slf4j; |
8 | | -import org.springframework.beans.factory.annotation.Value; |
9 | | -import org.springframework.http.HttpStatus; |
10 | | -import org.springframework.http.MediaType; |
11 | 11 | import org.springframework.stereotype.Service; |
| 12 | +import org.springframework.web.reactive.function.client.ClientResponse; |
12 | 13 | import org.springframework.web.reactive.function.client.WebClient; |
| 14 | +import org.springframework.web.reactive.function.client.WebClientRequestException; |
13 | 15 | import reactor.core.publisher.Mono; |
| 16 | +import reactor.util.retry.Retry; |
14 | 17 |
|
15 | 18 | import java.time.Duration; |
| 19 | +import java.util.concurrent.TimeoutException; |
16 | 20 |
|
17 | | -@Service |
18 | 21 | @Slf4j |
| 22 | +@Service |
| 23 | +@RequiredArgsConstructor |
19 | 24 | public class AiCardFeedbackService { |
20 | | - WebClient webClient = WebClient.builder() |
21 | | - .codecs(configurer -> configurer |
22 | | - .defaultCodecs() |
23 | | - .maxInMemorySize(5 * 1024 * 1024)) // 5MB |
24 | | - .build(); |
25 | | - @Value("${ai.service.url}") |
26 | | - private String AI_URL; |
27 | 25 |
|
28 | | - public AiFeedbackResponseDto postAiFeedback(AiFeedbackRequestDto aiFeedbackRequestDto) { |
| 26 | + private final WebClient aiWebClient; |
29 | 27 |
|
30 | | - AiFeedbackResponseDto aiFeedbackResponseDto = webClient.post() |
31 | | - .uri(AI_URL + "/ai/feedback") |
32 | | - .contentType(MediaType.APPLICATION_JSON) |
33 | | - .body(Mono.just(aiFeedbackRequestDto), AiFeedbackRequestDto.class) |
| 28 | + private final Retry retryPolicy = Retry.backoff(2, Duration.ofMillis(200)) |
| 29 | + .maxBackoff(Duration.ofSeconds(1)) |
| 30 | + .jitter(0.3) |
| 31 | + .filter(this::isRetryable); |
| 32 | + |
| 33 | + @CircuitBreaker(name = "aiFeedback", fallbackMethod = "fallback") |
| 34 | + public AiFeedbackResponseDto postAiFeedback(AiFeedbackRequestDto aiFeedbackRequestDto) { |
| 35 | + return aiWebClient.post() |
| 36 | + .uri("/ai/feedback") |
| 37 | + .bodyValue(aiFeedbackRequestDto) |
34 | 38 | .retrieve()//요청 |
35 | | - //에러 처리 : 요청이 잘못갔을 경우 |
36 | | - .onStatus(HttpStatus.BAD_REQUEST::equals, |
37 | | - response -> response.bodyToMono(String.class).map(InvalidParameterException::new)) |
38 | | - //에러 처리 : 사용자 텍스트 추출 실패 |
39 | | - .onStatus(HttpStatus.UNPROCESSABLE_ENTITY::equals, |
40 | | - response -> response.bodyToMono(String.class).map(AiGenerationFailException::new)) |
41 | | - //에러 처리 : 텍스트 분리 실패, 정확도 계산, 그래프 추출 실패 |
42 | | - .onStatus(HttpStatus.INTERNAL_SERVER_ERROR::equals, |
43 | | - response -> response.bodyToMono(String.class).map(AiGenerationFailException::new)) |
| 39 | + .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), |
| 40 | + this::mapError) |
44 | 41 | .bodyToMono(AiFeedbackResponseDto.class) |
45 | | - .timeout(Duration.ofSeconds(60)) //10초 안에 응답 오지 않으면 TimeoutException 발생 |
| 42 | + .retryWhen(retryPolicy) |
| 43 | + .timeout(Duration.ofSeconds(15)) // 전체 시도(Retry 포함)에 대한 마지노선 |
46 | 44 | .block(); |
| 45 | + } |
47 | 46 |
|
48 | | - return aiFeedbackResponseDto; |
| 47 | + private Mono<? extends Throwable> mapError(ClientResponse response) { |
| 48 | + return response.bodyToMono(String.class) |
| 49 | + .defaultIfEmpty("") |
| 50 | + .map(body -> { |
| 51 | + int statusCode = response.statusCode().value(); |
| 52 | + if (statusCode == 400) return new InvalidParameterException(body); |
| 53 | + if (statusCode == 422) return new AiGenerationFailException(body); |
| 54 | + if (statusCode >= 500) return new AiServerException(body); |
| 55 | + return new RuntimeException("AI error: " + statusCode + " body=" + body); |
| 56 | + }); |
49 | 57 | } |
| 58 | + |
| 59 | + private boolean isRetryable(Throwable ex) { |
| 60 | + return ex instanceof TimeoutException |
| 61 | + || ex instanceof WebClientRequestException |
| 62 | + || ex instanceof AiServerException |
| 63 | + || ex.getCause() instanceof io.netty.handler.timeout.ReadTimeoutException; |
| 64 | + } |
| 65 | + |
| 66 | + private AiFeedbackResponseDto fallback(Throwable t) { |
| 67 | + log.warn("AI circuit breaker OPEN - fallback executed", t); |
| 68 | + throw new AiServerException("AI service temporarily unavailable"); |
| 69 | + } |
| 70 | + |
50 | 71 | } |
0 commit comments