EVD-332  adapter-outbound-httpclient
듀얼스택 localhost 가 영구 TLS 실패를 재시도 가능한 CONNECT 로 강등시킨다
측정일 2026-08-31 · workmachine 컨테이너

────────────────────────────────────────────────────────────────────────
1. 관측: 이 저장소에서 이번 사이클 유일하게 실패한 테스트
────────────────────────────────────────────────────────────────────────
$ ./gradlew :adapter:outbound:httpclient:test

MutualTlsHandshakeContractTest > anUntrustedAuthorityIsAPermanentTlsFailure() FAILED
    org.opentest4j.AssertionFailedError at MutualTlsHandshakeContractTest.java:168
MutualTlsHandshakeContractTest > anExpiredCertificateIsAPermanentTlsFailure() FAILED
    org.opentest4j.AssertionFailedError at MutualTlsHandshakeContractTest.java:168
MutualTlsHandshakeContractTest > aHostnameMismatchIsAPermanentTlsFailure() FAILED
    org.opentest4j.AssertionFailedError at MutualTlsHandshakeContractTest.java:168

283 tests completed, 3 failed          (모듈 전체)
5 tests completed, 3 failed            (해당 클래스만)

XML 리포트에서 뽑은 실제 메시지 — 세 건 모두 동일:
    expected: TLS_HANDSHAKE
     but was: CONNECT

실패 지점 MutualTlsHandshakeContractTest.java:164-171
    private void assertPermanent(Throwable captured) {
      TransportFailure classified =
          new ApacheFailureClassifier().classify(captured, AttemptStage.TLS_HANDSHAKE);
      assertThat(classified.evidence()).isEqualTo(ExecutionEvidence.NOT_SENT);   // 167 통과
      assertThat(classified.stage()).isEqualTo(AttemptStage.TLS_HANDSHAKE);      // 168 실패
      assertThat(classified.category()).isEqualTo(FailureCategory.TLS_PERMANENT);
      assertThat(classified.category().permanent()).isTrue();
    }

같은 클래스의 나머지 2건은 통과했다:
  aClientCertificateSatisfiesAServerThatRequiresOne   (startTls(..., true), 핸드셰이크 성공)
  aMissingClientCertificateCannotReachTheUpstream     (startTls(..., true), RuntimeException 여부만 단언)
실패한 3건만 startTls(..., false) 를 쓴다 — 이것이 처음 눈에 띈 차이였고, 오해의 출발점이었다.

────────────────────────────────────────────────────────────────────────
2. 첫 가설과 그 기각
────────────────────────────────────────────────────────────────────────
가설 A: ApacheFailureClassifier.classify 가 원인 사슬을 바깥→안으로 훑으며 첫 매치를
        반환(ApacheFailureClassifier.java:36-43)하므로, Apache 가 SSLHandshakeException 을
        연결 예외로 감싸면 CONNECT 규칙(58-64행)이 TLS 규칙(65-72행)을 가린다.

기각. jshell 로 실제 예외 사슬을 떠보니 SSL 예외가 사슬에 아예 없었다:
    [0] ResourceAccessException :: ... Connect to https://localhost:56507 failed: Connection refused
    [1] HttpHostConnectException :: Connect to https://localhost:56507 failed: Connection refused
분류기는 자기가 받은 것을 정확히 분류했다. 정보는 도착 전에 이미 사라졌다.

가설 B: startTls(..., false) 일 때 MockWebServer 가 뜨지 않는다.
기각. 두 경우 모두 원시 소켓 접속이 성공한다:
    === requireClientAuth=TRUE  port=49531 ===  raw 127.0.0.1: OK   raw localhost: OK
    === requireClientAuth=FALSE port=37221 ===  raw 127.0.0.1: OK   raw localhost: OK

────────────────────────────────────────────────────────────────────────
3. 확정된 기전 — 접속 호스트만 바꾼 대조 실험
────────────────────────────────────────────────────────────────────────
동일 서버·동일 클라이언트 신뢰재료, baseUrl 의 호스트만 교체:

=== untrusted-authority @127.0.0.1 ===
  [0] ResourceAccessException :: (bad_certificate) PKIX path validation failed: ... signature check failed
  [1] javax.net.ssl.SSLHandshakeException :: (bad_certificate) PKIX path validation failed
  [2] sun.security.validator.ValidatorException :: PKIX path validation failed
  [3] java.security.cert.CertPathValidatorException :: signature check failed
  [4] java.security.SignatureException :: Signature does not match.
  -> stage=TLS_HANDSHAKE category=TLS_PERMANENT permanent=true      ← 테스트가 기대하는 값

=== untrusted-authority @localhost ===
  [0] ResourceAccessException :: Connect to https://localhost:40485 failed: Connection refused
  [1] HttpHostConnectException :: Connect to https://localhost:40485 failed: Connection refused
  -> stage=CONNECT category=CONNECT permanent=false                 ← 테스트가 본 값

=== expired-certificate @127.0.0.1 ===
  [1] SSLHandshakeException :: (certificate_expired) PKIX path validation failed: validity check failed
  [4] java.security.cert.CertificateExpiredException :: NotAfter: Sun Aug 30 13:52:15 UTC 2026
  -> stage=TLS_HANDSHAKE category=TLS_PERMANENT permanent=true

환경:
  /etc/hosts   127.0.0.1  localhost
               ::1        localhost ip6-localhost ip6-loopback
  lo           inet6 ::1/128 scope host
  InetAddress.getAllByName("localhost") -> [localhost/127.0.0.1, localhost/0:0:0:0:0:0:0:1]
  preferIPv4Stack / preferIPv6Addresses 설정: 저장소·모듈 어디에도 없음

기전:
  MockWebServer 는 IPv4 루프백에만 바인딩한다. MockHttpServer.uri() 는 호스트명 "localhost"
  를 돌려준다(MockHttpServer.java:70-72). Apache HttpClient 5 의 연결 오퍼레이터는 해석된
  주소 전체를 순회하며, 마지막 주소가 아니면 그 주소의 실패를 삼키고 다음으로 넘어간다.
    127.0.0.1 → TCP 성공 → TLS 핸드셰이크 실패(진짜 실패) → 삼켜짐
    ::1       → TCP 거부(듣는 소켓 없음) → 마지막 주소 → HttpHostConnectException 으로 승격
  따라서 호출자에게 도달하는 유일한 예외는 두 번째 주소의 연결 거부이고, 진짜 원인인
  SSLHandshakeException 은 사슬 어디에도 남지 않는다.

  핸드셰이크가 성공하는 테스트가 통과하는 이유도 같은 루프다. 127.0.0.1 에서 성공하면
  루프가 즉시 반환하므로 ::1 을 시도하지 않는다. 즉 startTls 의 boolean 은 원인이 아니라
  상관관계였다 — 실패하는 케이스가 곧 두 번째 주소까지 가는 케이스다.

────────────────────────────────────────────────────────────────────────
4. 판정
────────────────────────────────────────────────────────────────────────
테스트 실패 자체는 환경 의존이다. ApacheFailureClassifier 에 결함은 없다.
localhost 가 IPv4 로만 풀리는 환경에서는 세 건 모두 통과한다. 프로덕션 코드를 고쳐서
고칠 문제가 아니며, 고쳐야 할 것은 픽스처가 호스트명을 돌려준다는 점이다.

다만 이 실패가 드러낸 런타임 성질은 테스트 잡음이 아니다:

  FailureCategory.java:34-44   TLS_PERMANENT.permanent() == true
                               CONNECT.permanent()       == false
  DefaultRetryEligibilityEngine.java:37-39
                               permanent() 이면 RetryDenied.permanentFailure
  DefaultRetryEligibilityEngine.failureDecision
                               case CONNECT -> RetryAllowed.of("CONNECT")

  듀얼스택 업스트림에서 한쪽 주소 패밀리는 TLS 를 영구 거절하고 다른 쪽은 연결이 거부되면,
  절대 검증되지 않을 인증서에 대한 호출이 CONNECT 로 분류되어 예산·데드라인이 소진될 때까지
  매 시도 재시도된다. 분류기가 볼 수 있는 정보 안에서는 이 강등을 막을 방법이 없다.
  Apache 의 다중 주소 루프가 앞선 주소의 실패를 버리기 때문이다.

────────────────────────────────────────────────────────────────────────
5. 재현 절차
────────────────────────────────────────────────────────────────────────
CP=<:adapter:outbound:httpclient 의 test 런타임 클래스패스, 존재하는 항목만>
jshell --class-path "$CP" 로 아래를 실행:
  MockHttpServer s = MockHttpServer.startTls(TlsFixture.trusted().serverSocketFactory(), false);
  // baseUrl 호스트를 "localhost" / "127.0.0.1" 로 각각 바꿔 ApacheBlockingTransportProvider 로 GET
  // 잡은 예외의 getCause 사슬 전체와 ApacheFailureClassifier.classify(t, TLS_HANDSHAKE) 결과를 출력
클래스패스는 init 스크립트로 test.classpath 를 출력해 얻었다(저장소 미수정).
애플리케이션 소스는 한 줄도 건드리지 않았다.
