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

22 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 a13-f005-retry-after-illegalargumentexception 음수 Retry-After 를 거르지 않는 파서 셋이 조립되지 않아 결함이 잠재로 남는다 notification-and-delivery clean-architecture-backend-template 게시 전 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 case:a13-f005-retry-after-illegalargumentexception 2026-09-02 case-a13-f005-retry-after-illegalargumentexception.body.md
key file
a13-f005-retry-after-illegalargumentexception ../../../final/evidence/rendered/a13-f005-retry-after-illegalargumentexception.svg
key file
a13-f005-retry-after-illegalargumentexception-negative ../../../final/evidence/rendered/a13-f005-retry-after-illegalargumentexception-negative.svg
key file
a13-f005-retry-after-illegalargumentexception-unassembled ../../../final/evidence/rendered/a13-f005-retry-after-illegalargumentexception-unassembled.svg
key file
a13-f005-retry-after-illegalargumentexception-ambiguous ../../../final/evidence/rendered/a13-f005-retry-after-illegalargumentexception-ambiguous.svg
../../../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
원본 분석 절은 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. 이 헤더를 넣는 시험이 넣는 값을 전부 나열한다.

본문

파서는 Long.parseLong이 받아들이는 값을 그대로 Duration으로 만든다.

// 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(); }
  });
}

받는 쪽은 그 값을 거부한다.

// 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도 이 헤더를 정수로 바꾸지만 파서가 아니라 시험 단언이다.

그 네 파일에서 부호를 보는 줄은 하나다.

// 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-seconds1*DIGIT 이라 0 을 허용한다. 다만 소비자 쪽에서는 차이가 없다. RetryBackoff.delay:44가 힌트를 계산된 후퇴보다 클 때만 쓰므로 PT0S와 빈 값이 구분되지 않는다.

넷에 같은 술어를 처방할 수도 없다. 알림 리프는 RetryBackoff.delay:45가 상한을 걸어 주므로 음수만 거르면 되지만, BlockingAttemptExecutor:238이 채우는 값은 DefaultRetryEligibilityEngine:127에서 Duration.plus로 들어간다. 아주 큰 양수는 거기서 넘친다.

FcmFailureClassifier가 나오는 자리는 여섯이다. FcmBatchCoordinatorclassify만, FcmContactPointUpdaterinvalidatesContactPoint만 부른다. :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 하나다.

조립기가 없는 계열의 프로파일을 켜면 기동이 거부된다.

// 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에서 완료된 미래를 돌려주므로 던지는 시점은 같다.

// SesNotificationProviderAdapter.java:105-108
  public CompletionStage<ProviderSubmissionResult> submit(ProviderSubmission submission) {
    Objects.requireNonNull(submission, "submission");
    return CompletableFuture.completedFuture(send(submission));
  }

send(...)가 먼저 평가되므로 인자 예외는 submit 호출 자체에서 동기로 던져진다.

// 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 를 그대로 통과해 배차 서비스로 간다. 잡기가 셋인데 :221ProviderCallNotStartedException, :227NotificationException 이라 타입이 맞지 않는다. 남는 것이 마지막이다.

// 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의 재시도 정책이다.

정책이 보는 두 값은 어댑터가 아니라 프로파일 스냅샷에서 온다.

// NotificationDispatchService.java:372-373
        profile.capabilities().providerIdempotency(),
        profile.capabilities().statusQuery(),

알림 경로의 프로덕션 코드 가운데 NotificationProviderAdapter.capabilities() 를 부르는 것은 없다. 스냅샷을 만드는 자리가 조립기이므로, 이 두 값은 그날 쓰인 조립기가 정한다.

// 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 를 정한다.

// 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이 확정 수락인지 보고 아니면 실패로 덮는다.

// 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:45AMBIGUOUS_SUBMISSION 가지는 어차피 닿지 않는다. 모호한 시도 뒤에는 :24가 스위치 앞에서 먼저 돌려주기 때문이다. 조절이었다면 그 표시가 서지 않아 :24를 지나고 :38-41의 같은 채널 재시도에 닿았을 것이다.

건강 반영도 사라진다. applyHealth는 게이트웨이 :48에서 불리고 그 줄은 :47이 던지면 닿지 않는다.

원문과 어긋나는 지점

원문 :659는 이 예외가 NotificationSchedulerWorker의 포괄 잡기에 닿아 리스가 만료되고 회수가 재대사로 처리한다고 적었다. 배차 서비스의 :243이 먼저 잡으므로 dispatch()는 정상으로 돌아오고, 작업자의 잡기는 발화하지 않으며, 리스는 만료가 아니라 :177에서 반납된다.

아무도 걸리지 않은 이유

자료 마지막 두 묶음이 이 헤더를 넣는 자리다. 알림 리프의 시험 넷이 10, 7, 3, 7을 쓰고, httpclient 리프의 픽스처 StatefulUpstream:621을 쓴다. 적대적 값은 없다.

NotificationChaosSecurityTest:1203은 그 시험이 헤더를 공격해서 나온 값이 아니다. 그 클래스의 javadoc 이 주제를 둘로 적는다. 커밋 후 응답 손실과 텔레메트리 비밀 누출이다. 3은 비밀 누출 검사가 쓰는 오류 응답 목록에 들어 있을 뿐이다.

확인하지 못한 것

조립기를 써서 세 어댑터를 실제로 띄워 보지는 않았다. 6번 이후는 분기 조건을 코드로 확인한 정적 추적이다. httpclient 사본의 넘침도 소비자 코드를 읽어 확인했을 뿐 실행으로 재현하지 않았다.