The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.7 KiB
kind, slug, title, topic, project, status, sourceRevision, rootTreeNode, evidenceCapturedOn, body, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | rootTreeNode | evidenceCapturedOn | body | assets | evidence | source | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a-circuit-breaker-permit-that-leaks-on-local-rejection | 로컬 거부 경로에서 회로 브레이커 permission이 반환되지 않는다 | http-failure-classification | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | case:a-circuit-breaker-permit-that-leaks-on-local-rejection | 2026-09-02 | case-a-circuit-breaker-permit-that-leaks-on-local-rejection.body.md |
|
|
|
로컬 거부 경로에서 회로 브레이커 permission이 반환되지 않는다
회로 브레이커 permission을 얻은 뒤 rate limiter나 bulkhead가 요청을 거부하면 그 permission이 반환되지 않는다. HALF_OPEN 상태에서는 시험 슬롯이 영구히 소비되어, 회복한 업스트림에 대해 회로가 닫히지 않을 수 있다.
관계
- 전송 실패의 단계와 범주 — AttemptStage와 FailureCategory 이 파이프라인이 그 분류를 만들기 전에 지나는 승인 계층이다.
문제
요청 하나가 실행되기 전에 세 가드를 차례로 지난다. 회로 브레이커가 permission 을 주고, rate limiter 가 토큰을 주고, bulkhead 가 슬롯을 준다.
뒤의 두 가드가 거부하면 그 경로는 예외를 던지고 끝난다. 그 사이에 이미 받아 둔 회로 permission 을 돌려주는 호출이 없다.
결론
경로 확인으로 확정한 결함이다. 회로가 반쯤 열린 상태에서만 발생한다.
업스트림 장애로 회로가 열린다. 대기 후 반쯤 열린 상태로 바뀌고, 트래픽이 돌아온다. 그 순간 평상시 부하에 맞춰 사이징된 로컬 rate limiter 나 bulkhead 가 거부하기 시작하고, 거부마다 시험 슬롯 하나가 사라진다.
거부가 허용된 시험 호출 수만큼 쌓이면 브레이커는 성공도 실패도 못 본 채 그 상태에 머문다. 회복한 업스트림에 대해 회로가 닫히지 않는다.
수정하려면 인터페이스에 permission 반환 연산을 추가하고, rate limiter 와 bulkhead 가 요청을 거부하는 두 경로에서 그 연산을 호출해야 한다.
검증 환경
OpenJDK : 21.0.12 Gradle : 9.0.0 Spring Boot : 4.0.8 Resilience4j : 2.2.0 확인 방식 : 파이프라인 진입부의 예외 경로 추적, 인터페이스 연산 전수 확인, 반환 연산 이름 검색 소스 수정 : x
재현 조건
- AttemptResiliencePipeline.execute 의 83행부터 97행까지를 읽고, 세 가드의 거부 경로에서 회로 브레이커 연산이 호출되는지 확인한다.
- AttemptCircuitBreaker 의 추상 연산을 전수 확인한다. 넷이며 반환 연산이 없다.
- releasePermission 을 코드베이스에서 검색한다. Java 매치가 0 이다.
- AttemptResiliencePipelineTest 에서 회로 permission 반환을 단언하는 테스트가 있는지 확인한다.
본문
AttemptResiliencePipeline.execute 진입부에서 세 가드가 차례로 실행된다. 83행이 회로 permission 을 얻고, 88행이 rate limiter 를, 93행이 bulkhead 를 본다.
83행을 통과한 뒤 88행과 93행이 거부하면 그 두 경로는 예외를 던지고 끝난다. 회로 permission 이 돌아오지 않는다.
반환할 연산 자체가 인터페이스에 없다
:::evidence key="a-circuit-breaker-permit-that-leaks-on-local-rejection" alt="코드베이스에서 AttemptResiliencePipeline 의 가드와 반납 지점, AttemptCircuitBreaker 의 추상 연산 전수, releasePermission 검색 결과를 뽑은 출력 26줄. 인터페이스의 연산 넷과 Java 코드 매치 0, 그리고 그 이름이 설계 문서에만 남아 있다는 것이 그 출력에 그대로 보인다." caption="세 가드 · 정상 경로 반납 · AttemptCircuitBreaker 연산 넷 · releasePermission 검색 — 26줄 · exit 0" zoom="true" :::
AttemptCircuitBreaker 의 추상 연산은 넷이다 — tryAcquirePermission · onSuccess · onError · state. 획득은 있고 반환은 없다.
releasePermission 은 src 아래 Java 파일에서 매치가 0 이다. 저장소 전체로 넓히면 한 곳에 나오는데, 그것은 이 능력의 설계 문서다. 즉 이름이 설계 단계에서는 존재했고 구현에는 들어오지 않았다.
로컬 거부 경로가 부르는 것을 잊은 것이 아니라, 부를 수 있는 연산이 없다.
같은 거부 경로에서 rate 토큰은 돌려준다
94행이 이 판정을 뒷받침한다. bulkhead 가 거부하는 경로는 rateLimiter.onCompleted() 를 불러 rate 토큰을 명시적으로 반환한다. 저자가 permit 반환을 의식하고 있었다는 증거다.
정상 경로에도 같은 의식이 보인다. releaseAttemptPermits() 가 bulkhead.release() 와 rateLimiter.onCompleted() 를 함께 부른다 — 자료의 120행이 그 두 번째 호출이다. 세 가드 중 둘은 정상 경로에서도 거부 경로에서도 반납되고, 회로만 어느 쪽에서도 반납되지 않는다.
이 결함은 HALF_OPEN 에서만 값을 갖는다
Resilience4j 의 tryAcquirePermission() 은 반쯤 열린 상태에서 허용된 시험 호출 수 중 하나를 소비한다. 그 슬롯은 onSuccess · onError · releasePermission 중 하나로만 돌아온다. 아무것도 부르지 않으면 슬롯은 영구히 소비된다.
닫힌 상태에서는 permission 이 계수를 소비하지 않으므로 같은 코드가 무해하다. 그래서 이 결함은 코드가 아니라 상태에 걸려 있고, 평상시 테스트로는 드러나지 않는다.
조건들이 우연히 겹치지 않는다
시험 슬롯이 열리는 시점은 업스트림이 회복을 시작한 시점이고, 트래픽이 돌아오는 시점도 같다. 평상시 부하에 맞춰 사이징된 로컬 가드는 그 순간에 거부하기 시작한다.
거부마다 슬롯 하나가 사라진다. 허용된 시험 호출 수만큼 거부가 나면 브레이커는 성공도 실패도 관측하지 못한 채 그 상태에 머문다. maxWaitDurationInHalfOpenState 기본값이 0 — 무한 대기 — 이므로 시간이 그것을 풀어 주지도 않는다.
테스트가 그 공백을 그대로 보여 준다
인접한 두 성질에는 테스트가 있다.
openCircuitDoesNotConsumeRateOrBulkheadPermit— 회로가 거부할 때 뒤의 둘을 소비하지 않는다bulkheadRejectionReleasesTheRateLimiterAndIsNotACircuitError— bulkhead 거부가 rate 를 돌려준다
둘째 테스트가 단언하는 이벤트 순서는 circuit-enter · rate-enter · bulkhead-reject · rate-exit 다. 회로를 돌려주는 이벤트가 그 목록에 없다. 88행의 rate limiter 거부 경로에는 테스트가 아예 없다.
수정
AttemptCircuitBreaker 에 releasePermission() 을 더해 Resilience4j 의 같은 이름 연산에 위임하고, alwaysClosed() 구현에서는 아무것도 하지 않게 둔다. 그리고 두 로컬 거부 경로에서 그것을 부른다.
확인하지 못한 것
시험 슬롯 고갈을 반복 호출로 재현해 보지는 않았다. 회로가 닫히지 않는 상태를 런타임에서 관측한 것은 아니다.
Resilience4j 의 상태별 permission 회계와 무한 대기 기본값은 그 라이브러리의 문서화된 동작을 근거로 삼았고, 이 회차에 라이브러리 코드를 실행해 확인하지는 않았다.