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
42 changes: 42 additions & 0 deletions common/circuit-breaker/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.4'
id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.ticketPing'
version = '0.0.1-SNAPSHOT'

java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}

configurations {
compileOnly {
extendsFrom annotationProcessor
}
}

repositories {
mavenCentral()
}

bootJar {
enabled = false
}

jar {
enabled = true
}

dependencies {
api 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0'


}

tasks.named('test') {
useJUnitPlatform()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package circuit.config;

import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import io.github.resilience4j.circuitbreaker.event.CircuitBreakerOnErrorEvent;
import io.github.resilience4j.circuitbreaker.event.CircuitBreakerOnFailureRateExceededEvent;
import io.github.resilience4j.circuitbreaker.event.CircuitBreakerOnStateTransitionEvent;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;

@Slf4j
@Configuration
@RequiredArgsConstructor
public class CircuitBreakerEventConfig {

private final CircuitBreakerRegistry circuitBreakerRegistry;

@PostConstruct
public void registerCircuitBreakerEventListeners() {
circuitBreakerRegistry.getAllCircuitBreakers().forEach(circuitBreaker -> {
circuitBreaker.getEventPublisher()
.onStateTransition(this::logStateTransition)
.onFailureRateExceeded(this::logFailureRateExceeded)
.onError(this::logErrorEvent);
});
}

private void logStateTransition(CircuitBreakerOnStateTransitionEvent event) {
log.info("CircuitBreaker '{}' state changed from {} to {}",
event.getCircuitBreakerName(),
event.getStateTransition().getFromState(),
event.getStateTransition().getToState());
}

private void logFailureRateExceeded(CircuitBreakerOnFailureRateExceededEvent event) {
log.warn("CircuitBreaker '{}' failure rate exceeded: {}%",
event.getCircuitBreakerName(),
event.getFailureRate());
}

private void logErrorEvent(CircuitBreakerOnErrorEvent event) {
log.error("CircuitBreaker '{}' recorded an error: {}",
event.getCircuitBreakerName(),
event.getThrowable().getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
spring:
cloud:
openfeign:
circuitbreaker: enabled

resilience4j:
circuitbreaker:
configs:
default:
registerHealthIndicator: true
slidingWindowType: COUNT_BASED
slidingWindowSize: 10
minimumNumberOfCalls: 10
failureRateThreshold: 50
slowCallRateThreshold: 100
slowCallDurationThreshold: 10s
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 3
recordSlowCalls: true
record-exceptions:
- java.util.concurrent.TimeoutException
- java.net.SocketTimeoutException
- java.net.UnknownHostException
- java.net.ConnectException
- feign.RetryableException
- feign.FeignException.GatewayTimeout
- feign.FeignException.BadGateway
- feign.FeignException.TooManyRequests
- feign.FeignException.ServiceUnavailable
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
@Getter
@RequiredArgsConstructor
public enum CircuitBreakerErrorCase implements ErrorCase {
SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 연결 불가능합니다. 관리자에게 문의해주세요."),
SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 연결 불가능합니다. 잠시 후 다시 시도해주세요."),
CONNECTION_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "서비스 요청 시간이 초과되었습니다. 잠시 후 다시 시도해주세요."),
SERVICE_IS_OPEN(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 연결 불가능합니다. 잠시 후 다시 시도해주세요.");
SERVICE_IS_OPEN(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 연결 불가능합니다. 관리자에게 문의해주세요.");

private final HttpStatus httpStatus;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,40 @@

import auth.UserCacheDto;
import com.ticketPing.gateway.application.client.AuthClient;
import com.ticketPing.gateway.common.exception.CircuitBreakerErrorCase;
import com.ticketPing.gateway.common.exception.SecurityErrorCase;
import exception.ApplicationException;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import io.netty.channel.ChannelOption;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import response.CommonResponse;

import java.time.Duration;

@Slf4j
@Component
public class AuthWebClient implements AuthClient {

private final WebClient webClient;

public AuthWebClient(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder.build();
this.webClient = webClientBuilder
.clientConnector(new ReactorClientHttpConnector(
HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 1000)
.responseTimeout(Duration.ofSeconds(15))))
.build();
}

@CircuitBreaker(name = "authServiceCircuitBreaker", fallbackMethod = "validateTokenFallback")
public Mono<UserCacheDto> validateToken(String token) {
return webClient.post()
.uri("http://auth/api/v1/auth/validate")
Expand All @@ -33,4 +50,22 @@ public Mono<UserCacheDto> validateToken(String token) {
}
});
}

private Mono<UserCacheDto> validateTokenFallback(String token, Throwable ex) {
if (ex instanceof CallNotPermittedException) {
return Mono.error(new ApplicationException(CircuitBreakerErrorCase.SERVICE_IS_OPEN));
} else if (
ex instanceof WebClientResponseException.BadGateway ||
ex instanceof WebClientResponseException.GatewayTimeout ||
ex instanceof WebClientResponseException.TooManyRequests ||
ex instanceof WebClientResponseException.ServiceUnavailable
) {
return Mono.error(new ApplicationException(CircuitBreakerErrorCase.SERVICE_UNAVAILABLE));
} else if (ex instanceof WebClientResponseException) {
return Mono.error(ex);
} else {
return Mono.error(new ApplicationException(CircuitBreakerErrorCase.SERVICE_UNAVAILABLE));
}
}

}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.ticketPing.gateway.infrastructure.filter;

import com.ticketPing.gateway.application.client.AuthClient;
import exception.ApplicationException;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
Expand All @@ -15,6 +18,7 @@
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;
import java.util.List;

@Component
Expand Down Expand Up @@ -52,6 +56,14 @@ public Mono<SecurityContext> load(ServerWebExchange exchange) {

return Mono.just((SecurityContext) new SecurityContextImpl(authentication));
})
.onErrorResume(ApplicationException.class, e -> {
exchange.getResponse().setStatusCode(HttpStatus.SERVICE_UNAVAILABLE);
DataBuffer buffer = exchange.getResponse()
.bufferFactory()
.wrap(e.getMessage().getBytes(StandardCharsets.UTF_8));
return exchange.getResponse().writeWith(Mono.just(buffer))
.then(Mono.empty());
})
.onErrorResume(WebClientResponseException.Unauthorized.class, e -> Mono.empty());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public Mono<ResponseEntity<CommonResponse<Object>>> defaultFallback(ServerWebExc

private ResponseEntity<CommonResponse<Object>> handleCircuitBreakerException(Throwable ex) {
if (ex instanceof TimeoutException) {
return createServiceUnavailableResponse(CircuitBreakerErrorCase.SERVICE_UNAVAILABLE);
return createServiceUnavailableResponse(CircuitBreakerErrorCase.CONNECTION_TIMEOUT);
} else if (ex instanceof CallNotPermittedException) {
return createServiceUnavailableResponse(CircuitBreakerErrorCase.SERVICE_IS_OPEN);
} else {
Expand Down
15 changes: 12 additions & 3 deletions gateway/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,6 @@ resilience4j:
- org.springframework.cloud.gateway.support.NotFoundException
- io.netty.channel.AbstractChannel$AnnotatedConnectException
instances:
authServiceCircuitBreaker:
baseConfig: default
userServiceCircuitBreaker:
baseConfig: default
performanceServiceCircuitBreaker:
Expand All @@ -65,4 +63,15 @@ resilience4j:
paymentServiceCircuitBreaker:
baseConfig: default
queueManageServiceCircuitBreaker:
baseConfig: default
baseConfig: default
authServiceCircuitBreaker:
baseConfig: default
record-exceptions:
- java.util.concurrent.TimeoutException
- org.springframework.cloud.gateway.support.NotFoundException
- io.netty.channel.AbstractChannel$AnnotatedConnectException
- org.springframework.web.reactive.function.client.WebClientRequestException
- org.springframework.web.reactive.function.client.WebClientResponseException.BadGateway
- org.springframework.web.reactive.function.client.WebClientResponseException.GatewayTimeout
- org.springframework.web.reactive.function.client.WebClientResponseException.TooManyRequests
- org.springframework.web.reactive.function.client.WebClientResponseException.ServiceUnavailable
8 changes: 4 additions & 4 deletions monitoring/grafana/provisioning/alerting/alert-rule.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ groups:
model:
disableTextWrap: false
editorMode: builder
expr: sum by(name) (resilience4j_circuitbreaker_state{state=~"open|half_open"})
expr: sum by(name, instance) (resilience4j_circuitbreaker_state{state=~"open|half_open"})
fullMetaSearch: false
includeNullMetadata: true
instant: true
Expand Down Expand Up @@ -187,7 +187,7 @@ groups:
execErrState: Error
for: 0s
annotations:
description: CircuitBreaker ''{{ $labels.name }}'' has change from CLOSED to OPEN.
description: CircuitBreaker ''{{ $labels.instance }}'' ''{{ $labels.name }}'' has change from CLOSED to OPEN.
labels: { }
isPaused: false
notification_settings:
Expand All @@ -210,7 +210,7 @@ groups:
disableTextWrap: false
editorMode: builder
exemplar: false
expr: sum by(name) (avg_over_time(resilience4j_circuitbreaker_state{state=~"open|half_open"}[5m]))
expr: sum by(name, instance) (avg_over_time(resilience4j_circuitbreaker_state{state=~"open|half_open"}[5m]))
fullMetaSearch: false
includeNullMetadata: true
instant: true
Expand Down Expand Up @@ -252,7 +252,7 @@ groups:
execErrState: Error
for: 1m
annotations:
description: 'CircuitBreaker ''{{ $labels.name }}'' has been in an OPEN or HALF_OPEN state for 5 minutes.'
description: 'CircuitBreaker ''{{ $labels.instance }}'' ''{{ $labels.name }}'' has been in an OPEN or HALF_OPEN state for 5 minutes.'
labels: {}
isPaused: false
notification_settings:
Expand Down
1 change: 1 addition & 0 deletions services/auth/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ dependencies {
implementation project(':common:dtos')
implementation project(':common:caching')
implementation project(':common:monitoring')
implementation project(':common:circuit-breaker')

// MVC
implementation 'org.springframework.boot:spring-boot-starter-web'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

@SpringBootApplication
@EnableFeignClients
@ComponentScan(basePackages = {"com.ticketPing.auth", "aop", "exception", "caching"})
@ComponentScan(basePackages = {"com.ticketPing.auth", "aop", "exception", "caching", "circuit"})
public class AuthApplication {
public static void main(String[] args) {
SpringApplication.run(AuthApplication.class, args);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.ticketPing.auth.common.exception;

import exception.ErrorCase;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public enum CircuitBreakerErrorCase implements ErrorCase {
SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 연결 불가능합니다. 잠시 후 다시 시도해주세요."),
SERVICE_IS_OPEN(HttpStatus.SERVICE_UNAVAILABLE, "서비스가 연결 불가능합니다. 관리자에게 문의해주세요.");

private final HttpStatus httpStatus;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,43 @@
package com.ticketPing.auth.infrastructure.client;

import com.ticketPing.auth.application.client.UserClient;
import com.ticketPing.auth.common.exception.CircuitBreakerErrorCase;
import com.ticketPing.auth.infrastructure.config.CustomFeignConfig;
import exception.ApplicationException;
import feign.FeignException;
import feign.RetryableException;
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.UUID;
import response.CommonResponse;
import user.UserLookupRequest;
import user.UserResponse;

@FeignClient(name = "user")
@FeignClient(name = "user", configuration = CustomFeignConfig.class)
public interface UserFeignClient extends UserClient {
@GetMapping("/api/v1/users/login")
@CircuitBreaker(name = "userServiceCircuitBreaker", fallbackMethod = "fallbackForUserService")
CommonResponse<UserResponse> getUserByEmailAndPassword(@RequestBody UserLookupRequest userLookupRequest);

default CommonResponse<UserResponse> fallbackForUserService(UserLookupRequest userLookupRequest, Throwable cause) {
if (cause instanceof CallNotPermittedException) {
throw new ApplicationException(CircuitBreakerErrorCase.SERVICE_IS_OPEN);
}
else if (
cause instanceof FeignException.GatewayTimeout ||
cause instanceof FeignException.ServiceUnavailable ||
cause instanceof FeignException.BadGateway ||
cause instanceof FeignException.TooManyRequests ||
cause instanceof RetryableException
) {
throw new ApplicationException(CircuitBreakerErrorCase.SERVICE_UNAVAILABLE);
}
else if (cause instanceof FeignException) {
throw (FeignException) cause;
} else {
throw new ApplicationException(CircuitBreakerErrorCase.SERVICE_UNAVAILABLE);
}
}
}
Loading
Loading