Files
document-haness/docs/clean-architecture-backend-template/tech-log-studio/notification-and-delivery/case/case-a13-f005-retry-after-illegalargumentexception.md
T
DongHyeonkaandClaude Fable 5.1 b25357c48a docs(clean-architecture-backend-template): fold analysis into final and re-select one topic
- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다
- 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5
  (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를
  techviz 로 만들었다
- 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs
  돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다.
  Concept 이 인용한 코드가 SSOT 에 없어 뺐다
- candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 12:39:20 +09:00

305 lines
22 KiB
Markdown

---
kind: CASE
slug: a13-f005-retry-after-illegalargumentexception
title: 음수 Retry-After 를 거르지 않는 파서 셋이 조립되지 않아 결함이 잠재로 남는다
topic: notification-and-delivery
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: case:a13-f005-retry-after-illegalargumentexception
evidenceCapturedOn: 2026-09-02
body: case-a13-f005-retry-after-illegalargumentexception.body.md
assets:
- key: a13-f005-retry-after-illegalargumentexception
file: ../../../final/evidence/rendered/a13-f005-retry-after-illegalargumentexception.svg
- key: a13-f005-retry-after-illegalargumentexception-negative
file: ../../../final/evidence/rendered/a13-f005-retry-after-illegalargumentexception-negative.svg
- key: a13-f005-retry-after-illegalargumentexception-unassembled
file: ../../../final/evidence/rendered/a13-f005-retry-after-illegalargumentexception-unassembled.svg
- key: a13-f005-retry-after-illegalargumentexception-ambiguous
file: ../../../final/evidence/rendered/a13-f005-retry-after-illegalargumentexception-ambiguous.svg
evidence:
- ../../../final/evidence/raw/a13-f005-retry-after-illegalargumentexception.txt
- ../../../final/evidence/raw/a13-f005-retry-after-illegalargumentexception-negative.txt
- ../../../final/evidence/raw/a13-f005-retry-after-illegalargumentexception-unassembled.txt
- ../../../final/evidence/raw/a13-f005-retry-after-illegalargumentexception-ambiguous.txt
source:
- 원본 분석 절은 final/document.md#a13#L636 이다.
---
# 음수 Retry-After 를 거르지 않는 파서 셋이 조립되지 않아 결함이 잠재로 남는다
헤더 파서는 숫자 형식만 방어하고 부호는 방어하지 않는다. 실패 값 타입의 생성자는 음수를 거부한다. 그 조합이 인자 예외를 만드는데, 그 예외를 만들 수 있는 세 분류기는 이 빌드에서 조립되지 않는다.
## 관계
- **검증기가 불리지 않는 지금 실제로 도는 게이트는 생성자다**
검사가 생산자가 아니라 소비자 생성자에 있다는 점이 같다.
- **"상한을 두고 읽는다"는 본문 핸들러가 전부 읽은 뒤에 자른다**
같은 리프의 다른 원격 입력 사례다.
- **바이트가 프로세스를 떠났는가가 REJECTED와 AMBIGUOUS를 가른다**
응답을 다 받은 실패가 AMBIGUOUS 로 적히므로 그 기준으로 설명되지 않는다.
## 문제
원격이 조절 응답과 함께 재시도 대기 헤더를 보낸다. 파서가 그 값을 기간으로 바꾸고 실패 값 타입이 그것을 담는다. 두 쪽의 입력 계약이 같은지, 그리고 그 차이가 실제로 도달 가능한지 확인했다.
## 결론
계약은 다르다. 파서는 정수 파싱이 받아들이는 값을 그대로 초 단위 기간으로 만들고 부호는 보지 않는다. 받는 쪽 생성자는 음수를 인자 예외로 거절한다.
같은 파서가 저장소에 넷 있다. 부호를 보는 줄은 웹훅 사본 하나뿐이고, 그 술어는 표준이 허용하는 0 까지 걸러 낸다.
고쳐야 할 술어도 리프마다 다르다. 알림 리프의 소비자는 힌트에 상한을 걸므로 음수만 거르면 되지만, httpclient 사본의 소비자는 힌트에 기간을 더하므로 아주 큰 양수에서 넘친다.
넷 가운데 하나는 죽어 있다. FCM 분류기가 자기 사본을 공개 메서드로 들고 있는데, 그 분류기를 쓰는 세 자리가 부르는 것은 다른 메서드다.
문제가 실제로 나는 조합은 공용 파서 쪽이다. 세 분류기가 다섯 자리에서 그것을 부르고, 상태 변환기는 429 뿐 아니라 500 이상에도 같은 값을 넘긴다. 음수 헤더를 단 429 응답을 넣으면 세 분류기가 모두 인자 예외로 끝난다. APNs 만 정상 분류를 돌려주는데 애초에 이 헤더를 읽지 않기 때문이다.
그런데 그 셋은 이 빌드에서 돌 수 없다.
세 어댑터를 생성하는 코드가 시험 소스에만 있다. main 에 있는 조립기는 SMTP 하나뿐이다. 그 밖의 계열을 켜면 해당 어댑터를 만드는 조립 코드가 없어 애플리케이션이 기동하지 않는다. 거부 문구가 이유를 적어 뒀다. 해당 전송은 인터페이스만 제공하고 실제 구현은 포함하지 않는다는 것이다.
조립되면 P2 다. 원격이 정하는 값 하나가 확정 거절을 영구 실패로 바꾸고 재시도와 대체를 막는다. 이 빌드에서는 그 셋이 조립되지 않으므로 한 단계 내려 P3 으로 둔다. 배선되지 않은 발견을 도달 가능한 등급에서 한 단계 내리는 것은 이 저장소가 websocket 리프에서 쓴 방식이다. 같은 저장소가 P3 을 매긴 다른 자리는 닿지 않는다는 것에 더해 결과가 무해하다는 조건을 하나 더 달았는데, 여기 결과는 무해하지 않다. 등급이 심각도를 바꾸되 발견을 없애지는 않는다.
원문도 P3 이지만 근거가 다르다. 원문은 표준을 지키는 제공자가 음수를 보내지 않는다는 것을 들었다. 그 근거는 계속 유효하지만, 조립되지 않았다는 근거는 조립기가 생기면 사라진다.
조립기가 생기면 이 결함이 도달 가능해지므로, 그때 무슨 일이 나는지까지 정적으로 따라갔다.
세 어댑터의 제출 메서드는 미래를 만들기 전에 본체를 먼저 실행한다. 예외는 게이트웨이가 미래를 기다리기 전에 던져지므로 완료 예외가 되지 않고, 게이트웨이의 완료 예외 잡기를 지나친다. 미래를 나중에 채우는 어댑터는 하나뿐이고, 그 계열 분류기는 이 파서와 무관하다.
배차 서비스의 잡기 셋 가운데 앞의 둘은 타입이 맞지 않는다. 마지막 포괄 잡기가 받아 응답 소실과 모호한 제출로 적는다. 그 가지의 주석은 바이트가 제공자에 닿았을 수 있다는 전제를 적어 뒀는데, 예외 시점에 응답은 이미 읽혀 있었다.
그다음이 원문과 갈리는 자리다. 원문은 예외가 일정 작업자의 포괄 잡기에 닿아 리스가 만료된다고 적었으나, 배차 서비스가 먼저 잡으므로 그 잡기에는 예외가 도달하지 않는다.
한 회차의 순서도 짚어야 한다. 라우팅은 이 실패를 볼 수 없다. 그 결정이 제출보다 먼저 나기 때문이다. 그 회차를 끝내는 것은 재시도 정책이다. 정책이 읽는 두 능력 값의 출처는 어댑터가 아니다. 어댑터의 능력 선언 메서드는 프로덕션 호출자가 없다.
그 두 값의 조합이 셋인데 결과는 둘로 모인다. 상태 조회를 지원하면 바로 조정으로 간다. 둘 다 지원하지 않으면 중지한다. 멱등성만 지원하면 재전송을 예약하지만 그 회차는 제출에 닿지 못한다. 수신자의 ambiguousAttemptExists 가 이미 참이라, 라우팅이 제출 대신 조정으로 보내기 때문이다.
한 회차에 수신자 상태를 쓰는 자리도 둘이다. 기록기가 먼저 모호한 결과를 조정 필요로 적고 ambiguousAttemptExists 를 참으로 만든다. 그다음 후속 조치가 조건마다 그 상태를 덮는다. 중지에서는 실패로, 재전송 예약에서는 재시도 대기로 덮는다. 둘 중 실패만 다음 회차의 청구 대상이 아니다. 그 시도까지 모호한 시도가 없었다면, 조절은 그 값을 참으로 만들지 않으므로 다시 집혔을 것이다.
아무도 걸리지 않은 이유도 확인했다. 알림 리프에서 이 헤더를 넣는 시험이 넷인데 값이 10, 7, 3, 7 이다. 다른 리프의 픽스처가 넣는 값도 1 이다. 적대적 값은 한 자리도 없다.
## 검증 환경
OpenJDK : 21.0.12
확인 방식 : 파서 넷과 생성자 대조, 분류기 넷에 음수 헤더를 넣는 프로브, 조립 가능성과 예외 경로 정적 추적
소스 수정 : x
## 재현 조건
이 사례의 프로브는 새로 만든 것이고 원문에는 없다. 원문 근거는 분석 문서의 §21.1 절이다.
1. 파싱 구문으로 저장소를 훑어 초 단위 변환 자리를 찾는다.
2. 각 파서가 부호를 검사하는지 읽는다.
3. 받는 값 타입의 정규 생성자 검증을 읽는다.
4. 고정 리비전 산출물로 클래스패스를 만들고 파서 셋에 같은 헤더 넷을 넣는다.
5. 음수 헤더를 단 429 응답을 분류기 넷에 넣는다.
6. 그 분류기를 쓰는 어댑터가 main 에서 생성되는지, 그 계열의 조립기가 있는지 센다.
7. 제출 메서드가 동기인지 확인하고 게이트웨이와 배차 서비스의 잡기를 읽는다.
8. 한 회차 안의 호출 순서와 중지가 남기는 상태, 그리고 다음 회차의 청구 조건을 읽는다.
9. 이 헤더를 넣는 시험이 넣는 값을 전부 나열한다.
## 본문
<!-- body:start -->
파서는 `Long.parseLong`이 받아들이는 값을 그대로 `Duration`으로 만든다.
```java
// ProviderResults.java:90-99
public static Optional<Duration> retryAfter(Optional<String> headerValue) {
return headerValue.flatMap(value -> {
try { return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim()))); }
catch (NumberFormatException notSeconds) { return Optional.empty(); }
});
}
```
받는 쪽은 그 값을 거부한다.
```java
// ProviderFailure.java:29-34 (canonical constructor)
retryAfter.ifPresent(delay -> {
if (delay.isNegative()) { throw new IllegalArgumentException("retryAfter"); }
});
```
## 같은 파서가 넷이고 술어는 하나도 맞지 않는다
:::evidence key="a13-f005-retry-after-illegalargumentexception" alt="코드베이스 정적 검색 출력 49줄. 파싱 구문 검색을 retry·seconds·header 로 좁힌 결과와, 네 파서 안에서 부호를 보는 줄, 받는 생성자의 검증, 공용 파서를 쓰는 다섯 자리, FCM 분류기가 나오는 자리 전부, 이 헤더를 넣는 시험 값이 차례로 보인다." caption="Retry-After 파서 넷과 그 소비자 — 49줄 · exit 0" zoom="true"
:::
첫 묶음은 `parseLong`·`parseInt`·`Duration.parse` 로 저장소를 훑은 뒤 `retry`·`seconds`·`header` 가 든 줄만 남긴 것이다. Retry-After 를 초로 바꾸는 것은 그중 넷이다. `ProviderResults:94`, `FcmFailureClassifier:70`, `WebhookNotificationProviderAdapter:243`, `BlockingAttemptExecutor:238`. 목록의 `WebThrottleHttpContract:49`도 이 헤더를 정수로 바꾸지만 파서가 아니라 시험 단언이다.
그 네 파일에서 부호를 보는 줄은 하나다.
```java
// WebhookNotificationProviderAdapter.java:243-244
long seconds = Long.parseLong(value.trim());
return seconds > 0 ? Optional.of(Duration.ofSeconds(seconds)) : Optional.empty();
```
`seconds > 0`은 음수와 함께 0 도 버린다. RFC 9110 §10.2.3 의 `delta-seconds``1*DIGIT` 이라 0 을 허용한다. 다만 소비자 쪽에서는 차이가 없다. `RetryBackoff.delay:44`가 힌트를 계산된 후퇴보다 클 때만 쓰므로 `PT0S`와 빈 값이 구분되지 않는다.
넷에 같은 술어를 처방할 수도 없다. 알림 리프는 `RetryBackoff.delay:45`가 상한을 걸어 주므로 음수만 거르면 되지만, `BlockingAttemptExecutor:238`이 채우는 값은 `DefaultRetryEligibilityEngine:127`에서 `Duration.plus`로 들어간다. 아주 큰 양수는 거기서 넘친다.
`FcmFailureClassifier`가 나오는 자리는 여섯이다. `FcmBatchCoordinator``classify`만, `FcmContactPointUpdater``invalidatesContactPoint`만 부른다. `:66-75`의 사본에는 호출자가 없다.
값이 흘러 들어가는 상태도 429 하나가 아니다. `ProviderResults.fromStatus``:54`의 429 가지와 `:78`의 500 이상 가지에 같은 인자를 넘긴다.
## 음수 헤더를 실제로 넣었다
:::evidence key="a13-f005-retry-after-illegalargumentexception-negative" alt="JVM 프로브 출력 29줄. 헤더 네 값을 파서 셋에 넣은 결과와, 음수 헤더를 단 429 응답을 분류기 넷에 넣은 결과가 나온다." caption="음수 Retry-After 를 파서 셋과 분류기 넷에 넣은 프로브 — 29줄 · exit 0" zoom="true"
:::
`-30`을 넣으면 공용 파서와 FCM 사본이 `PT-30S`를 만들고 웹훅 사본은 빈 값을 준다. 그 `PT-30S`가 생성자에 닿으면 SES·Twilio·WebPush 셋이 모두 `IllegalArgumentException: retryAfter`로 끝난다. APNs 만 `retryAfter=Optional.empty`인 정상 실패를 돌려준다. `ApnsFailureClassifier:55`가 헤더 대신 빈 값을 넘기기 때문이다.
`0`에서 두 계열이 갈린다. 공용 파서는 `PT0S`를 만들고 그 값은 음수가 아니므로 생성자를 통과한다.
HTTP-date 형식은 프로브에 넣은 셋 모두 빈 값이 되어 계산된 후퇴로 떨어진다. 프로브에 없는 네 번째도 `BlockingAttemptExecutor:239-243`에서 같은 `NumberFormatException` 가지로 빈 값을 준다.
## 그 셋은 이 빌드에서 조립되지 않는다
:::evidence key="a13-f005-retry-after-illegalargumentexception-unassembled" alt="코드베이스 정적 검색 출력 49줄. 재시도 정책이 보는 두 값의 출처, 알림 경로에서 capabilities 를 선언하고 부르는 자리, 프로파일 스냅샷을 만드는 자리, 조립기 구현 하나, 세 어댑터를 생성하는 자리, 조립기 없는 계열의 기동 거부 문구가 차례로 보인다." caption="세 어댑터의 조립 가능성과 능력 선언의 출처 — 49줄 · exit 0" zoom="true"
:::
자료의 각 줄 앞에 붙은 `main`·`test` 가 소스셋이다. 세 어댑터를 생성하는 자리는 여섯 줄 모두 `test` 다. `implements ProviderRuntimeAssembler` 도 main 에는 `SmtpProviderRuntimeAssembler:48` 하나뿐이고, `new ProviderProfileSnapshot(` 을 main 에서 부르는 자리도 그 클래스의 `:127` 하나다.
조립기가 없는 계열의 프로파일을 켜면 기동이 거부된다.
```java
// NotificationProviderAssembly.java:96-104
if (assembler == null) {
throw new IllegalStateException(
"notification provider profile '"
+ profileId
+ "' is of type "
+ type
+ ", which has no assembler in this build. The transport is a seam, not an"
+ " implementation; remove the profile or supply a ProviderRuntimeAssembler for"
+ " that family.");
```
그래서 이 결함은 잠재다. 원문이 P3 을 매긴 근거는 표준을 지키는 제공자가 음수를 보내지 않는다는 것인데, 그보다 앞서 이 코드에 닿을 방법이 없다.
## 조립기가 생기면 무슨 일이 나는가
:::evidence key="a13-f005-retry-after-illegalargumentexception-ambiguous" alt="코드베이스 정적 검색 출력 46줄. 일곱 어댑터의 제출 반환 줄, 게이트웨이가 던지고 잡는 자리, 배차 서비스의 세 잡기와 마지막 가지가 만드는 값, 한 회차의 호출 순서, 중지가 남기는 상태, 다음 회차의 청구 조건이 차례로 보인다." caption="인자 예외가 지나는 길과 한 회차의 끝 — 46줄 · exit 0" zoom="true"
:::
먼저 어디를 지나지 않는지가 중요하다. 일곱 어댑터의 반환 줄이 자료에 있다. 여섯은 본체를 먼저 실행하고 그 결과로 완료된 미래를 만들며, `FcmNotificationProviderAdapter:65`만 조정자에게 넘긴다. 그 조정자도 `FcmBatchCoordinator:96`에서 완료된 미래를 돌려주므로 던지는 시점은 같다.
```java
// SesNotificationProviderAdapter.java:105-108
public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
Objects.requireNonNull(submission, "submission");
return CompletableFuture.completedFuture(send(submission));
}
```
`send(...)`가 먼저 평가되므로 인자 예외는 `submit` 호출 자체에서 동기로 던져진다.
```java
// RegistryProviderDispatchGateway.java:45-50
try (AttemptPermit permit = runtime.acquireAttempt()) {
ProviderSubmissionResult result =
runtime.adapter().submit(submission).toCompletableFuture().join();
applyHealth(runtime, result);
return result;
} catch (CompletionException failure) {
```
`:47`에서 던져지므로 `join()`에 닿지 않고 `CompletionException`도 되지 않는다. `:50`의 잡기는 이 경로에 없다. 자료에서 `supplyAsync` 를 쓰는 줄은 `SmtpNotificationProviderAdapter:82` 하나이고, 그 계열 분류기에는 `ProviderResults` 참조가 없다.
예외는 try-with-resources 를 그대로 통과해 배차 서비스로 간다. 잡기가 셋인데 `:221``ProviderCallNotStartedException`, `:227``NotificationException` 이라 타입이 맞지 않는다. 남는 것이 마지막이다.
```java
// NotificationDispatchService.java:243-253
} catch (RuntimeException transportFailure) {
// Anything the adapter did not classify. The bytes may have reached the provider, so the only
// honest record is an ambiguous one — this branch is the residue, not the default.
return ProviderSubmissionResult.ambiguous(
ProviderFailure.of(
NotificationFailureCode.PROVIDER_RESPONSE_LOST,
FailureCategory.AMBIGUOUS_SUBMISSION,
false),
ProviderExecutionEvidence.responseLost(),
Duration.ZERO);
}
```
주석의 전제는 바이트가 제공자에 닿았을 수 있다는 것이다. 이 실패에는 그 전제가 없다. 예외가 난 시점에 429 응답은 이미 읽혀 있었고, 429 는 제공자가 받지 않았다는 확정이다.
## 그 회차의 끝
한 회차 안의 순서가 정해져 있다. `:121`의 라우팅 결정은 제출보다 앞이고 이전 시도 상태로 돈다. 이 실패를 같은 회차에 보는 것은 `:175`의 재시도 정책이다.
정책이 보는 두 값은 어댑터가 아니라 프로파일 스냅샷에서 온다.
```java
// NotificationDispatchService.java:372-373
profile.capabilities().providerIdempotency(),
profile.capabilities().statusQuery(),
```
알림 경로의 프로덕션 코드 가운데 `NotificationProviderAdapter.capabilities()` 를 부르는 것은 없다. 스냅샷을 만드는 자리가 조립기이므로, 이 두 값은 그날 쓰인 조립기가 정한다.
```java
// DefaultNotificationRetryPolicy.java:54-59
if (context.confirmation() == AttemptConfirmation.AMBIGUOUS) {
if (context.statusQuerySupported()) {
return new RetryDecision.Reconcile(context.now().plus(reconcileDelay));
}
if (!context.providerIdempotency()) {
return new RetryDecision.Stop("AMBIGUOUS_UNSAFE_TO_RETRY");
```
갈래는 셋이다. 상태 조회가 참이면 `:56`의 조정, 둘 다 거짓이면 `:59`의 중지, 상태 조회만 거짓이고 멱등성이 참이면 블록을 빠져나간다. 셋째 갈래도 `:69`·`:74`·`:77`의 중지 셋을 지나야 `:80`의 재전송에 닿는다.
그 재전송은 제공자에 닿지 않는다. 이 회차에서 상태를 먼저 쓰는 것은 `:173`의 기록기이고, 그 기록기가 `:108`에서 결과의 확인 상태를 보고 `ambiguous` 를 정한다.
```java
// DispatchOutcomeRecorder.java:119-126
ambiguous
? RecipientDeliveryState.RECONCILIATION_REQUIRED
: RecipientDeliveryState.DISPATCHING,
...
recipient.ambiguousAttemptExists() || ambiguous,
recipient.duplicateRisk() || ambiguous,
```
`ambiguousAttemptExists` 가 세워진 채 커밋되고, `transitionHeldBy` 는 상태와 다음 시각만 쓰므로 그 표시가 남는다. 그사이 요청이 만료되면 애초에 다시 집히지도 않는다. 집힌다면 `RoutingDecisionEngine:24`가 실패 스위치에 가기 전에 조정을 돌려주고, `NotificationDispatchService:122-126`이 제출 앞에서 되돌아온다. `applyRoutingStop:389-393`이 수신자를 조정 필요로 옮긴다.
중지 갈래에서는 `:435-440`이 확정 수락인지 보고 아니면 실패로 덮는다.
```java
// NotificationDispatchService.java:435-440
case RetryDecision.Stop ignored ->
recipients.transitionHeldBy(
work.recipient().id(),
attempt.submissionOutcome() == SubmissionOutcome.CONFIRMED_ACCEPTED
? RecipientDeliveryState.COMPLETED
: RecipientDeliveryState.FAILED,
```
실패는 `RecipientClaimSql:30`의 청구 대상 셋에 없다. `RoutingDecisionEngine:45``AMBIGUOUS_SUBMISSION` 가지는 어차피 닿지 않는다. 모호한 시도 뒤에는 `:24`가 스위치 앞에서 먼저 돌려주기 때문이다. 조절이었다면 그 표시가 서지 않아 `:24`를 지나고 `:38-41`의 같은 채널 재시도에 닿았을 것이다.
건강 반영도 사라진다. `applyHealth`는 게이트웨이 `:48`에서 불리고 그 줄은 `:47`이 던지면 닿지 않는다.
## 원문과 어긋나는 지점
원문 `:659`는 이 예외가 `NotificationSchedulerWorker`의 포괄 잡기에 닿아 리스가 만료되고 회수가 재대사로 처리한다고 적었다. 배차 서비스의 `:243`이 먼저 잡으므로 `dispatch()`는 정상으로 돌아오고, 작업자의 잡기는 발화하지 않으며, 리스는 만료가 아니라 `:177`에서 반납된다.
## 아무도 걸리지 않은 이유
자료 마지막 두 묶음이 이 헤더를 넣는 자리다. 알림 리프의 시험 넷이 `10`, `7`, `3`, `7`을 쓰고, httpclient 리프의 픽스처 `StatefulUpstream:62``1`을 쓴다. 적대적 값은 없다.
`NotificationChaosSecurityTest:120``3`은 그 시험이 헤더를 공격해서 나온 값이 아니다. 그 클래스의 javadoc 이 주제를 둘로 적는다. 커밋 후 응답 손실과 텔레메트리 비밀 누출이다. `3`은 비밀 누출 검사가 쓰는 오류 응답 목록에 들어 있을 뿐이다.
## 확인하지 못한 것
조립기를 써서 세 어댑터를 실제로 띄워 보지는 않았다. 6번 이후는 분기 조건을 코드로 확인한 정적 추적이다. httpclient 사본의 넘침도 소비자 코드를 읽어 확인했을 뿐 실행으로 재현하지 않았다.
<!-- body:end -->