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>
75 KiB
11 · adapter-outbound-httpclient 완전 해부
상태: COMPLETE — 2026-08-31 재검증 기준 revision(최초 분석):
a24ece9cf797f7ea647e33bf846b115208ed1ba5재검증 revision:21234e38cdb9a926cbc92bb97a2aee2e4a7d2916· 리프 소스 변경 0 (EVD-333) 분석 범위:src/adapter/outbound/httpclient· Gradle:adapter:outbound:httpclientSSOT owner:adapter-outbound-httpclientintegration/family document:analysis/00-project-overview.md(secondary, INTEGRATION_ONLY)
0. SSOT identity · denominator · coverage ledger
- registered leaf id:
adapter-outbound-httpclient - canonical state
analysisFile:analysis/11-adapter-outbound-httpclient.md(이 문서) - source path:
src/adapter/outbound/httpclient - registry
allowed_dependencies:["domain-core", "application-core", "shared-contract", "adapter-outbound-support"] - registry
runtime_memberships:["app-bootstrap"] - 재검증 증거:
EVD-332(§51 진단 교정),EVD-333(소스 드리프트 0)
고정 섹션 골격과의 대응. 이 문서는 leaf 안에 7개 하위 범위가 있어 하위 범위별로 번호를 이어 쓴다. 고정 골격의 각 역할은 다음 절이 맡는다 — 재검증은 번호를 바꾸는 대신 대응을 명시한다.
| 고정 골격 | 이 문서 |
|---|---|
| §0 SSOT identity / denominator / coverage ledger | 이 절 + §52 |
| 하위 범위 denominator | §1, §10, §19, §27, §34, §41, §45 |
| §12 negative-space probes 4종 | §7, §16, §24, §31, §38, §48 (하위 범위별) |
| §14 evidence table | §55 + EVD-332, EVD-333, evidence 167~175 |
| §16 확인한 것 / 확인하지 못한 것 | §55 |
| §17 손볼 것 (P1/P2/P3) + 확인된 설계 | §53 |
tracked file 370개 — main 260 (15,004 LOC), test 62 (6,049), testkit source set 35 (2,754), httpClientPerformanceTest 7 (495), jmh 2 (130), governance 4. 총 약 24.4k LOC.
{ "id": "adapter-outbound-httpclient",
"gradle_path": ":adapter:outbound:httpclient",
"allowed_dependencies": ["domain-core", "application-core", "shared-contract", "adapter-outbound-support"],
"runtime_memberships": ["app-bootstrap"] }
cache-redis와 같은 형태다 — 설계 문서는 이 플랫폼을 19개 Gradle 모듈로 나누지만 "This repository's fail-closed module registry outranks that layout, so the module boundaries are packages under dev.caskeleton.adapter.outbound.httpclient and HttpClientModuleBoundaryTest enforces the design's module dependency table."
main 패키지 배치(260):
| 계층 | 패키지 | 파일 |
|---|---|---|
| 복원력 | resilience |
39 |
| 프로파일/조립 | profile |
29 |
| 공개 API | api/error 23 · api/result 9 · api/body 7 · api/operation 6 · api 5 |
50 |
| 보안 | security 21 · auth 18 |
39 |
| 게이트웨이 | restclient 19 · webclient 17 |
36 |
| 서비스/동적 | service 14 · dynamic 11 · observation 6 · migration 4 |
35 |
| 전송 | transport 10 · reactor 6 · apache 5 · jdk 4 · http3 4 · spring7 3 |
32 |
build.gradle이 이 저장소에서 가장 긴 근거 주석을 갖는다. 몇 가지가 특히 이 leaf의 성격을 보여 준다.
- HTTP/3가
compileOnly인 이유 — 이전에는implementation이어서 "the whole QUIC/HTTP-3/QPACK stack on every deployment's runtime classpath — megabytes and an attack surface — to serve a feature the Stable starter never auto-configures." - Jackson 3가 optional이 아닌 이유 —
RestClientResponseReader.defaultConverters()가 Spring 7 컨버터를 만드는데 생성자 서명에 타입이 없어 javac가 못 잡았고, Jackson 3가 test/jmh 클래스패스에만 있어서 "the reader worked in every test and would have thrownNoClassDefFoundErrorin any deployment that did not happen to have Jackson 3 from somewhere else." - Resilience4j는 실행 원시연산만 — "HTTP retry eligibility is owned by this module (design D-09) and never delegated to a generic retry library."
- testkit이 별도 source set인 이유 — 세 lane이 소비하는데 그중 하나만 test lane이고,
jmh가sourceSets.test.output을 참조하면 IDE가 모델링하지 못해 "every testkit reference in the benchmarks was an unresolved type in the editor while the build was green." - lane 5개 중 둘이
failOnNoDiscoveredTests를 잃었던 기록 — "the cross-transport contract suite and the SSRF/credential-leak suite would each have reported success on discovering nothing. Declaring the lanes removes the opportunity: the convention has no opt-out." check가 무엇을 게이트하는지 — 특수 lane들이 "existed but hung off nothing:checkran onlytest". 지금은 hermetic한 넷(contract·security·BlockHound·spring62 surface)이check에 붙어 있고, Docker가 필요한 fault lane과 머신 의존적인 performance lane은 의도적으로 빠져 있다.
하위 범위 ledger
| # | 범위 | main | test | 기타 | 합 | 상태 |
|---|---|---|---|---|---|---|
| 1 | governance + profile/** — 프로파일 검증 · 런타임 레지스트리 · 조립 |
29 | 2 | 4 | 35 | COMPLETE |
| 2 | api/** — error · result · body · operation · 루트 |
50 | 5 | – | 55 | COMPLETE |
| 3 | resilience/** — 재시도 자격 · 회로 · 격벽 · 파이프라인 |
39 | 8 | – | 47 | COMPLETE |
| 4 | restclient/** + webclient/** — 블로킹/리액티브 게이트웨이 |
36 | 10 | – | 46 | COMPLETE |
| 5 | security/** + auth/** — SSRF 방어 · TLS 재료 · 자격증명 |
39 | 7 | – | 46 | COMPLETE |
| 6 | service + dynamic + observation + migration + contract/architecture test |
35 | 21 | – | 56 | COMPLETE |
| 7 | 전송 6종 + testkit / performance / jmh source set | 32 | 9 | 44 | 85 | COMPLETE |
| TOTAL | 260 | 62 | 48 | 370 | 7 / 7 |
manifest: evidence/raw/167-httpclient-module-inventory.txt.
1. Sub-scope 01 범위와 denominator
내부 상태: COMPLETE — 35 / 35 FULL_READ 범위: governance 4 +
profile/**main 29 (1,271 LOC) + 전용 test 2 역할: Named Client Profile의 fail-closed startup 검증과 런타임 세대(generation) 레지스트리
manifest와 probe: evidence/raw/168-httpclient-profile-probes.txt.
2. ClientProfileValidator — 34개 위반 코드가 각각 과거 사고를 적는다
이 저장소에서 본 가장 조밀한 설정 검증기다. validate(profile, environment)가 11개 검사 그룹을 돌리고 결과를 정렬해 "a configuration error reports deterministically across runs and machines"를 보장한다.
특히 이 leaf에서만 보이는 태도가 하나 있다 — 바인딩은 되지만 어떤 전송에도 닿지 않는 설정을 무시하지 않고 거부한다.
"Three of them had no consumer anywhere:
timeout.dns,proxy.credential-providerandproxy.import-ambient-no-proxy. An operator who set a DNS timeout believed resolution was bounded and it was not; one who named a proxy credential provider believed the proxy was authenticated and it was not… the honest position is to refuse a value the platform cannot honour instead of accepting it and doing nothing."
기본값은 통과시키므로 "only a deliberate, unmet request fails"다. 앞선 열 개 모듈에서 반복해 발견한 "선언되었으나 아무것도 하지 않는 설정" 패턴을, 이 모듈은 명시적 거부로 처리한다.
같은 논리가 관측 설정에도 적용된다 — full-url-recording은 아무도 읽지 않았고 body-logging은 actuator 보고에만 닿았다. "Leaving them that way is the worse of the two failure modes — an operator who set them believed the platform was recording full URLs or bodies, and an operator who left them false had no assurance that it was not." 지금은 production에서 둘 다 거부된다.
나머지 검사도 각각 구체적인 다운그레이드를 막는다.
REACTIVE_REDIRECT_UNSUPPORTED— 엔진 리다이렉트는 모든 전송에서 꺼져 있고 hop별 재검증을 하는 coordinator는 블로킹 스택에만 있다. 리액티브 프로파일이 redirect를 켜면 "the caller received the 302 as an ordinary response and read its empty body as the answer." 거부가 정직한 결과다 — "a configured guarantee that silently does nothing is worse than one the platform declines to offer."HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED—ProtocolIntent가 "H2를 선호"와 "H2를 요구"를 구분한다. JDK 클라이언트는HTTP_2를 선호로 다뤄 조용히 HTTP/1.1로 협상하고 Apache classic은 HTTP/1.1 전용이라,HTTP_2만 선언한 프로파일이 "ran happily over HTTP/1.1, and nothing anywhere said so."TLS_PROTOCOL_SET_REQUIRED— 빈 집합이 통과하면 JVM 기본값이 선택되어, "a profile that meant to pin a TLS floor got whatever the platform default happened to be — including TLS 1.2 on a profile whose operator had deliberately emptied the list to 'tighten' it."DYNAMIC_TARGET_PROXY_UNSUPPORTED— 포워드 프록시는 호스트명을 자기 쪽에서 다시 해석하므로 "The SSRF defence would be present, correct, and bypassed."RETRY_POLICY_CONTRADICTS_ATTEMPTS—policy를 실행 경로에서 아무도 읽지 않아 "the actuator could reportretryPolicy: nonefor a profile that was retrying three times."
배선 확인. app-bootstrap의 HttpClientStartupValidator:37이 이 검증기를 생성한다(168-... §8.1). 이 leaf는 앞선 cache-redis와 달리 실제로 조립돼 있다 — app-bootstrap에 이 leaf를 위한 auto-configuration 12개가 있다.
3. ClientRuntimeRegistry — 세대 교체가 틈으로 관측되지 않는다
"A swap publishes the replacement first and drains the predecessor afterwards, so a rotation is never observable as a gap." acquire는 관측한 세대가 예약 직전에 draining으로 넘어가면 새로 발행된 세대에 대해 재시도한다.
과거 누수 두 건이 코드와 주석에 남아 있다.
- 교체된 세대가
runtimes에서 빠지고 스케줄된 drain 작업만 소유하게 되어, 그 작업이 발화하기 전에 레지스트리가 닫히면 "leaked the whole generation — and the resource-bound suite could not see it, because nothing enumerated it." 지금은retired집합이 추적한다. close()가forEach로 닫다가 첫 예외에서 멈춰 "a single misbehaving pool left every remaining connection, thread and socket open — shutdown leaked more the worse the failure was." 지금은 전부 닫고 실패를 suppressed로 모은다.
4. P3 — close()가 실패하면 drain 스케줄러 스레드가 남는다
§3의 두 번째 수정이 절반만 적용돼 있다.
retired.clear();
runtimes.clear();
if (firstFailure != null) {
throw firstFailure; // ← 여기서 던진다
}
ScheduledExecutorService scheduler = drainScheduler.getAndSet(null); // ← 도달하지 않는다
if (scheduler != null) {
// Await termination: a registry that returns while its drain thread is still alive would
// leak a thread per rotation cycle, which the resource-bound suite exists to catch.
scheduler.shutdownNow();
…
}
forceClose() 중 하나라도 던지면 throw firstFailure가 먼저 실행되어 스케줄러 종료 블록에 도달하지 않는다. drainScheduler도 비워지지 않으므로 스레드가 살아 있고, 클래스 javadoc이 "The single scheduled executor… is shut down with the registry so no thread outlives it"이라고 적은 성질이 그 경로에서 성립하지 않는다.
바로 위 루프는 "Every runtime is closed even when one refuses"를 위해 예외를 모으도록 고쳐졌는데, 같은 논리가 스케줄러에는 적용되지 않았다. test closingTheRegistryReleasesEveryGenerationAndLeavesNoThread는 실패 없는 경로만 검증한다.
판정: P3. 스레드가 데몬이라 JVM 종료를 막지는 않고, 레지스트리당 하나이며, 닫기 실패라는 조건이 필요하다. 그러나 주석이 "a thread per rotation cycle"을 명시적 위험으로 적고 resource-bound suite가 그것을 잡으려 존재하는데, 정확히 그 누수가 실패 경로에 남아 있다. 수정은 스케줄러 종료를 finally로 옮기는 한 줄이다.
5. P3 — POOL_ROUTE_EXCEEDS_TOTAL 위반 코드는 발화할 수 없다
// PoolSettings 정규 생성자
if (maxConnectionsPerRoute > maxTotalConnections) {
throw new IllegalArgumentException("per-route pool must not exceed the total pool");
}
// ClientProfileValidator:192
if (profile.pool().maxConnectionsPerRoute() > profile.pool().maxTotalConnections()) {
out.add(violation("POOL_ROUTE_EXCEEDS_TOTAL", profile, "pool.max-connections-per-route"));
}
ClientProfile은 이미 구성된 PoolSettings를 들고 있고, 그 record는 route > total인 상태로 존재할 수 없다. production의 유일한 생성 지점(HttpClientProfileFactory:83)도 같은 생성자를 지난다. 따라서 검증기의 이 분기는 도달 불가이고, 그 코드의 test 참조가 0인 것도 그래서다(§6).
sub-scope 03(redis)의 requireIdentifier 죽은 분기와 같은 모양이다 — 선행 검증이 후행 검증을 가린다. 다만 결과가 다르다: record 생성자는 IllegalArgumentException을 던져 startup을 즉시 실패시키므로, 검증기가 수집해 정렬된 목록으로 보고하는 결정적 진단 형식을 이 한 조합만 받지 못한다. 주석이 설명하는 실제 위험("on Reactor — where the per-route knob is the only one that exists — it silently becomes the effective limit")은 여전히 막혀 있다. P3.
6. P3 — 위반 코드 34종 중 22종이 어떤 test에서도 이름으로 확인되지 않는다
ClientProfileValidator가 내는 코드는 34종이다. 저장소 전체의 test/testkit source set에서 그 문자열을 참조하는 파일 수를 세면(168-... §8.4b):
| test 참조 | 코드 수 | 예 |
|---|---|---|
| 1건 이상 | 12 | TRUST_ALL_FORBIDDEN(3) · HOSTNAME_VERIFICATION_REQUIRED(2) · PLAINTEXT_*(2) · HTTP3_STABLE_FORBIDDEN(2) … |
| 0건 | 22 | DYNAMIC_TARGET_PROXY_UNSUPPORTED · FULL_URL_RECORDING_FORBIDDEN · BODY_LOGGING_FORBIDDEN · REACTIVE_REDIRECT_UNSUPPORTED · HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED · TLS_PROTOCOL_SET_REQUIRED · DNS_TIMEOUT_UNSUPPORTED · PROXY_CREDENTIAL_UNSUPPORTED · RETRY_POLICY_CONTRADICTS_ATTEMPTS · MISSING_PRODUCTION_SETTING · ALLOWED_HOST_MISMATCH · ALLOWED_PORT_MISMATCH … |
문제는 개수가 아니라 어느 쪽이 비어 있는가다. 확인되지 않는 22종에는 §2가 인용한 사고 유래 가드가 거의 전부 들어 있다 — SSRF 우회(DYNAMIC_TARGET_PROXY_UNSUPPORTED), 로그의 PII(FULL_URL_RECORDING_FORBIDDEN·BODY_LOGGING_FORBIDDEN), 조용한 프로토콜 다운그레이드(HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED·TLS_PROTOCOL_SET_REQUIRED), 아무 일도 하지 않는 설정(DNS_TIMEOUT_UNSUPPORTED·PROXY_CREDENTIAL_UNSUPPORTED), 그리고 리다이렉트를 조용히 무시하는 경우(REACTIVE_REDIRECT_UNSUPPORTED).
test 두 개(ClientProfileValidatorTest 106줄)가 그룹으로 몇 개를 묶어 확인하지만(rejectsSimpleFactoryAndUnacknowledgedHttp3AndJdkRoutePool), 나머지 22종은 분기를 지워도 초록으로 남는다. 코드 자체는 현재 옳다 — 위험은 회귀다. P3. 수정은 @ParameterizedTest로 코드별 최소 케이스를 한 벌 놓는 것이고, 34종이 모두 결정적으로 정렬된 목록을 내므로 그 형태가 자연스럽다.
7. Negative-space probes — sub-scope 01
- 8.1 reachability:
ClientProfileValidator가 app-bootstrapHttpClientStartupValidator에 배선됨 확인.ClientRuntimeRegistry는 leaf 내부 10곳 + app-bootstrap 5곳에서 소비. - 8.2 계약 ↔ 구현: 위반 코드 34종 전수 열거와 각 코드가 막는 다운그레이드를 주석에서 추적(§2).
- 8.3 중복 mechanism:
PoolSettings생성자와 validator가 같은 규칙을 두 번 검사하고 후자가 도달 불가(§5). - 8.4 test 대비 표면: 코드 34 vs test 참조 12(§6).
close()실패 경로의 스케줄러 도달성(§4).
8. Sub-scope 01 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| P3 | ClientRuntimeRegistry.close()가 throw firstFailure를 스케줄러 종료보다 먼저 실행해, 닫기 실패 시 drain 스레드가 남는다 — 클래스 javadoc의 "no thread outlives it"과 어긋난다 |
forceClose()가 던지는 종료 |
| P3 | POOL_ROUTE_EXCEEDS_TOTAL 분기가 PoolSettings 생성자에 가려 도달 불가 |
진단 형식 |
| P3 | 위반 코드 34종 중 22종이 어떤 test에서도 이름으로 확인되지 않고, 그 22종에 사고 유래 보안 가드가 대부분 포함된다 | 회귀 |
9. Sub-scope 01 완료 조건
- denominator 35 / 35 FULL_READ (
168-...OWNED FILES) - §8.1~§8.4 probe 수행, 중복 mechanism 1건 · 도달성 2건 조사
- 위반 코드 34종을 전수 열거해 test 참조 수를 계수(§6)
- 소스 미변경
10. Sub-scope 02 범위와 denominator
내부 상태: COMPLETE — 55 / 55 FULL_READ 범위:
api/error23 +api/result9 +api/body7 +api/operation6 +api루트 5 (main 50, 1,552 LOC) + 전용 test 5 역할: 이 플랫폼의 공개 어휘 — 무엇을 요청하고, 무엇을 증거로 삼고, 무엇이 실패했는지
manifest와 probe: evidence/raw/169-httpclient-api-probes.txt.
11. 증거(evidence) 모델이 이 모듈의 중심이다
세 개의 enum이 재시도 안전성 판단의 전부를 담는다.
ExecutionEvidence — NOT_SENT · SENT_NO_RESPONSE · RESPONSE_RECEIVED · PARTIAL_RESPONSE. 규칙 한 줄이 붙어 있다 — "NOT_SENT is only used when a stage failure proves the request never reached the server. A generic engine I/O failure is never upgraded to NOT_SENT."
AttemptStage — 12단계에 명시적 order와 provesNotSent 플래그가 붙는다. VALIDATION(0)부터 PROXY_CONNECT(6)까지가 provesNotSent=true이고 REQUEST_HEADERS(7)부터는 false다. 즉 요청 헤더를 쓰기 시작한 순간부터는 "보내지 않았다"를 증명할 수 없다는 규칙이 데이터로 표현된다. order가 enum 서수와 분리된 이유도 적혀 있다 — "the progress tracker forbids regression and the evidence classifier reads the rank, so neither depends on enum declaration ordinals."
BodyReplayability — REPLAYABLE(3) · REOPENABLE(2) · ONE_SHOT(1) · UNKNOWN(0)에 weakest(left, right) 결합 연산이 있어 복합 본문이 가장 약한 쪽을 따른다. strength도 서수와 분리돼 있다.
그리고 OperationIdempotency가 네 번째 축을 더한다 — 규칙 한 줄이 설계 결정 D-09다: "Retry eligibility never derives safety from the HTTP method alone." CONTRACT_IDEMPOTENT는 "메서드는 표준 멱등이 아니지만 업스트림 계약이 반복 안전을 보장한다"를 표현 가능하게 만든다.
12. 저카디널리티·무비밀 원칙이 타입 수준에서 강제된다
HttpFailureMetadata의 javadoc이 제외 목록을 열거한다 — "Full URL, query values, expanded path variables, request or response bodies, Authorization / Cookie / API key values, the raw idempotency key, client secrets, and resolved IPs are deliberately absent."
그리고 toString() 재정의 세 곳이 각각 과거 유출을 적는다.
HttpOperation.toString()— 생성된 record toString이 "every header value, the body object and the expanded URI variables"를 찍었고, "anAuthorizationheader, a request payload and a customer identifier were one stack trace away from the log aggregator." 지금은 헤더 키 집합과 본문 클래스 이름만 낸다.ObjectBody.toString()— "the generatedtoStringrendered the payload itself… which for an outbound call is by definition someone else's data." 지금은ObjectBody[Type, media/type, REDACTED].IdempotencyKey.toString()—IdempotencyKey[REDACTED], 원값은 명시적 접근자로만.
api/body의 나머지 넷(BodySource·EmptyBody·IOSupplier·OneShotStreamBody·ReopenableStreamBody)은 toString을 재정의하지 않는데, 그 record 성분이 스트림 핸들·공급자·길이·미디어 타입이라 기본 toString이 데이터를 찍지 않는다. 값을 들고 있는 둘(ByteArrayBody·ObjectBody)만 재정의돼 있다 — 전수 확인했다(169-... §8.1).
HttpOperation은 URI 템플릿만 들고 다닌다("never an expanded URL: observability tags and failure metadata must stay low-cardinality, and the security layer expands components itself"). TRACE는 enum에 없고, test exposesHttpMethodSemanticsWithoutTrace가 HttpMethod.values()에 "TRACE"가 없음을 반사로 확인한다 — 저장소 전체에서 TRACE 문자열의 다른 참조는 그 test 한 줄뿐이다.
FailureCategory 24종이 전송 중립 어휘이고 "Every transport classifier maps engine-specific exceptions onto exactly one of these values so Apache, JDK, Reactor Netty, and Jetty produce identical retry and observation semantics." permanent()가 8종을 영구 실패로 분류한다. api/error에는 예외 21종 + 메타데이터 1종이 있고 전부 HttpClientException을 상속하며 metadata를 노출한다(test everyStableFailureExposesMetadata).
13. ObjectBody의 재생 가능성 판정 — 값의 성질이지 코덱의 성질이 아니다
이 sub-scope에서 가장 신중한 코드다. 과거 동작과 그 결과가 적혀 있다.
"Every
ObjectBodyused to reportREPLAYABLEunconditionally, on the strength of a javadoc line asking callers not to mutate the value afterwards. A mutable DTO handed to the platform and then changed by the caller — a builder reused across calls, a collection the caller kept a reference to — produced a retry that sent different bytes under the same idempotency key, which is the one thing a replay must never do."
지금은 deeplyImmutable(value)가 구조적으로 판정한다 — 문자열·숫자·불리언·문자·enum·UUID·Temporal은 통과, 컬렉션과 맵은 JDK의 불변 뷰인지 이름으로 확인하고 원소까지 재귀, record는 모든 성분을 반사로 재귀 확인, 그 외는 전부 ONE_SHOT. 반사가 실패하면 "A component the platform cannot inspect cannot be certified, and an uncertified body is one-shot rather than optimistically replayable."
컬렉션 판정이 이름 기반인 이유도 적혀 있다 — "List.of(...) and Collections.unmodifiableList(...) return package-private classes with no shared marker interface. An ordinary ArrayList the caller still holds is exactly the case this must not accept."
test 넷이 네 갈래를 고정한다 — anImmutableRecordReplays, aMutableValueIsOneShot, aRecordWrappingMutableStateIsOneShot, anArbitraryBeanIsOneShot.
14. P3 — Number가 허용 목록에 있어 가변 숫자 타입이 REPLAYABLE로 인증된다
deeplyImmutable의 첫 분기가 candidate instanceof Number를 무조건 통과시킨다.
if (candidate instanceof String
|| candidate instanceof Number // ← java.util.concurrent.atomic.* 가 전부 여기 들어온다
|| candidate instanceof Boolean
…) {
return true;
}
java.util.concurrent.atomic의 AtomicInteger·AtomicLong·LongAdder·DoubleAdder·LongAccumulator·DoubleAccumulator는 모두 Number를 상속하는 가변 타입이다. 따라서 성분에 AtomicInteger를 가진 record(또는 ObjectBody의 값 자체가 AtomicInteger인 경우)는 REPLAYABLE로 인증되고, 호출자가 그 사이에 값을 증가시키면 재시도가 같은 idempotency key로 다른 바이트를 보낸다 — 이 검사가 존재하는 이유로 인용된 바로 그 결과다.
같은 형태의 좁은 구멍이 Temporal에도 있다(java.time 구현체는 불변이지만 사용자 정의 Temporal 구현은 그렇지 않을 수 있다). 그러나 Number가 훨씬 현실적이다 — DTO에 카운터를 두는 것은 드물지 않다.
판정: P3. 도달성이 좁고(원자 카운터를 요청 DTO에 넣어야 한다), 검사 전체의 방향은 보수적이며, aMutableValueIsOneShot test가 일반적인 가변 객체는 잡는다. 기록하는 이유는 이 검사가 "records, enums, strings, boxed primitives and immutable collection views replay; anything else is treated as one-shot"라고 선언하는데 Number 한 줄이 그 선언보다 넓기 때문이다. 수정은 boxed primitive 여덟 종과 BigInteger/BigDecimal을 명시하거나, java.util.concurrent.atomic 패키지를 제외하는 것이다.
15. P3/기록 — 재생 가능성 판정이 호출마다 반사로 재계산된다
replayability()는 캐시 없이 매번 deeplyImmutable(value)를 돌리고, record 성분마다 accessor.setAccessible(true) + invoke를 수행한다. 재시도 엔진은 물리 시도마다 이 값을 읽으므로, 중첩이 깊은 DTO에서는 시도 수 × 트리 크기만큼 반사 호출이 일어난다.
정확성 문제는 아니다 — 결과가 값에만 의존하므로 캐시해도 같다. 이 모듈에 httpClientPerformanceTest source set이 따로 있고 "assert on resource bounds rather than behaviour"를 목적으로 하는데, 그 lane이 이 경로를 재는지는 sub-scope 07에서 확인한다. P3/기록.
16. Negative-space probes — sub-scope 02
- 8.1 값 유출 표면:
api/body의 7종 중 값을 들고 있는 둘만toString을 재정의했고 나머지는 기본 toString이 데이터를 찍지 않음을 전수 확인(§12). - 8.2 허용 목록의 경계:
deeplyImmutable의 7개instanceof분기와 컬렉션 이름 판정을 전수 검토,Number에서 구멍 발견(§14). - 8.3 어휘 ↔ 예외 대응:
FailureCategory24종과api/error예외 21종의 대응 확인. - 8.4 부재 주장:
TRACE가 enum·구현·부트스트랩 어디에도 없고 참조는 그 부재를 확인하는 test 한 줄뿐(§12).
17. Sub-scope 02 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| P3 | ObjectBody.deeplyImmutable의 instanceof Number가 AtomicInteger·LongAdder 등 가변 숫자 타입을 REPLAYABLE로 인증한다 — 재시도가 같은 idempotency key로 다른 바이트를 보낼 수 있다 |
원자 카운터를 담은 요청 DTO |
| P3/기록 | replayability()가 호출마다 반사로 재계산되고 캐시가 없다 |
깊은 DTO · 다중 시도 |
18. Sub-scope 02 완료 조건
- denominator 55 / 55 FULL_READ (
169-...OWNED FILES) - §8.1~§8.4 probe 수행, 허용 목록 경계 조사 1건
- 증거 3축(
ExecutionEvidence·AttemptStage·BodyReplayability)과 멱등성 축의 관계를 코드로 추적 - 소스 미변경
19. Sub-scope 03 범위와 denominator
내부 상태: COMPLETE — 47 / 47 FULL_READ 범위:
resilience/**main 39 (1,746 LOC) + 전용 test 8 (47 test 메서드) 역할: 이 플랫폼이 존재하는 이유 — 증거 기반 재시도 자격 판정과 시도별 가드
manifest와 probe: evidence/raw/170-httpclient-resilience-probes.txt.
20. 재시도 결정표가 순서로 표현돼 있다
DefaultRetryEligibilityEngine.decide의 javadoc이 규칙이다 — "The order is the point. Cheap absolute blockers come first (attempts, budget, replayability, first byte, deadline, draining), then ambiguity, then status- and failure-specific rules. A later rule can never re-enable something an earlier rule forbade."
절대 차단 여섯이 먼저다 — 시도 수 소진 · 예산 소진 · 본문 재생 불가 · 첫 바이트 전달됨 · 런타임 draining · 남은 deadline이 최소 시도 예산 이하. 그다음 영구 실패 범주, 그다음 증거, 그다음 상태/실패별.
상태별 규칙에 수정 이력이 붙어 있다.
"408, 425 and 429 all mean the request reached the upstream and was answered, so repeating one is only safe under the same rule as every other repeat. These three used to skip that check: a non-idempotent POST answered 429 was retried, and a rate-limited upstream that had already accepted the work got it a second time. A 429 is a scheduling signal, never a statement that nothing happened."
그리고 RetryContext.safelyIdempotent()가 이 모듈의 D-09를 구현한다 — HTTP 메서드는 RetryContext에 아예 없다("so a POST with a registered idempotency key and a GET against a non-idempotent RPC endpoint are both handled correctly instead of by method-name folklore"). 키 기반 멱등성은 키가 실제로 전송됐는지까지 요구한다.
"The two used to be conflated: the platform read
idempotencyKey.isPresent(), concluded the upstream could deduplicate, and retried — while the header was never sent, so the upstream had nothing to deduplicate against and processed the request twice. Possession of a key is the caller's intent; transmission is the upstream's ability to honour it."
test aKeyThatWasNeverSentDoesNotMakeARepeatSafe가 그것을 고정한다.
Retry-After는 남은 deadline 안에 들어갈 때만 존중된다(allowWithin), 그리고 존중된 Retry-After는 maxBackoff로 잘리지 않는다 — 잘라 버리면 업스트림이 요청한 대기보다 일찍 다시 두드리게 되기 때문이다.
21. 가드 순서와 그 근거
AttemptResiliencePipeline이 물리 시도마다 Circuit Breaker → Rate Limiter → Bulkhead → HTTP 호출을 고정 순서로 적용하고 역순으로 해제한다.
"The order is not cosmetic. An open circuit must reject before a rate token or a bulkhead permit is spent, otherwise a dead upstream keeps consuming the quota and concurrency that healthy upstreams need." "A local rejection (rate limiter or bulkhead) is deliberately not recorded as a circuit error: the upstream never saw the request, and counting our own back-pressure as upstream failure would open the breaker on a healthy dependency."
그리고 브레이커가 무엇을 보는지에 대한 수정 이력이 하나 더 있다 — 이전에는 원시 전송만 파이프라인 안에서 돌고 응답→예외 매핑이 밖에서 일어나서 "a 503 completed the call normally, the breaker recorded a success, and an upstream that answered nothing but 503 never opened its circuit. The thing the breaker is for was the one thing it could not see." 지금은 remoteFailure 분류기가 반환값을 보고 브레이커에 알린다.
test 47개가 이 규칙들을 촘촘히 덮는다 — appliesCircuitThenRateLimiterThenBulkheadPerAttempt, openCircuitDoesNotConsumeRateOrBulkheadPermit, bulkheadRejectionReleasesTheRateLimiterAndIsNotACircuitError, answeredStatusesDoNotRetryANonIdempotentOperation, deniesOneShotBodyEvenForPut, honorsRetryAfterOnlyInsideDeadline, protocolProofOfNonProcessingWinsOverEverything, streamAfterGoAwayLastIdIsPeerNotProcessed 등.
Http2ProtocolEvidence는 프로토콜 수준 증거를 다룬다 — REFUSED_STREAM과 GOAWAY의 last-stream-id보다 큰 스트림 id는 피어가 처리하지 않았음의 증명이라 NOT_SENT로 승격되고, 그 이하 id의 리셋은 여전히 모호하다(streamAtOrBelowGoAwayLastIdStaysAmbiguous, aBareStreamResetProvesNothing).
22. P2 — 로컬 거부 경로에서 회로 브레이커 permission이 반환되지 않는다
AttemptResiliencePipeline.execute의 진입부다.
83: if (!circuitBreaker.tryAcquirePermission()) { … throw HttpCircuitOpenException }
88: if (!rateLimiter.tryAcquirePermission()) {
89: rejections.rateLimited();
90: throw new HttpRateLimitRejectedException(…); // ← 회로 permission 미반환
92: }
93: if (!bulkhead.tryAcquire()) {
94: rateLimiter.onCompleted(); // ← rate 토큰은 반환한다
95: rejections.bulkheadRejected();
96: throw new HttpBulkheadRejectedException(…); // ← 회로 permission 미반환
98: }
83행에서 회로 permission을 얻은 뒤, 88행과 93행의 로컬 거부 두 경로는 onSuccess·onError·releasePermission 중 어느 것도 부르지 않고 던진다. releasePermission은 이 leaf와 app-bootstrap 어디에도 등장하지 않고(170-... §8.1, exit=1), AttemptCircuitBreaker 인터페이스에도 그 연산이 없다(tryAcquirePermission·onSuccess·onError·state 넷뿐).
Resilience4j에서 이것이 중요한 상태는 HALF_OPEN이다. 그 상태의 tryAcquirePermission()은 permittedNumberOfCallsInHalfOpenState 중 하나를 소비하고, 그 시험 슬롯은 onSuccess/onError/releasePermission 중 하나로만 돌아온다. 아무것도 부르지 않으면 슬롯은 영구히 소비된다.
실패 시나리오. 업스트림 장애로 회로가 OPEN → 대기 후 HALF_OPEN 전이 → 트래픽이 돌아오면서 평상시 부하에 맞춰 사이징된 로컬 rate limiter나 bulkhead가 거부 → 그 거부마다 시험 슬롯 하나가 사라진다. 허용된 시험 호출 수만큼 그런 거부가 나면 브레이커는 성공도 실패도 관측하지 못한 채 HALF_OPEN에 머문다. Resilience4j의 maxWaitDurationInHalfOpenState 기본값은 0(무한 대기)이므로, 회복한 업스트림에 대해 회로가 닫히지 않는다. 그리고 이 조건들은 우연히 겹치는 것이 아니라 회복 순간에 자연히 함께 일어난다 — 회로가 반쯤 열리는 바로 그때 트래픽이 몰린다.
비대칭이 이 finding을 뒷받침한다. bulkhead 거부 경로는 rateLimiter.onCompleted()로 rate 토큰을 명시적으로 돌려준다(94행) — 저자가 permit 반환을 의식하고 있었다는 증거다. 세 가드 중 둘은 반환되고 첫 번째만 반환되지 않는다.
test도 그 공백을 그대로 보여 준다 — openCircuitDoesNotConsumeRateOrBulkheadPermit(회로가 거부할 때 뒤의 둘을 소비하지 않음)과 bulkheadRejectionReleasesTheRateLimiterAndIsNotACircuitError(bulkhead 거부가 rate를 돌려줌)는 있지만, "rate/bulkhead 거부가 회로 permission을 돌려준다"는 test는 없다.
판정: P2. 수정은 AttemptCircuitBreaker에 releasePermission()을 더하고(Resilience4j CircuitBreaker.releasePermission()에 위임, alwaysClosed()는 no-op) 두 로컬 거부 경로에서 호출하는 것이다.
23. Confirmed — PARTIAL_RESPONSE 재시도 분기는 도달 가능하다 (후보 → 결함 아님)
decide의 40~46행은 evidence == PARTIAL_RESPONSE일 때 안전 멱등이면 재시도를 허용한다. 그런데 28행이 이미 context.firstByteDelivered()에서 거부한다. DefaultExecutionEvidenceClassifier는 progress.responseBytesDelivered() > 0 || progress.firstByteDelivered()일 때 PARTIAL_RESPONSE를 내고, AttemptProgressTracker.recordDeliveredBytes가 두 필드를 함께 세팅한다. 여기까지만 보면 40행은 도달 불가로 보인다.
전수 추적한 결과 도달 가능하다. PARTIAL_RESPONSE를 만드는 곳이 evidence classifier 하나가 아니다 — 전송 실패 분류기 넷이 엔진 예외로부터 직접 그 값을 만든다(ApacheFailureClassifier:121,136 · ReactorFailureClassifier:94,114 · JdkFailureClassifier:83,103 · JettyHttp3FailureClassifier:41). 그 경로는 tracker의 firstByteDelivered를 보지 않으므로, "엔진은 응답 일부를 봤지만 호출자에게는 한 바이트도 전달되지 않은" 상태가 표현된다. 40행 주석이 말하는 구분("A partial response that never reached the caller may still be retried… once a byte was delivered the earlier guard has already denied it")이 실제로 성립한다.
FirstByteRetryBoundaryTest가 그 경계를 양쪽에서 고정한다. 결함 아님으로 판정.
24. Negative-space probes — sub-scope 03
- 8.1 permit 반환 대칭: 세 가드의 획득/반환 경로 전수 추적 — 회로만 반환 없음(§22).
releasePermission저장소 전체 매치 0. - 8.2 분기 도달성:
PARTIAL_RESPONSE생산 지점 전수 조사로 후보를 오탐 판정(§23). - 8.3 결정표 순서: 절대 차단 6 → 영구 실패 → 증거 → 상태/실패별의 단조성 확인(§20).
- 8.4 test 밀도: main 39 파일에 test 8 파일 / 47 메서드. 상태별·증거별·본문별 갈래가 이름으로 고정됨.
25. Sub-scope 03 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| P2 | rate limiter·bulkhead 로컬 거부 경로가 회로 브레이커 permission을 반환하지 않는다 — HALF_OPEN 시험 슬롯이 소진되어 회복한 업스트림에 대해 회로가 닫히지 않을 수 있다 | 회로 회복 중 로컬 백프레셔가 걸리는 배포 |
26. Sub-scope 03 완료 조건
- denominator 47 / 47 FULL_READ (
170-...OWNED FILES) - §8.1~§8.4 probe 수행, permit 반환 대칭 조사 1건
- 후보 finding 1건(
PARTIAL_RESPONSE분기 도달 불가 의심)을 생산 지점 전수 조사로 오탐 판정(§23) - 소스 미변경
27. Sub-scope 04 범위와 denominator
내부 상태: COMPLETE — 46 / 46 FULL_READ 범위:
restclient/**19 +webclient/**17 (main 36, 3,476 LOC) + 전용 test 10 역할: 블로킹/리액티브 두 게이트웨이 — 준비 · 시도 실행 · 응답 매핑 · 스트리밍 · SSE
manifest와 probe: evidence/raw/171-httpclient-gateway-probes.txt.
28. 두 예산, 두 계층, 그리고 읽는 도중의 강제
ResponseSizeLimiter가 와이어 바이트와 디코드 바이트를 따로 센다 — "a compressed payload passes a wire check and then expands, so a single limit either rejects legitimate traffic or lets a decompression bomb through." 그리고 CountingBoundedInputStream이 상한을 읽는 도중에 적용한다 — "a response that is discovered to be too large only once it is fully buffered has already cost the memory the limit exists to protect."
BoundedErrorBody는 오류 본문을 RFC 9457 문서를 해독할 만큼만 읽고, "The bytes never reach an exception message or a log." toString()은 길이와 truncated 여부만 낸다.
RemoteProblemDecoder의 규칙 한 줄이 이 계층의 성격을 요약한다 — "The wire status wins. A remote status member is read and discarded, because trusting it would let an upstream relabel a 503 as a 400 and change our retry behaviour from its own body." 확장 속성도 allowlist로 걸러 "an upstream cannot inject unbounded attributes into our telemetry." test 넷이 그 갈래를 고정한다(mapsProblemJsonWithoutTrustingBodyStatus·dropsExtensionsThatAreNotAllowlisted·treatsANonProblemContentTypeAsAnEmptyProblem·survivesAnUnparseableProblemDocument).
29. 리다이렉트는 엔진이 아니라 이 플랫폼이 따라간다
모든 전송에서 엔진 리다이렉트를 끄고 BlockingRedirectCoordinator만이 hop을 만든다. 각 hop마다 대상 정책을 다시 적용하고 origin이 바뀌면 자격증명을 떨어뜨린다 — "which is exactly what an engine's built-in follower does not do."
수정 이력 둘이 붙어 있다.
- "The redirect policy decides whether a hop is permissible in shape; the profile decides whether its destination is permissible at all. Only the first check existed, so an upstream could redirect a trusted profile to an origin its allowlist excluded." 지금은
targetGuard.requireAllowed(follow.target(), metadata)가 매 hop 실행된다. - 303은 메서드를 GET으로 바꾸면서 본문도 버린다 — "Changing only the method sent the original payload as a GET body to a destination the upstream chose." 301/302는 메서드를 유지한다("the platform refuses to guess a rewrite the caller did not ask for").
hop 상한은 RedirectEvaluator:16이 context.hop() >= context.policy().maxHops()로 강제하고 test stopsAtTheConfiguredHopLimit이 잡는다 — 조정자의 for 루프에 종료 조건이 없어 보이는 것은 평가기가 거부로 끝내기 때문이다(후보 추적 → 결함 아님).
그리고 hop은 재시도가 아니다 — "A hop is a physical request: it passes through the resilience pipeline via the supplied sender, so it consumes rate and bulkhead capacity. It is not a retry, because nothing failed."
30. P3 — BoundedDataBufferFlux의 두 연산자가 이름만 있고 아무것도 하지 않는다
클래스 javadoc이 목적을 적는다 — "Bounds a reactive body and releases every buffer it does not hand on… Cancellation and error are the paths that leak in practice: the subscriber stops asking, the upstream drops what it already produced, and those buffers are direct memory nobody returns."
그런데 그 두 경로에 붙은 연산자가 둘 다 비어 있다.
return source
.doOnNext(buffer -> { limiter.recordWireBytes(…); guard.markDelivered(); })
.doOnDiscard(DataBuffer.class, DataBufferUtils::release) // ← 실제로 일하는 유일한 연산자
.doOnCancel(() -> {}) // ← no-op
.onErrorResume(failure -> Flux.error(failure)); // ← 같은 오류를 그대로 재방출, no-op
doOnCancel(() -> {})은 정의상 아무 일도 하지 않고, onErrorResume(f -> Flux.error(f))는 오류 신호에 대해 항등이다. 따라서 이 클래스의 버퍼 해제는 전적으로 doOnDiscard와 드라이버 자신의 해제 동작에 의존한다.
누수가 실재한다고 주장하지는 않는다 — Reactor Netty의 ByteBufFlux는 취소 시 미방출 버퍼를 스스로 해제하고, doOnDiscard는 discard 프로토콜을 지원하는 연산자에 대해 동작한다. 문제는 코드가 하지 않는 일을 하는 것처럼 읽힌다는 것이다: 두 누수 경로의 이름을 딴 연산자가 나란히 있고 둘 다 비어 있어서, 이 클래스를 읽는 사람은 취소·오류 해제가 여기서 명시적으로 처리된다고 결론짓게 된다. 취소 경로에 test가 없지는 않다 — ReactiveStreamingLifecycleTest.cancellationReleasesTheConnectionForTheNextCall이 있다. 다만 그것이 확인하는 것은 연결 반환이고 버퍼 해제가 아니며, 실질적 안전망은 모든 lane에 켜져 있는 Netty leak detector(paranoid)다(§0).
판정: P3. 수정은 두 연산자를 지우고 javadoc이 doOnDiscard와 드라이버의 역할을 정확히 적게 하거나, 취소 경로에서 실제로 해제해야 할 것이 있다면 그것을 구현하는 것이다.
31. Negative-space probes — sub-scope 04
- 8.1 죽은 연산자:
BoundedDataBufferFlux의 4개 연산자 중 2개가 no-op(§30). 소비자는ReactiveStreamingGateway:102하나. - 8.2 편의 생성자의 가짜 신원:
ResponseSizeLimiter의 2인자 생성자는 프로파일 이름이 리터럴"response-size-limiter"인 정적 메타데이터를 쓴다. production 호출 지점 셋(BlockingAttemptExecutor:52·BlockingStreamingGateway:60·ReactiveStreamingGateway:69)은 전부 3인자 생성자로 실제 메타데이터를 넘긴다 — 2인자 형태는 test 전용이다. 결함 아님으로 판정하되,public이므로 fork가 쓰면 예외에 가짜 프로파일 이름이 실린다. - 8.3 hop 상한: 조정자 루프의 종료 조건 부재를 후보로 추적해
RedirectEvaluator의 거부로 확정 — 오탐(§29). - 8.4 상태 신뢰 경계: 원격 problem 문서의
status가 폐기되고 와이어 상태가 이긴다는 규칙을 코드와 test로 확인(§28).
32. Sub-scope 04 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| P3 | BoundedDataBufferFlux의 doOnCancel(() -> {})과 onErrorResume(f -> Flux.error(f))가 no-op인데, 클래스 javadoc은 그 두 경로를 이 클래스가 처리한다고 적는다 |
코드 독해 · 회귀 |
33. Sub-scope 04 완료 조건
- denominator 46 / 46 FULL_READ (
171-...OWNED FILES) - §8.1~§8.4 probe 수행
- 후보 finding 2건(리다이렉트 hop 무한 루프 의심,
ResponseSizeLimiter가짜 메타데이터)을 각각 평가기 거부와 호출 지점 전수로 추적해 결함 아님으로 판정(§29, §31) - 소스 미변경
34. Sub-scope 05 범위와 denominator
내부 상태: COMPLETE — 46 / 46 FULL_READ 범위:
security/**21 +auth/**18 (main 39, 2,072 LOC) + 전용 test 7 역할: 목적지·헤더·본문 정책, TLS 재료와 회전, 자격증명 해석
manifest와 probe: evidence/raw/172-httpclient-security-auth-probes.txt.
35. 목적지 정책 — 절대 URI를 정화하지 않고 거부한다
TrustedTargetPolicy의 규칙 — "An absolute URI is rejected here rather than sanitised: H2 exists to vary method, relative path, query, approved headers, and body — not the destination. Changing the destination is what H3 is for, and H3 has its own policy, credentials, and DNS validation."
requireRelativeTemplate가 빈 템플릿, // 시작, :// 포함, /로 시작하지 않음을 거부한다. 확장은 문자열 연결이 아니라 Spring DefaultUriBuilderFactory의 TEMPLATE_AND_VALUES 인코딩이라 "a value containing /, ?, or # cannot change the shape of the request." 그리고 확장 후에 requireAllowedOrigin이 host/port allowlist를 다시 본다.
멱등성 키 처리에 수정 이력 둘이 붙어 있다.
"Before, the key was carried on the operation, checked for presence by the retry engine, and never written to the wire: the upstream saw no key, could not deduplicate, and the platform meanwhile treated a repeat as contractually safe. A duplicated payment is the shape of that bug." "A caller-supplied value for the same header is refused rather than merged. Two keys for one request is a contradiction."
그리고 리다이렉트 hop에 allowlist를 다시 적용하는 requireAllowedTarget이 public인 이유도 적혀 있다 — 조정자가 이전에는 리다이렉트 정책만 보고 프로파일 allowlist를 보지 않아 "An upstream could therefore redirect a trusted profile to any origin the redirect policy tolerated, including one the operator had explicitly excluded."
36. 헤더 소유권과 자격증명 제거
HeaderPolicy의 PLATFORM_OWNED 9종(authorization·proxy-authorization·host·content-length·transfer-encoding·traceparent·tracestate·baggage·cookie)은 호출자가 덮을 수 없고, CR/LF는 무조건 거부된다 — "a header value that can contain a newline is a request-splitting primitive."
SensitiveHeaderStripper에 이 sub-scope에서 가장 미묘한 수정이 있다.
"It adds rather than replaces, which its name always claimed and its behaviour did not. A profile that named a custom API-key header —
X-Client-Key, say — produced a stripper that dropped only that one and forwardedX-Api-Keyacross an origin boundary, so configuring a custom header made the default headers less protected than leaving it alone."
37. 자격증명은 값이 아니라 신원만 남긴다
SingleFlightTokenLoader가 토큰 갱신을 한 번으로 접고, 그 실행 위치에 대한 두 가지 과거 오류를 적는다 — 공용 ForkJoinPool에서 돌아 "That pool is sized for CPU-bound work"였고, 대기가 **무한 join()**이라 "A token endpoint that accepted the connection and never answered" 상황에서 전체가 멈췄다.
UnauthorizedRetryPolicy의 재시도 상한이 1인 이유 — "An expired token produces a 401 that a refresh fixes; a wrong scope produces a 401 that no number of refreshes fixes." 그리고 재생 가능한 본문과 (읽기 전용이거나 명시적 키를 가진) 연산만 허용한다. test 셋이 그 갈래를 고정한다(denies401ReplayForOneShotPost·allowsExactlyOneReplayForASafeReplayableOperation·allowsAKeyedWriteOnlyWhenAuthFailedBeforeAnySideEffect).
AccessToken과 RequestCredentials는 toString()을 REDACTED로 재정의하고, CredentialRequest도 마찬가지인데 그 이유가 구체적이다 — "The generated toString printed the authenticated principal and the full target URI, including any query string. A credential-resolution failure is exactly when this record ends up in a log line, which made the failure path the most likely place for a user identity and a signed URL to escape."
OAuth2TokenCacheKey가 6개 성분을 모두 키에 넣는 이유도 적혀 있다 — "sharing a token across principals, scope sets, audiences, tenants, or client certificates is a privilege-escalation bug, not a cache optimisation."
TlsRuntimeRotationCoordinator는 회전을 세대 교체로 처리하고("a connection pool holds sockets that were negotiated with the old material"), 어느 프로파일이 참여하는지를 회전된 신원 이름으로 정한다 — 이전에는 "The identity argument used to be required and then ignored: every registered profile was" 회전 대상이었다.
38. Negative-space probes — sub-scope 05
- 8.1 자격증명 유출 표면:
auth의 18 타입 중 record 5종을 전수 확인 — 값을 담는 셋(AccessToken·RequestCredentials·CredentialRequest)은 모두 redactedtoString을 갖고,OAuth2TokenCacheKey·UnauthorizedRetryContext는 비밀을 담지 않는다. 나머지 13종은final class/interface/enum이라 생성된 toString이 없다. - 8.2 가짜 메타데이터 편의 생성자:
HeaderPolicy.validate(input)1인자 오버로드가 정적UNBOUND(프로파일 이름 리터럴"header-policy")를 쓰지만, production 호출 지점 둘(TrustedTargetPolicy:80·DefaultDynamicTargetGateway:156)은 전부 2인자 형태로 실제 메타데이터를 넘긴다 — sub-scope 04의ResponseSizeLimiter와 같은 형태이며 같은 결론(결함 아님). - 8.3 SSRF 방어 지점 전수:
requireAllowedTarget/requireAllowedOrigin의 호출 지점 셋(준비 시 1, 리다이렉트 hop 1, 내부 1)과 동적 대상의IpAddressClassifier/ValidatedDnsResolver(sub-scope 06 범위)를 확인. - 8.4 소유 헤더 목록:
PLATFORM_OWNED9종과ALWAYS_STRIPPED4종 + 관례적 API 키 2종의 관계 확인.
39. Sub-scope 05 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| — | 없음. 목적지가 정화가 아니라 거부로 다뤄지고, 헤더 소유권과 CR/LF 거부가 타입 수준에 있으며, 자격증명을 담는 모든 값 타입이 redacted toString을 갖는다 |
— |
40. Sub-scope 05 완료 조건
- denominator 46 / 46 FULL_READ (
172-...OWNED FILES) - §8.1~§8.4 probe 수행, 자격증명 유출 표면 전수 조사
- 후보 finding 1건(
HeaderPolicy의 가짜 메타데이터)을 호출 지점 전수로 추적해 결함 아님으로 판정(§38) - 소스 미변경
41. Sub-scope 06 범위와 denominator
내부 상태: COMPLETE — 56 / 56 FULL_READ 범위:
service14 +dynamic11 +observation6 +migration4 (main 35, 2,698 LOC) + test 21 (전용 11 +contract7 +architecture3) 역할: 선언적 서비스 클라이언트, 동적 대상(SSRF 방어), 관측 태그, RestTemplate 이관 진단
manifest와 probe: evidence/raw/173-httpclient-service-dynamic-probes.txt.
42. 동적 대상 — SSRF 방어가 소켓까지 이어진다
이 sub-scope의 중심은 CallScopedDnsPin의 javadoc이 적는 과거 결함이다.
"This is the piece the SSRF defence was missing.
ValidatedDnsResolverresolved a host, rejected the target if any answer was forbidden, and produced aPinnedTargetholding the exact approved addresses — and then the gateway handed the transport a URL containing the hostname, and the transport resolved it again. Everything between the two resolutions was unvalidated: a DNS server under an attacker's control answers the first query with a public address and the second with169.254.169.254, and the platform connects to the metadata service having 'validated' the target. The classic rebinding attack, defeated by a check that discarded its own result."
지금은 사슬이 닫혀 있다. TargetCanonicalizer가 순서대로 파싱·userinfo 거부·Punycode 정규화·allowlist 비교를 하고("Order is the security property"), ValidatedDnsResolver가 모든 응답을 검사하며("Validating only the first answer is a common and fatal shortcut"), DefaultDynamicTargetGateway:105-121이 hop마다 CallScopedDnsPin을 열고 finally에서 resolver.forget(...)을 부른다. 그리고 전송 쪽은 app-bootstrap HttpClientTransportAutoConfiguration:81이 CallScopedDnsPin::addressesFor를 ValidatedAddressResolverGroup에 주입해 소켓이 핀에 없는 주소로 나가지 못한다 — 핀이 비면 "The transport must then refuse rather than fall back to a system lookup."
IpAddressClassifier도 두 가지를 명시한다 — IPv4-mapped IPv6를 되돌려 정규화하고, 구조가 "an allowlist of globally routable unicast space, then the operator's own" 형태다. 그리고 "Every rejection here used to be an acceptance. Integer.parseInt took -1…"라는 수정 이력이 있다.
관측도 닫혀 있다 — HttpClientTagPolicy는 모르는 태그 이름을 거부한다("a metric backend cannot undo a"高카디널리티 태그), SensitiveValueRedactor는 쿼리 값을 키별로 마스킹하지 않고 통째로 버린다("an allowlist of 'safe' query" 키를 유지하는 방식은 안전하지 않다는 판단).
계약 lane의 메타 test가 특히 좋다 — everySelectedBlockingTransportRanEveryContract, everySelectedReactiveTransportRanEveryContract, everySelectedTransportIsClaimedByExactlyOneContainer. lane이 "무언가 돌았다"가 아니라 **"선택된 전송 각각이 모든 계약을 돌았다"**를 확인한다. HttpClientModuleBoundaryTest는 설계의 19-모듈 의존 표를 패키지 경계로 강제하고, PublicApiArchitectureTest.coreApiDependsOnNothingInsideThePlatform이 공개 API의 독립성을 잡는다.
43. Confirmed — ValidatedDnsResolver의 approved 맵은 hop마다 비워진다 (후보 → 결함 아님)
javadoc이 "They are deliberately not retained here between calls"라고 적는데 approved ConcurrentHashMap 필드는 여전히 존재하고 resolve()마다 채워진다. 무경계 증가로 보였으나 전수 추적 결과 DefaultDynamicTargetGateway가 finally에서 resolver.forget(host)를 부르므로(:118-122) 항목은 hop 종료와 함께 사라진다. 그리고 보안 경로는 이 맵이 아니라 thread-local 핀을 쓴다 — approvedAddresses(String)의 production 호출자는 0이고 test·testkit 관측용이다. 결함 아님.
(맵이 host만으로 키를 잡으므로 같은 host에 대한 동시 호출은 항목을 공유하고 먼저 끝난 쪽이 forget한다. 보안 결정은 thread-local이 내리므로 결과에 영향이 없다.)
44. Sub-scope 06 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| — | 없음. 정규화→검증→핀→소켓 사슬이 닫혀 있고, 관측 태그가 allowlist로 폐쇄되며, 계약 lane이 전송별 완전 실행을 메타 test로 확인한다 | — |
45. Sub-scope 07 범위와 denominator
내부 상태: COMPLETE — 85 / 85 FULL_READ 범위:
transport10 +reactor6 +apache5 +jdk4 +http34 +spring73 (main 32, 2,189 LOC) + 전용 test 9 +testkitsource set 35 (2,754 LOC) +httpClientPerformanceTest7 (495) +jmh2 (130) 역할: 전송 제공자 6종과 그 능력 선언, 그리고 세 개의 보조 source set
manifest와 probe: evidence/raw/174-httpclient-transport-testkit-probes.txt.
46. 전송은 능력을 선언하고, 프로파일보다 약하면 startup이 실패한다
TransportCapabilityValidator가 프로파일이 요구하는 것과 전송이 선언한 것을 대조해 부족분을 이름으로 모아 거부한다 — 프로토콜, route pool, 유계 pending 큐, proxy, mutual TLS, 동적 대상 안정성. 메시지는 "profile settings and capability names only — never a URL, address, or secret."
능력 레코드가 그 선언을 데이터로 만든다. ReactiveTransportCapabilities.reactorNetty()는 9개 능력을 전부 true로, jettyHttp3Experimental()은 route pool·유계 큐·DNS 핀·동적 안정성을 false로 선언한다. HTTP/3는 compileOnly 의존이라 클래스가 없으면 Http3CapabilityReport가 전송을 거부한다 — "the failure mode is a startup error rather than a NoClassDefFoundError mid-call"(§0).
testkit이 별도 source set인 것도 이 sub-scope의 성격이다 — 계약을 담은 클래스 35개(BlockingTransportContract·ReactiveTransportContract·RetrySafetyContract·ResourceLifecycleContract·ObservabilityContract·DynamicTargetSecurityContract)를 test·performance·jmh 세 lane이 공유한다. NettyLeakDetectionExtension은 leak detector 레벨을 믿지 않고 확인한다 — "asserts the level rather than trusting the flag reached the forked JVM"(§0).
성능 lane 7개는 자원 상한을 검증한다 — PoolSaturationPerformanceTest·RetryStormBudgetTest·RuntimeRotationDrainTest·OAuthRefreshContentionTest·LargeBodyResourceTest·Http2StreamSaturationTest. §15에서 남긴 질문(ObjectBody.replayability()의 반사 비용을 재는 lane이 있는가)의 답은 없다 — 성능 lane은 풀·재시도·회전·토큰 경합·본문 크기·H2 스트림을 재고 본문 재생 가능성 판정 비용은 재지 않는다.
47. P3 — 동적 대상 DNS 핀 능력 검사가 블로킹 오버로드에만 있다
TransportCapabilityValidator에는 오버로드가 둘이다. 블로킹 쪽에는 이런 검사와 주석이 있다.
if (profile.mode() == ClientMode.DYNAMIC && !capabilities.validatedDnsPinning()) {
// `validatedDnsPinning` was declared on every capability record and read by nothing. It is
// the capability that decides whether the SSRF address validation survives to the socket, so
// a transport that does not have it cannot serve a dynamic target no matter what its
// `dynamicTargetStable` flag says — the two were being conflated.
missing.add("call-scoped validated DNS pinning");
}
리액티브 오버로드에는 이 검사가 없다. dynamicTargetStable만 본다 — 즉 주석이 "conflated"라고 지적한 바로 그 상태가 리액티브 경로에 그대로 남아 있다.
지금 노출은 없다. 두 리액티브 전송의 두 플래그가 같은 값이기 때문이다 — reactorNetty()는 둘 다 true, jettyHttp3Experimental()은 둘 다 false. 게다가 ClientProfileValidator가 DYNAMIC + JETTY 조합을 이미 거부한다(DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED).
판정: P3. 위험은 fork가 리액티브 전송을 추가하면서 dynamicTargetStable=true, validatedDnsPinning=false로 선언하는 경우 — 주석이 "cannot serve a dynamic target no matter what its dynamicTargetStable flag says"라고 못박은 정확히 그 조합이 리액티브 쪽에서는 통과한다. 수정은 같은 세 줄을 리액티브 오버로드에 복사하는 것이다.
48. Negative-space probes — sub-scope 06·07
- 8.1 SSRF 사슬의 끝점: 정규화 → 전체 응답 검증 → thread-local 핀 → 전송 resolver 주입까지 호출 지점으로 전수 추적(§42).
- 8.1b 잔존 캐시:
ValidatedDnsResolver.approved의 수명과 production 호출자 0을 확인해 오탐 판정(§43). - 8.2 조건부 형제:
TransportCapabilityValidator의 두 오버로드 비교 — 한쪽에만 적용된 수정(§47). - 8.3 능력 선언 대 실제: 두 리액티브 전송의 9개 능력 플래그 전수 확인(§46, §47).
- 8.4 lane 완전성: 계약 lane의 메타 test 3종과 성능 lane 7종의 대상 범위 확인(§42, §46).
49. Sub-scope 06·07 findings backlog
| 우선순위 | finding | reachability |
|---|---|---|
| P3 | 동적 대상의 validatedDnsPinning 능력 검사가 TransportCapabilityValidator의 블로킹 오버로드에만 있고 리액티브 오버로드에는 없다 — 주석이 "conflated"라고 지적한 상태가 한쪽에 남아 있다 |
dynamicTargetStable=true, validatedDnsPinning=false인 리액티브 전송을 추가하는 fork |
50. Sub-scope 06·07 완료 조건
- denominator 56 / 56 및 85 / 85 FULL_READ (
173-...,174-...OWNED FILES) - §8.1~§8.4 probe 수행, 조건부 형제 비교 1건
- 후보 finding 1건(
approved맵 무경계 증가 의심)을finally배치와 호출자 전수로 추적해 결함 아님으로 판정(§43) - §15에서 남긴 성능 lane 질문을 해소(§46)
- 소스 미변경
51. 교정 — 영구 TLS 실패의 CONNECT 분류는 분류기 결함이 아니라 픽스처의 듀얼스택 호스트명이다
이 절은 이전 사이클이 여기에 적었던 P1 진단을 철회하고 교체한다. 관측된 실패는 그대로 재현되지만,
그 원인으로 지목했던 기전은 측정으로 반증되었다. 근거는 EVD-332다.
51.1 관측은 그대로다
:adapter:outbound:httpclient:test 는 HEAD 에서도 283 중 3건 실패한다.
anUntrustedAuthorityIsAPermanentTlsFailure() → expected: TLS_HANDSHAKE but was: CONNECT
anExpiredCertificateIsAPermanentTlsFailure() → expected: TLS_HANDSHAKE but was: CONNECT
aHostnameMismatchIsAPermanentTlsFailure() → expected: TLS_HANDSHAKE but was: CONNECT
세 건 모두 MutualTlsHandshakeContractTest.java:168 — assertThat(classified.stage()).isEqualTo(TLS_HANDSHAKE) 다.
바로 앞줄인 167행(evidence == NOT_SENT)은 통과한다.
51.2 철회하는 진단
이전 사이클의 주장은 이랬다.
Apache HttpClient 5는 TLS 핸드셰이크 실패를 연결 단계 실패로 감싼다 — 사슬이
HttpHostConnectException→SSLHandshakeException이다. 바깥 것이 CONNECT 분기에 먼저 걸리므로 안쪽SSLHandshakeException은 검사되지 않는다.
틀렸다. 그 진단은 ApacheFailureClassifier.recognize의 분기 순서(pool → DNS → CONNECT → TLS)를 읽고
사슬의 모양을 추론한 것이지, 사슬을 실제로 떠본 것이 아니다. 잡힌 예외를 그대로 출력하면 이렇다.
[0] ResourceAccessException :: ... Connect to https://localhost:56507 failed: Connection refused
[1] HttpHostConnectException :: Connect to https://localhost:56507 failed: Connection refused
사슬에 SSLHandshakeException이 없다. 그림자에 가려진 것이 아니라 애초에 도착하지 않았다.
분류기는 자기가 받은 것을 정확히 분류했다.
51.3 확정된 기전 — 접속 호스트만 바꾼 대조
동일한 서버 객체, 동일한 클라이언트 신뢰재료. baseUrl의 호스트 문자열만 바꿨다.
=== untrusted-authority @127.0.0.1 ===
[1] javax.net.ssl.SSLHandshakeException :: (bad_certificate) PKIX path validation failed
[4] java.security.SignatureException :: Signature does not match.
-> stage=TLS_HANDSHAKE category=TLS_PERMANENT permanent=true ← test가 기대하는 값
=== untrusted-authority @localhost ===
[1] HttpHostConnectException :: Connect to https://localhost:40485 failed: Connection refused
-> stage=CONNECT category=CONNECT permanent=false ← test가 본 값
이 컨테이너의 /etc/hosts는 localhost를 두 패밀리에 준다.
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
InetAddress.getAllByName("localhost") -> [127.0.0.1, 0:0:0:0:0:0:0:1]
MockWebServer는 IPv4 루프백에만 바인딩하고, MockHttpServer.uri()는 호스트명 localhost를 돌려준다
(MockHttpServer.java:70-72). Apache HttpClient 5의 연결 오퍼레이터는 해석된 주소를 순회하면서
마지막이 아닌 주소의 실패를 삼킨다.
127.0.0.1 → TCP 성공 → TLS 핸드셰이크 실패(진짜 실패) → 삼켜짐
::1 → TCP 거부(듣는 소켓 없음) → 마지막 주소 → HttpHostConnectException 으로 승격
호출자에게 도달하는 유일한 예외는 두 번째 주소의 연결 거부다.
핸드셰이크가 성공하는 test가 통과하는 이유도 같은 루프다. 127.0.0.1 에서 성공하면 루프가 즉시
반환하므로 ::1을 시도하지 않는다. 따라서 처음 눈에 띄었던 startTls(..., true/false) 차이는
원인이 아니라 상관관계였다 — 실패하는 케이스가 곧 두 번째 주소까지 가는 케이스다.
startTls(..., false)에서 서버가 뜨지 않는다는 가설도 함께 기각했다. 두 경우 모두 원시 소켓 접속이
성공한다(raw 127.0.0.1: OK, raw localhost: OK).
51.4 두 개의 판정
(a) test 실패 자체 — P3, 픽스처 결함. 프로덕션 코드에 결함이 없다. localhost가 IPv4로만 풀리는
환경에서는 세 건 모두 통과한다. 고칠 것은 MockHttpServer.uri()가 호스트명을 돌려준다는 점이다 —
루프백 IPv4 주소를 돌려주거나 test가 주소를 고정하면 사라진다. 이 저장소는 preferIPv4Stack을
어디에도 설정하지 않으므로, 듀얼스택 CI/컨테이너에서 이 세 건은 항상 빨갛다.
(b) 이 실패가 드러낸 런타임 성질 — P2/기록, 이 모듈에서 고칠 수 없다. 분류표는 두 범주를 정반대로 다룬다.
| 분류 | DefaultRetryEligibilityEngine |
|---|---|
TLS_PERMANENT |
permanent() → RetryDenied.permanentFailure (37-39행) |
CONNECT |
failureDecision의 case CONNECT -> RetryAllowed.of("CONNECT") — 멱등성과 무관하게 재시도 |
다중 주소 호스트에서 한 패밀리는 TLS를 영구 거절하고 다른 패밀리는 연결을 거부하면, 절대 검증되지 않을
인증서에 대한 호출이 CONNECT로 분류되어 예산·데드라인이 소진될 때까지 매 시도 재시도된다.
분류기가 볼 수 있는 정보 안에서 이 강등을 막을 방법은 없다 — Apache의 루프가 앞선 주소의 실패를 이미
버렸기 때문이다. 이 모듈의 설계 전제는 "증거에 기반해 재시도 안전성을 판정한다"인데, 증거를 만드는
계층이 증거의 일부를 버리는 지점이 여기다. 다만 인증서가 신뢰 불가면 보통 두 패밀리 모두 TLS에서
실패하고, 그때는 마지막 주소의 예외도 SSLHandshakeException이라 올바르게 TLS_PERMANENT가 된다.
강등은 패밀리별 실패 양상이 다를 때만 일어난다.
51.5 이전 사이클이 남긴 열린 항목의 처리
이전 §51은 "ReactorFailureClassifier·JdkFailureClassifier도 같은 사슬 순회 형태를 쓰므로 같은
그림자 문제가 있는지 확인이 필요하다"로 끝났다. 그 열린 항목은 전제가 반증되어 소멸한다 —
Apache는 TLS 실패를 연결 예외로 감싸지 않는다(§51.3의 @127.0.0.1 측정이 직접 보여 준다).
사슬 순회 순서가 TLS를 가리는 일은 이 경로에서 일어나지 않는다. 다른 두 분류기를 같은 방식으로
개별 실행해 보지는 않았다. 확인하지 못한 것으로 남긴다.
52. 모듈 ledger 정합
| # | 범위 | main | test | 기타 | 합 | FULL_READ | probe |
|---|---|---|---|---|---|---|---|
| 1 | governance + profile/** |
29 | 2 | 4 | 35 | 35 | 167, 168 |
| 2 | api/** |
50 | 5 | – | 55 | 55 | 169 |
| 3 | resilience/** |
39 | 8 | – | 47 | 47 | 170 |
| 4 | restclient/** + webclient/** |
36 | 10 | – | 46 | 46 | 171 |
| 5 | security/** + auth/** |
39 | 7 | – | 46 | 46 | 172 |
| 6 | service+dynamic+observation+migration |
35 | 21 | – | 56 | 56 | 173 |
| 7 | 전송 6종 + testkit/perf/jmh | 32 | 9 | 44 | 85 | 85 | 174 |
| TOTAL | 260 | 62 | 48 | 370 | 370 | 7 / 7 |
coverage ledger: FULL_READ 370 / STRUCTURAL_ONLY 0 / EXCLUDED 0 / 미분류 0.
53. 모듈 findings
| # | 우선순위 | finding | 위치 |
|---|---|---|---|
| 1 | P3 | MutualTlsHandshakeContractTest 3건이 실패하지만 원인은 프로덕션 코드가 아니라 픽스처다 — MockHttpServer.uri()가 듀얼스택 localhost를 돌려주고 MockWebServer는 IPv4에만 바인딩한다 (이전 사이클의 P1 진단은 EVD-332로 철회) |
§51 |
| 1b | P2/기록 | 다중 주소 호스트에서 패밀리별 실패 양상이 다르면 영구 TLS 실패가 재시도 가능한 CONNECT로 강등된다 — Apache가 앞선 주소의 실패를 버리므로 이 모듈에서 고칠 수 없다 |
§51.4 |
| 2 | P2 | rate limiter·bulkhead 로컬 거부 경로가 회로 브레이커 permission을 반환하지 않는다 — HALF_OPEN 시험 슬롯 소진으로 회복한 업스트림에 회로가 닫히지 않을 수 있다 | §22 |
| 3 | P3 | ClientRuntimeRegistry.close()가 실패 시 drain 스케줄러 종료에 도달하지 않아 스레드가 남는다 |
§4 |
| 4 | P3 | POOL_ROUTE_EXCEEDS_TOTAL 위반 코드가 PoolSettings 생성자에 가려 도달 불가 |
§5 |
| 5 | P3 | 위반 코드 34종 중 22종이 어떤 test에서도 이름으로 확인되지 않고, 그 22종에 사고 유래 보안 가드가 대부분 포함된다 | §6 |
| 6 | P3 | ObjectBody.deeplyImmutable의 instanceof Number가 AtomicInteger·LongAdder 등 가변 타입을 REPLAYABLE로 인증한다 |
§14 |
| 7 | P3 | BoundedDataBufferFlux의 doOnCancel·onErrorResume가 no-op인데 javadoc은 그 두 경로를 이 클래스가 처리한다고 적는다 |
§30 |
| 8 | P3 | 동적 대상 validatedDnsPinning 능력 검사가 TransportCapabilityValidator의 블로킹 오버로드에만 있다 |
§47 |
| 9 | P3/기록 | ObjectBody.replayability()가 호출마다 반사로 재계산되고 캐시가 없으며, 성능 lane도 이 경로를 재지 않는다 |
§15, §46 |
결함 아님으로 판정한 후보 6건 — PARTIAL_RESPONSE 분기 도달 불가 의심(§23), 리다이렉트 hop 무한 루프 의심(§29), ResponseSizeLimiter·HeaderPolicy의 가짜 메타데이터 생성자(§31, §38), ValidatedDnsResolver.approved 맵 무경계 증가 의심(§43).
54. 이 모듈에서 반복해서 나타난 패턴
- 거부가 무시보다 낫다. 이 저장소의 다른 열 모듈에서 반복 발견한 "선언되었으나 아무것도 하지 않는 설정"을, 이 모듈은
validateUnsupportedSettings로 명시적 거부한다 — "the honest position is to refuse a value the platform cannot honour instead of accepting it and doing nothing." - 증거가 메서드 이름을 이긴다.
RetryContext에 HTTP 메서드가 없고, 멱등성 키는 전송됐는지까지 요구하며, 첫 바이트 전달은 래치다. - 저장소에서 가장 밀도 높은 사고 기록. 34개 위반 코드,
AttemptResiliencePipeline의 브레이커 가시성 수정,SensitiveHeaderStripper의 "adds rather than replaces",CallScopedDnsPin의 "a check that discarded its own result",TrustedTargetPolicy의 "A duplicated payment is the shape of that bug" — 각 가드가 자신이 막는 사고를 인용한다. - 그러나 그 규율이 test로 고정된 비율은 낮다. 위반 코드 34 중 12만 test가 이름으로 잡고(§6), 사고 유래 가드 대부분이 그 밖에 있다. 그리고 §51은 그 반대편의 함정이다 — test 3건이 빨간 채로 남아 있고, 이전 사이클은 그 빨강을 프로덕션 P1으로 읽었다. 실제로는 픽스처의 호스트명 문제였다(
EVD-332). 실패하는 test는 결함의 증거가 아니라 조사의 시작점이다.
55. 검증
evidence/raw/175-httpclient-suite-verification.txt.
lane classes tests failures skipped
test 61 283 3 0
httpClientStableContractTest 7 24 0 0
httpClientSecurityTest 2 9 0 0
httpClientBlockHoundTest 1 3 0 0
spring62ApiSurfaceScan 1 6 0 0
git status --short → 0
두 가지를 그대로 기록한다.
:check는 소스와 무관한 이유로 실패한다.src/gradle/archive-hygiene.gradle의 게이트가build/libs에 남은 이전 리비전 JAR 두 개(+0137263441f6,+e98b56eb03ec)를 발견하고 빌드를 깬다. 저장소가cleanStaleTraceableJars라는 remedy 태스크를 제공한다. 이 분석은 사용자 워크스페이스의 빌드 산출물을 삭제하지 않았고, 대신 다섯 lane을 개별 실행해 실제 결과를 얻었다.:test의 3건 실패는 실재하지만 프로덕션 결함이 아니다. 2026-08-31 HEAD 재실행에서도 283 중 3건이 동일하게 실패한다. 예외 사슬을 직접 뜬 결과 원인은 픽스처가 듀얼스택localhost를 쓰는 것이었고, 접속 호스트를127.0.0.1로 바꾸면 세 건 모두TLS_PERMANENT가 된다(EVD-332). §51이 교정된 진단이다.- 재검증 시점의 소스 드리프트는 0이다. 문서 기준 revision
a24ece9c와 HEAD21234e38사이에서src/adapter/outbound/httpclient변경 파일 수는 0이며, 함께 바뀐src/build.gradle·modules.json도 이 리프에 영향이 없다(EVD-333).
작업 트리는 변경 0 — 이 분석은 어떤 애플리케이션 코드도 수정하지 않았다.
56. 모듈 완료 조건
- denominator 370 / 370 FULL_READ,
STRUCTURAL_ONLY0,EXCLUDED0, 미분류 0 (§52) - 7개 하위 범위 전부 §8.1~§8.4 negative-space probe 수행, evidence
167~1759건 생성 - 후보 finding 6건을 코드로 추적해 결함 아님으로 판정
- lane 5종 실행, 실패 3건을 예외 사슬 실측까지 추적해 픽스처 원인으로 확정하고 이전 P1 진단을 철회(§51,
EVD-332) - 재검증 revision
21234e38에서 리프 소스 변경 0 확인(EVD-333) - §0 SSOT identity 블록·고정 골격 대응표·Source anchors 추가
- 소스 미변경
Source anchors
src/adapter/outbound/httpclient/build.gradle
src/config/architecture/modules.json (adapter-outbound-httpclient 항목)
src/build.gradle:477-492 (plain-JUnit 조건 — 이 리프는 else 분기)
main/…/apache/ApacheFailureClassifier.java:31-43,45-96,98-106
main/…/api/operation/FailureCategory.java:9-32,34-44
main/…/api/operation/AttemptStage.java:9-42
main/…/resilience/DefaultRetryEligibilityEngine.java:25-51,53-59
main/…/profile/ClientProfileValidator.java
main/…/profile/ClientRuntimeRegistry.java
main/…/api/body/ObjectBody.java
main/…/resilience/AttemptResiliencePipeline.java
main/…/security/SensitiveHeaderStripper.java
main/…/security/TrustedTargetPolicy.java
main/…/dynamic/ValidatedDnsResolver.java
main/…/transport/TransportCapabilityValidator.java
main/…/reactor/BoundedDataBufferFlux.java
test/…/security/MutualTlsHandshakeContractTest.java:38-67,104-126,128-171
testkit/…/testkit/MockHttpServer.java:32-49,60-72
testkit/…/testkit/TlsFixture.java
testkit/…/testkit/TlsMaterials.java
evidence/raw/167-… ~ 175-httpclient-suite-verification.txt
evidence/raw/332-httpclient-dualstack-localhost-masks-tls-permanent.txt
evidence/raw/333-eighteen-docs-source-drift-zero.txt