Merge branch 'feature/circuit-breaker-test' into master
This commit is contained in:
+10
-13
@@ -17,10 +17,6 @@ import com.eactive.eai.common.lifecycle.LifecycleSupport;
|
||||
import com.eactive.eai.common.property.PropManager;
|
||||
import com.eactive.eai.common.util.ApplicationContextProvider;
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
import com.eactive.eai.custom.alarm.AlarmStateManager;
|
||||
import com.eactive.eai.custom.alarm.condition.AlarmCondition;
|
||||
import com.eactive.eai.custom.alarm.event.AlarmEvent;
|
||||
import com.eactive.eai.custom.alarm.key.CoreAlarmKey;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
@@ -45,7 +41,7 @@ public class TypedCircuitBreakerManager implements Lifecycle {
|
||||
TypedCircuitBreakerVO defaultConfigVo;
|
||||
HashMap<String, TypedCircuitBreakerVO> idCBConfigMap = new HashMap<>();
|
||||
HashMap<String, TypedCircuitBreakerVO> apiIdCBConfigMap = new HashMap<>();
|
||||
HashMap<String, TypedCircuitBreakerVO> urlCBConfigMap = new HashMap<>();
|
||||
// HashMap<String, TypedCircuitBreakerVO> urlCBConfigMap = new HashMap<>();
|
||||
private boolean started;
|
||||
|
||||
@Autowired
|
||||
@@ -116,7 +112,7 @@ public class TypedCircuitBreakerManager implements Lifecycle {
|
||||
CircuitBreakerConfig circuitBreakerConfig = this.createCircuitBreakerConfig(vo);
|
||||
vo.setCircuitBreakerConfig(circuitBreakerConfig);
|
||||
this.apiIdCBConfigMap.put(vo.getName(), vo);
|
||||
this.idCBConfigMap.remove(vo.getId(), vo);
|
||||
this.idCBConfigMap.put(vo.getId(), vo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,14 +355,15 @@ public class TypedCircuitBreakerManager implements Lifecycle {
|
||||
// } else {
|
||||
// this.sendAlert(stateTransitionEvent);
|
||||
// }
|
||||
AlarmEvent alarmEvent = AlarmEvent.builder().key(CoreAlarmKey.CircuitBreaker)
|
||||
.condition(AlarmCondition.builder().build())
|
||||
.message(String.format("CircuitBreaker state changed. apiId=%s, %s -> %s", event.getCircuitBreakerName(), fromState, toState))
|
||||
.build();
|
||||
|
||||
AlarmStateManager.getAlarmStateManager().onEvnet(alarmEvent);
|
||||
|
||||
// AlarmEvent alarmEvent = AlarmEvent.builder().key(CoreAlarmKey.CircuitBreaker)
|
||||
// .condition(AlarmCondition.builder().build())
|
||||
// .message(String.format("CircuitBreaker state changed. apiId=%s, %s -> %s", event.getCircuitBreakerName(), fromState, toState))
|
||||
// .build();
|
||||
//
|
||||
// AlarmStateManager.getAlarmStateManager().onEvnet(alarmEvent);
|
||||
|
||||
logger.warn("CircuitBreaker state changed. apiId={}, {} -> {}", event.getCircuitBreakerName(), fromState, toState);
|
||||
TypedCircuitBreakerManager.logger.warn("CircuitBreaker state changed. apiId={}, {} -> {}", event.getCircuitBreakerName(), fromState, toState);
|
||||
|
||||
} else {
|
||||
TypedCircuitBreakerManager.logger.error(
|
||||
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
package com.eactive.eai.common.circuitBreaker.type;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.MethodOrderer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
|
||||
import com.eactive.eai.common.property.PropManager;
|
||||
import com.eactive.eai.common.util.ApplicationContextProvider;
|
||||
|
||||
import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
|
||||
|
||||
/**
|
||||
* TypedCircuitBreakerManager 단위 테스트
|
||||
*
|
||||
* DB / 실 Spring 컨텍스트 없이 동작한다.
|
||||
* - PropManager : Mockito Mock (DB 없이 설정값 반환)
|
||||
* - TypedCircuitBreakerDAO : Mockito Mock (JPA 없이 VO 반환)
|
||||
* - Spring Bean : GenericApplicationContext 로 수동 구성
|
||||
*
|
||||
* 테스트 격리:
|
||||
* 각 테스트는 고유한 apiId 를 사용하거나 manager.reload() 로 레지스트리를 초기화한다.
|
||||
* manager.reload() 는 CircuitBreakerRegistry 를 새로 생성하므로 이전 상태가 제거된다.
|
||||
*/
|
||||
@TestMethodOrder(MethodOrderer.DisplayName.class)
|
||||
class TypedCircuitBreakerManagerTest {
|
||||
|
||||
// 슬라이딩 윈도우·최소 호출을 4로 설정해 빠른 상태 전이를 유도한다.
|
||||
private static final int DEFAULT_FAILURE_RATE = 50;
|
||||
private static final int DEFAULT_SLIDING_WINDOW = 4;
|
||||
private static final int DEFAULT_MIN_CALLS = 4;
|
||||
private static final int DEFAULT_WAIT_DURATION_SEC = 1; // OPEN 대기 1초 (테스트용)
|
||||
private static final int DEFAULT_HALF_OPEN_PERMITS = 2;
|
||||
private static final int DEFAULT_SLOW_RATE = 100;
|
||||
private static final int DEFAULT_SLOW_DURATION_SEC = 5;
|
||||
|
||||
private static GenericApplicationContext ctx;
|
||||
private static PropManager mockPropManager;
|
||||
private static TypedCircuitBreakerDAO mockDao;
|
||||
private static TypedCircuitBreakerManager manager;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 픽스처 설정 / 해제
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@BeforeAll
|
||||
static void setUpClass() throws Exception {
|
||||
mockPropManager = mock(PropManager.class);
|
||||
mockDao = mock(TypedCircuitBreakerDAO.class);
|
||||
|
||||
setupDefaultPropManagerMock();
|
||||
when(mockDao.getList()).thenReturn(Collections.emptyList());
|
||||
|
||||
// TypedCircuitBreakerManager 인스턴스 생성 후 DAO 리플렉션 주입
|
||||
manager = new TypedCircuitBreakerManager();
|
||||
injectField(manager, "dao", mockDao);
|
||||
|
||||
// DB / 실 Spring 없이 최소 컨텍스트 구성
|
||||
ctx = new GenericApplicationContext();
|
||||
ctx.getBeanFactory().registerSingleton("propManager", mockPropManager);
|
||||
ctx.getBeanFactory().registerSingleton("typedCircuitBreakerManager", manager);
|
||||
ctx.registerBeanDefinition("applicationContextProvider",
|
||||
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
|
||||
.getBeanDefinition());
|
||||
ctx.refresh();
|
||||
|
||||
manager.start();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDownClass() {
|
||||
if (ctx != null) ctx.close();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void resetMocks() throws Exception {
|
||||
reset(mockDao);
|
||||
setupDefaultPropManagerMock();
|
||||
when(mockDao.getList()).thenReturn(Collections.emptyList());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 헬퍼
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static void setupDefaultPropManagerMock() {
|
||||
when(mockPropManager.getProperty("CircuitBreaker", "failureRateThreshold"))
|
||||
.thenReturn(String.valueOf(DEFAULT_FAILURE_RATE));
|
||||
when(mockPropManager.getProperty("CircuitBreaker", "slidingWindowSize"))
|
||||
.thenReturn(String.valueOf(DEFAULT_SLIDING_WINDOW));
|
||||
when(mockPropManager.getProperty("CircuitBreaker", "minimumNumberOfCalls"))
|
||||
.thenReturn(String.valueOf(DEFAULT_MIN_CALLS));
|
||||
when(mockPropManager.getProperty("CircuitBreaker", "waitDurationInOpenState"))
|
||||
.thenReturn(String.valueOf(DEFAULT_WAIT_DURATION_SEC));
|
||||
when(mockPropManager.getProperty("CircuitBreaker", "permittedNumberOfCallsInHalfOpenState"))
|
||||
.thenReturn(String.valueOf(DEFAULT_HALF_OPEN_PERMITS));
|
||||
when(mockPropManager.getProperty("CircuitBreaker", "slowCallRateThreshold"))
|
||||
.thenReturn(String.valueOf(DEFAULT_SLOW_RATE));
|
||||
when(mockPropManager.getProperty("CircuitBreaker", "slowCallDurationThreshold"))
|
||||
.thenReturn(String.valueOf(DEFAULT_SLOW_DURATION_SEC));
|
||||
when(mockPropManager.getProperty("CircuitBreaker", "useYn", "N")).thenReturn("1");
|
||||
}
|
||||
|
||||
private static void injectField(Object target, String fieldName, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
|
||||
/** 테스트용 TypedCircuitBreakerVO 생성 (기본 설정값 사용) */
|
||||
private TypedCircuitBreakerVO buildVo(String id, String apiId, String useYn) {
|
||||
return TypedCircuitBreakerVO.builder()
|
||||
.id(id).name(apiId).type("COUNT_BASED").desc(apiId + " 테스트 설정")
|
||||
.failureRateThreshold(DEFAULT_FAILURE_RATE)
|
||||
.slidingWindowSize(DEFAULT_SLIDING_WINDOW)
|
||||
.minimumNumberOfCalls(DEFAULT_MIN_CALLS)
|
||||
.waitDurationInOpenState(DEFAULT_WAIT_DURATION_SEC)
|
||||
.permittedNumberOfCallsInHalfOpenState(DEFAULT_HALF_OPEN_PERMITS)
|
||||
.slowCallRateThreshold(DEFAULT_SLOW_RATE)
|
||||
.slowCallDurationThreshold(DEFAULT_SLOW_DURATION_SEC)
|
||||
.useYn(useYn)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** CB를 count 회 실패 처리한다 */
|
||||
private void recordFailures(CircuitBreaker cb, int count) {
|
||||
for (int i = 0; i < count; i++) {
|
||||
try {
|
||||
cb.executeCallable(() -> { throw new RuntimeException("테스트 강제 오류"); });
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 1. getCircuitBreaker - 기본 조회 동작
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
@DisplayName("1-1. 등록된 apiId → 해당 설정의 CircuitBreaker 반환")
|
||||
void testGetCircuitBreaker_registeredApiId_returnsTypedCircuitBreaker() throws Exception {
|
||||
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-001", "API_REG", "1")));
|
||||
manager.reload();
|
||||
|
||||
CircuitBreaker cb = manager.getCircuitBreaker("API_REG");
|
||||
|
||||
assertNotNull(cb);
|
||||
assertEquals("API_REG", cb.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1-2. 미등록 apiId → default CircuitBreaker 로 fallback")
|
||||
void testGetCircuitBreaker_unregisteredApiId_returnsDefaultCircuitBreaker() throws Exception {
|
||||
when(mockDao.getList()).thenReturn(Collections.emptyList());
|
||||
manager.reload();
|
||||
|
||||
CircuitBreaker cb = manager.getCircuitBreaker("API_UNKNOWN");
|
||||
|
||||
assertNotNull(cb, "미등록 apiId 는 default CB 를 반환해야 한다");
|
||||
assertEquals("default", cb.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1-3. useYn=0 인 apiId → default CircuitBreaker 로 fallback")
|
||||
void testGetCircuitBreaker_disabledApiId_returnsDefaultCircuitBreaker() throws Exception {
|
||||
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-002", "API_DISABLED", "0")));
|
||||
manager.reload();
|
||||
|
||||
CircuitBreaker cb = manager.getCircuitBreaker("API_DISABLED");
|
||||
|
||||
assertNotNull(cb, "비활성 apiId 는 default CB 로 fallback 되어야 한다");
|
||||
assertEquals("default", cb.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1-4. apiId 도 default 도 비활성(useYn=0) → null 반환")
|
||||
void testGetCircuitBreaker_defaultDisabled_returnsNull() throws Exception {
|
||||
when(mockPropManager.getProperty("CircuitBreaker", "useYn", "N")).thenReturn("0");
|
||||
when(mockDao.getList()).thenReturn(Collections.emptyList());
|
||||
manager.reload();
|
||||
|
||||
CircuitBreaker cb = manager.getCircuitBreaker("ANY_API");
|
||||
|
||||
assertNull(cb, "default 도 비활성이면 null 을 반환해야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1-5. PropManager useYn=Y → normalizeUseYn 으로 '1' 정규화 → default 활성")
|
||||
void testGetCircuitBreaker_defaultUseYn_Y_isNormalizedToEnabled() throws Exception {
|
||||
when(mockPropManager.getProperty("CircuitBreaker", "useYn", "N")).thenReturn("Y");
|
||||
when(mockDao.getList()).thenReturn(Collections.emptyList());
|
||||
manager.reload();
|
||||
|
||||
CircuitBreaker cb = manager.getCircuitBreaker("ANY_API");
|
||||
|
||||
assertNotNull(cb, "PropManager useYn=Y 는 활성으로 정규화되어야 한다");
|
||||
assertEquals("default", cb.getName());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 2. apiId 별 독립성
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
@DisplayName("2-1. 서로 다른 apiId → 독립적인 CircuitBreaker 인스턴스 반환")
|
||||
void testDifferentApiIds_haveIndependentCircuitBreakerInstances() throws Exception {
|
||||
when(mockDao.getList()).thenReturn(Arrays.asList(
|
||||
buildVo("uuid-A", "API_INDEP_A", "1"),
|
||||
buildVo("uuid-B", "API_INDEP_B", "1")));
|
||||
manager.reload();
|
||||
|
||||
CircuitBreaker cbA = manager.getCircuitBreaker("API_INDEP_A");
|
||||
CircuitBreaker cbB = manager.getCircuitBreaker("API_INDEP_B");
|
||||
|
||||
assertNotNull(cbA);
|
||||
assertNotNull(cbB);
|
||||
assertNotSame(cbA, cbB, "서로 다른 apiId 는 다른 CB 인스턴스여야 한다");
|
||||
assertEquals("API_INDEP_A", cbA.getName());
|
||||
assertEquals("API_INDEP_B", cbB.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("2-2. API_A 실패 → OPEN, API_B 는 CLOSED 유지 (상태 격리)")
|
||||
void testDifferentApiIds_statesAreIsolated() throws Exception {
|
||||
when(mockDao.getList()).thenReturn(Arrays.asList(
|
||||
buildVo("uuid-ISO-A", "API_ISO_A", "1"),
|
||||
buildVo("uuid-ISO-B", "API_ISO_B", "1")));
|
||||
manager.reload();
|
||||
|
||||
CircuitBreaker cbA = manager.getCircuitBreaker("API_ISO_A");
|
||||
CircuitBreaker cbB = manager.getCircuitBreaker("API_ISO_B");
|
||||
|
||||
// API_ISO_A 만 minimumNumberOfCalls=4 회 실패
|
||||
recordFailures(cbA, 4);
|
||||
|
||||
assertEquals(CircuitBreaker.State.OPEN, cbA.getState(), "API_ISO_A 는 OPEN 이어야 한다");
|
||||
assertEquals(CircuitBreaker.State.CLOSED, cbB.getState(), "API_ISO_B 는 CLOSED 를 유지해야 한다");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 3. 상태 전이
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
@DisplayName("3-1. 실패율 50% 초과(4/4 실패) → CLOSED → OPEN")
|
||||
void testCircuitBreaker_closedToOpen_afterFailureThresholdExceeded() throws Exception {
|
||||
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-001", "API_ST_OPEN", "1")));
|
||||
manager.reload();
|
||||
|
||||
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_OPEN");
|
||||
assertEquals(CircuitBreaker.State.CLOSED, cb.getState(), "초기 상태는 CLOSED 여야 한다");
|
||||
|
||||
// minimumNumberOfCalls=4, failureRateThreshold=50% → 4/4 실패(100%) → OPEN
|
||||
recordFailures(cb, 4);
|
||||
|
||||
assertEquals(CircuitBreaker.State.OPEN, cb.getState(), "실패율 초과 시 OPEN 상태여야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3-2. 실패율 미달(1/4 실패, 25%) → CLOSED 유지")
|
||||
void testCircuitBreaker_belowFailureThreshold_remainsClosed() throws Exception {
|
||||
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-002", "API_ST_BELOW", "1")));
|
||||
manager.reload();
|
||||
|
||||
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_BELOW");
|
||||
|
||||
// 4회 중 3회 성공, 1회 실패 → 실패율 25% < 50% → CLOSED 유지
|
||||
// Resilience4j 는 >= 비교: 50% >= 50% 는 OPEN 이므로 25% 로 낮춰야 CLOSED 유지
|
||||
try { cb.executeCallable(() -> "성공"); } catch (Exception ignored) {}
|
||||
try { cb.executeCallable(() -> "성공"); } catch (Exception ignored) {}
|
||||
try { cb.executeCallable(() -> "성공"); } catch (Exception ignored) {}
|
||||
recordFailures(cb, 1);
|
||||
|
||||
assertEquals(CircuitBreaker.State.CLOSED, cb.getState(), "실패율 25% (< threshold 50%) 이면 CLOSED 를 유지해야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3-3. CB OPEN 상태 → 모든 요청 즉시 차단 (CallNotPermittedException)")
|
||||
void testCircuitBreaker_open_blocksAllRequests() throws Exception {
|
||||
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-003", "API_ST_BLOCK", "1")));
|
||||
manager.reload();
|
||||
|
||||
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_BLOCK");
|
||||
recordFailures(cb, 4);
|
||||
assertEquals(CircuitBreaker.State.OPEN, cb.getState());
|
||||
|
||||
assertThrows(CallNotPermittedException.class,
|
||||
() -> cb.executeCallable(() -> "도달하면 안 됨"),
|
||||
"OPEN 상태에서 CallNotPermittedException 이 발생해야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3-4. OPEN → waitDuration(1초) 경과 → 첫 요청 시 HALF_OPEN 전이")
|
||||
void testCircuitBreaker_open_transitionsToHalfOpenAfterWaitDuration() throws Exception {
|
||||
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-004", "API_ST_HALFOPEN", "1")));
|
||||
manager.reload();
|
||||
|
||||
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_HALFOPEN");
|
||||
recordFailures(cb, 4);
|
||||
assertEquals(CircuitBreaker.State.OPEN, cb.getState());
|
||||
|
||||
// waitDurationInOpenState = 1초 대기 후 요청 → HALF_OPEN 전이
|
||||
Thread.sleep(1200);
|
||||
try { cb.executeCallable(() -> "HALF_OPEN 트리거"); } catch (Exception ignored) {}
|
||||
|
||||
assertEquals(CircuitBreaker.State.HALF_OPEN, cb.getState(),
|
||||
"waitDuration 경과 후 첫 요청 시 HALF_OPEN 상태여야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3-5. HALF_OPEN → 성공(permittedCalls=2회) → CLOSED 복구")
|
||||
void testCircuitBreaker_halfOpen_transitionsToClosedOnSuccess() throws Exception {
|
||||
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-005", "API_ST_RECOVER", "1")));
|
||||
manager.reload();
|
||||
|
||||
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_RECOVER");
|
||||
recordFailures(cb, 4);
|
||||
|
||||
Thread.sleep(1200);
|
||||
|
||||
// HALF_OPEN 에서 permittedNumberOfCallsInHalfOpenState=2 회 성공
|
||||
for (int i = 0; i < DEFAULT_HALF_OPEN_PERMITS; i++) {
|
||||
final String result = cb.executeCallable(() -> "성공");
|
||||
assertEquals("성공", result);
|
||||
}
|
||||
|
||||
assertEquals(CircuitBreaker.State.CLOSED, cb.getState(),
|
||||
"HALF_OPEN 에서 허용 호출 수 이상 성공하면 CLOSED 로 복구되어야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3-6. HALF_OPEN → 실패 → 다시 OPEN 재전이")
|
||||
void testCircuitBreaker_halfOpen_transitionsBackToOpenOnFailure() throws Exception {
|
||||
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-ST-006", "API_ST_REOPEN", "1")));
|
||||
manager.reload();
|
||||
|
||||
CircuitBreaker cb = manager.getCircuitBreaker("API_ST_REOPEN");
|
||||
recordFailures(cb, 4);
|
||||
|
||||
Thread.sleep(1200);
|
||||
|
||||
// HALF_OPEN 에서 permittedNumberOfCallsInHalfOpenState=2 회 모두 실패해야 OPEN 으로 재전이된다.
|
||||
// Resilience4j 는 허용 호출 수(2회)를 모두 소진한 후에 실패율을 평가하므로
|
||||
// 1회만 실패하면 HALF_OPEN 상태가 유지된다.
|
||||
recordFailures(cb, DEFAULT_HALF_OPEN_PERMITS);
|
||||
|
||||
assertEquals(CircuitBreaker.State.OPEN, cb.getState(),
|
||||
"HALF_OPEN 에서 허용 호출 수(2회) 모두 실패하면 다시 OPEN 으로 전이되어야 한다");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 4. reload
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
@DisplayName("4-1. reload(key) → CircuitBreakerConfig 갱신 (waitDuration 1초 → 60초)")
|
||||
void testReload_key_updatesCircuitBreakerConfig() throws Exception {
|
||||
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-RLD-001", "API_RELOAD", "1")));
|
||||
manager.reload();
|
||||
|
||||
// waitDurationInOpenState 60초로 변경
|
||||
TypedCircuitBreakerVO updated = TypedCircuitBreakerVO.builder()
|
||||
.id("uuid-RLD-001").name("API_RELOAD").type("COUNT_BASED")
|
||||
.failureRateThreshold(50).slidingWindowSize(4).minimumNumberOfCalls(4)
|
||||
.waitDurationInOpenState(60)
|
||||
.permittedNumberOfCallsInHalfOpenState(2)
|
||||
.slowCallRateThreshold(100).slowCallDurationThreshold(5)
|
||||
.useYn("1").build();
|
||||
when(mockDao.get("uuid-RLD-001")).thenReturn(updated);
|
||||
manager.reload("uuid-RLD-001");
|
||||
|
||||
CircuitBreaker cb = manager.getCircuitBreaker("API_RELOAD");
|
||||
assertEquals(60,
|
||||
cb.getCircuitBreakerConfig().getWaitDurationInOpenState().getSeconds(),
|
||||
"reload 후 waitDurationInOpenState 가 60초로 갱신되어야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("4-2. reload(key) useYn=0 → 맵에서 제거 → default fallback")
|
||||
void testReload_key_disabledVo_removesFromMap() throws Exception {
|
||||
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-RLD-002", "API_RM", "1")));
|
||||
manager.reload();
|
||||
assertEquals("API_RM", manager.getCircuitBreaker("API_RM").getName());
|
||||
|
||||
// useYn=0 으로 비활성화
|
||||
TypedCircuitBreakerVO disabled = TypedCircuitBreakerVO.builder()
|
||||
.id("uuid-RLD-002").name("API_RM").type("COUNT_BASED")
|
||||
.failureRateThreshold(50).slidingWindowSize(4).minimumNumberOfCalls(4)
|
||||
.waitDurationInOpenState(1).permittedNumberOfCallsInHalfOpenState(2)
|
||||
.slowCallRateThreshold(100).slowCallDurationThreshold(5)
|
||||
.useYn("0").build();
|
||||
when(mockDao.get("uuid-RLD-002")).thenReturn(disabled);
|
||||
manager.reload("uuid-RLD-002");
|
||||
|
||||
CircuitBreaker cb = manager.getCircuitBreaker("API_RM");
|
||||
assertEquals("default", cb.getName(),
|
||||
"비활성화된 CB 는 맵에서 제거 후 default 로 fallback 되어야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("4-3. reload() 전체 → CircuitBreakerRegistry 재생성으로 이전 CB 상태 초기화")
|
||||
void testReload_all_refreshesAllCircuitBreakers() throws Exception {
|
||||
// 초기: API_FULL_A 등록 후 OPEN 상태로 만들기
|
||||
when(mockDao.getList()).thenReturn(Arrays.asList(buildVo("uuid-FULL-A", "API_FULL_A", "1")));
|
||||
manager.reload();
|
||||
|
||||
CircuitBreaker cbA = manager.getCircuitBreaker("API_FULL_A");
|
||||
recordFailures(cbA, 4);
|
||||
assertEquals(CircuitBreaker.State.OPEN, cbA.getState(), "전체 reload 전 API_FULL_A 는 OPEN 이어야 한다");
|
||||
|
||||
// 전체 reload → CircuitBreakerRegistry 재생성
|
||||
// init() 은 apiIdCBConfigMap 을 초기화하지 않으므로 API_FULL_A 는 맵에 남아있다.
|
||||
// 하지만 새 레지스트리를 생성하므로 CB 상태(OPEN)는 초기화(CLOSED)된다.
|
||||
when(mockDao.getList()).thenReturn(Arrays.asList(
|
||||
buildVo("uuid-FULL-A", "API_FULL_A", "1"),
|
||||
buildVo("uuid-FULL-B", "API_FULL_B", "1")));
|
||||
manager.reload();
|
||||
|
||||
// 레지스트리 재생성으로 API_FULL_A CB 상태가 CLOSED 로 초기화되어야 한다
|
||||
CircuitBreaker cbAAfter = manager.getCircuitBreaker("API_FULL_A");
|
||||
assertEquals(CircuitBreaker.State.CLOSED, cbAAfter.getState(),
|
||||
"전체 reload 후 CircuitBreakerRegistry 재생성으로 이전 CB 상태가 CLOSED 로 초기화되어야 한다");
|
||||
|
||||
// 새 apiId 도 정상 등록
|
||||
assertEquals("API_FULL_B", manager.getCircuitBreaker("API_FULL_B").getName(),
|
||||
"전체 reload 후 새 apiId 는 정상 등록되어야 한다");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 5. 생명주기
|
||||
// =========================================================================
|
||||
|
||||
@Test
|
||||
@DisplayName("5-1. start() 후 isStarted() = true")
|
||||
void testIsStarted_afterStart_returnsTrue() {
|
||||
assertTrue(manager.isStarted(), "start() 후 isStarted() 는 true 여야 한다");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user