# HTTP Client Platform Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Spring 기반 Backend Skeleton에 Typed Service Client, Named Client Profile, 증거 기반 Retry, Blocking·Reactive 전송, OAuth2·TLS, Dynamic URL SSRF 방어, Streaming·SSE, 관측성을 제공하는 운영 가능한 외부 HTTP Client 플랫폼을 구현한다. **Architecture:** 일반 서비스 코드는 `@HttpExchange` 기반 H1 Typed Client를 사용하고, H2 Generic Gateway와 H3 Dynamic Target Gateway는 별도 권한 경계로 제공한다. 모든 호출은 immutable Named Client Profile에서 transport, pool, timeout, auth, resilience, security, observability 설정을 가져오며, Retry Coordinator가 `OperationIdempotency`, `BodyReplayability`, `ExecutionEvidence`, deadline, retry budget을 근거로 물리 시도를 통제한다. Blocking 경로는 RestClient와 Apache/JDK, Reactive 경로는 WebClient와 Reactor Netty를 사용한다. **Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Framework 6.2 common baseline with Spring 7.0 compatibility tests, Spring RestClient, Spring WebClient, Spring HTTP Service Client, Apache HttpClient 5, JDK HttpClient, Reactor Netty, Resilience4j, Spring Security OAuth2 Client, Micrometer, OpenTelemetry, JUnit 5, AssertJ, ArchUnit, MockWebServer, WireMock, Testcontainers, Toxiproxy, BlockHound. ## Global Constraints - 일반 업무 모듈의 기본 진입점은 H1 Typed Service Client다. - H2 Generic Gateway는 등록된 profile의 scheme, host, port, TLS, credential, hard limit을 변경하지 못한다. - H3 Dynamic Target Gateway는 Trusted profile의 credential, Cookie, default header를 상속하지 않는다. - H4 Native engine API는 application-facing public API로 노출하지 않는다. - 모든 upstream은 고유한 Named Client Profile을 가진다. - Blocking 기본 전송은 RestClient + Apache HttpClient 5이며 JDK HttpClient는 경량 대안이다. - Reactive·Streaming 기본 전송은 WebClient + Reactor Netty다. - HTTP/1.1과 HTTP/2는 Stable, HTTP/3는 Experimental이다. - RestTemplate은 migration module에서만 사용하고 신규 기능을 추가하지 않는다. - production에서 Simple request factory를 허용하지 않는다. - total deadline은 pool acquire, DNS, connect, TLS, request write, response read, retry backoff 전체를 감싼다. - Retry는 method만으로 결정하지 않고 idempotency, idempotency key, body replayability, execution evidence, deadline, retry budget을 함께 판정한다. - `NOT_SENT`는 전송되지 않았음을 증명할 수 있을 때만 사용한다. - 비멱등 `SENT_NO_RESPONSE`는 자동 Retry하지 않고 `HttpAmbiguousExecutionException`으로 반환한다. - first response byte가 application에 전달된 뒤 transparent Retry를 금지한다. - Retry backoff 동안 connection과 attempt bulkhead permit을 보유하지 않는다. - 물리 시도는 Circuit Breaker → Rate Limiter → Bulkhead → HTTP Call 순서를 사용한다. - OAuth2 token refresh는 동일 cache key에 대해 single-flight다. - 401 자동 재호출은 최대 한 번이며 replayable하고 안전한 operation에만 적용한다. - TLS 1.2·1.3과 hostname verification을 강제하고 trust-all과 평문 fallback을 금지한다. - Dynamic Target는 URI canonicalization, 모든 DNS 결과의 IP 검증, 실제 connection pinning, redirect 재검증을 수행한다. - metric label에는 전체 URL, query value, path variable, user ID, tenant ID 원문, token, Cookie, idempotency key를 기록하지 않는다. - Reactive event-loop에서 blocking DNS, file I/O, token load, JSON 변환을 실행하지 않는다. - 모든 response lifecycle은 성공, 실패, decode error, size 초과, cancel에서 connection·buffer를 정리한다. - 모든 작업은 실패 테스트 작성 → 실패 확인 → 최소 구현 → 통과 확인 → 커밋 순서로 수행한다. - 각 Task는 독립 검토 가능한 하나의 커밋으로 종료한다. --- ## 1. 확정 파일 구조 ```text backend-skeleton/ ├── settings.gradle.kts ├── build.gradle.kts ├── build-logic/ │ └── src/main/kotlin/httpclient-library-conventions.gradle.kts ├── modules/httpclient/ │ ├── httpclient-core-api/ │ ├── httpclient-profile/ │ ├── httpclient-transport-spi/ │ ├── httpclient-transport-apache/ │ ├── httpclient-transport-jdk/ │ ├── httpclient-restclient/ │ ├── httpclient-resilience/ │ ├── httpclient-auth/ │ ├── httpclient-security/ │ ├── httpclient-observability/ │ ├── httpclient-transport-reactor-netty/ │ ├── httpclient-webclient/ │ ├── httpclient-service-client/ │ ├── httpclient-dynamic-target/ │ ├── httpclient-resttemplate-migration/ │ ├── httpclient-spring7-service-groups/ │ ├── httpclient-jetty-http3-experimental/ │ ├── httpclient-spring-boot-starter/ │ └── httpclient-testkit/ ├── infra/httpclient/ │ ├── proxy/ │ ├── tls/ │ ├── oauth2/ │ └── toxiproxy/ ├── docs/httpclient/ │ ├── support-matrix.md │ ├── configuration-reference.md │ ├── retry-and-ambiguity.md │ ├── security.md │ ├── streaming.md │ ├── operations.md │ └── migration-guide.md └── docs/superpowers/specs/2026-08-08-httpclient-platform-design.md ``` ## 2. 핵심 패키지 ```text io.backend.skeleton.httpclient.api io.backend.skeleton.httpclient.api.body io.backend.skeleton.httpclient.api.error io.backend.skeleton.httpclient.api.operation io.backend.skeleton.httpclient.api.result io.backend.skeleton.httpclient.profile io.backend.skeleton.httpclient.transport io.backend.skeleton.httpclient.apache io.backend.skeleton.httpclient.jdk io.backend.skeleton.httpclient.restclient io.backend.skeleton.httpclient.resilience io.backend.skeleton.httpclient.auth io.backend.skeleton.httpclient.security io.backend.skeleton.httpclient.observation io.backend.skeleton.httpclient.reactor io.backend.skeleton.httpclient.webclient io.backend.skeleton.httpclient.service io.backend.skeleton.httpclient.dynamic io.backend.skeleton.httpclient.migration io.backend.skeleton.httpclient.spring7 io.backend.skeleton.httpclient.http3 io.backend.skeleton.httpclient.autoconfigure io.backend.skeleton.httpclient.testkit ``` --- ### Task 1: Gradle 멀티모듈과 공통 품질 규칙 구성 **Files:** - Modify: `settings.gradle.kts` - Create: `build-logic/src/main/kotlin/httpclient-library-conventions.gradle.kts` - Create: `modules/httpclient/httpclient-core-api/build.gradle.kts` - Create: `modules/httpclient/httpclient-profile/build.gradle.kts` - Create: `modules/httpclient/httpclient-transport-spi/build.gradle.kts` - Create: `modules/httpclient/httpclient-transport-apache/build.gradle.kts` - Create: `modules/httpclient/httpclient-transport-jdk/build.gradle.kts` - Create: `modules/httpclient/httpclient-restclient/build.gradle.kts` - Create: `modules/httpclient/httpclient-resilience/build.gradle.kts` - Create: `modules/httpclient/httpclient-auth/build.gradle.kts` - Create: `modules/httpclient/httpclient-security/build.gradle.kts` - Create: `modules/httpclient/httpclient-observability/build.gradle.kts` - Create: `modules/httpclient/httpclient-transport-reactor-netty/build.gradle.kts` - Create: `modules/httpclient/httpclient-webclient/build.gradle.kts` - Create: `modules/httpclient/httpclient-service-client/build.gradle.kts` - Create: `modules/httpclient/httpclient-dynamic-target/build.gradle.kts` - Create: `modules/httpclient/httpclient-resttemplate-migration/build.gradle.kts` - Create: `modules/httpclient/httpclient-spring7-service-groups/build.gradle.kts` - Create: `modules/httpclient/httpclient-jetty-http3-experimental/build.gradle.kts` - Create: `modules/httpclient/httpclient-spring-boot-starter/build.gradle.kts` - Create: `modules/httpclient/httpclient-testkit/build.gradle.kts` - Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/ModuleSmokeTest.java` **Interfaces:** - Produces every Gradle project path used by later tasks. - `httpclient-core-api` has no Spring, Apache, Netty, Resilience4j dependency. - Java toolchain is 21. - [ ] **Step 1: Write the failing core module smoke test** ```java package io.backend.skeleton.httpclient.api; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class ModuleSmokeTest { @Test void coreApiModuleLoads() { assertThat(ModuleSmokeTest.class.getPackageName()) .isEqualTo("io.backend.skeleton.httpclient.api"); } } ``` - [ ] **Step 2: Register all module paths and verify the build fails before module build files exist** Add to `settings.gradle.kts`: ```kotlin include( ":modules:httpclient:httpclient-core-api", ":modules:httpclient:httpclient-profile", ":modules:httpclient:httpclient-transport-spi", ":modules:httpclient:httpclient-transport-apache", ":modules:httpclient:httpclient-transport-jdk", ":modules:httpclient:httpclient-restclient", ":modules:httpclient:httpclient-resilience", ":modules:httpclient:httpclient-auth", ":modules:httpclient:httpclient-security", ":modules:httpclient:httpclient-observability", ":modules:httpclient:httpclient-transport-reactor-netty", ":modules:httpclient:httpclient-webclient", ":modules:httpclient:httpclient-service-client", ":modules:httpclient:httpclient-dynamic-target", ":modules:httpclient:httpclient-resttemplate-migration", ":modules:httpclient:httpclient-spring7-service-groups", ":modules:httpclient:httpclient-jetty-http3-experimental", ":modules:httpclient:httpclient-spring-boot-starter", ":modules:httpclient:httpclient-testkit" ) ``` Run: ```bash ./gradlew :modules:httpclient:httpclient-core-api:test ``` Expected: FAIL because the registered module build files are absent. - [ ] **Step 3: Add the convention plugin and directed module dependencies** Create `httpclient-library-conventions.gradle.kts`: ```kotlin plugins { `java-library` id("java-test-fixtures") } java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } } tasks.withType().configureEach { useJUnitPlatform() failFast = false } dependencies { "testImplementation"(platform("org.junit:junit-bom:5.12.2")) "testImplementation"("org.junit.jupiter:junit-jupiter") "testImplementation"("org.assertj:assertj-core:3.27.3") } ``` Apply the convention plugin to every module. Add only the dependencies listed in the design module table; in particular, `core-api` depends on no runtime framework and `testkit` is never an `implementation` dependency of production modules. - [ ] **Step 4: Run the core test and dependency report** ```bash ./gradlew :modules:httpclient:httpclient-core-api:test \ :modules:httpclient:httpclient-core-api:dependencies ``` Expected: PASS; the dependency report contains no Spring Web, Apache HC5, Netty, Reactor, Resilience4j, or Spring Security artifact. - [ ] **Step 5: Commit** ```bash git add settings.gradle.kts build-logic modules/httpclient git commit -m "build: add http client module boundaries" ``` --- ### Task 2: 핵심 식별자와 HTTP 의미론 타입 구현 **Files:** - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/ClientProfileName.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/OperationName.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/IdempotencyKey.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/HttpMethod.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/HttpStatus.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/OperationIdempotency.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/ExecutionEvidence.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/BodyReplayability.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/AttemptStage.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/FailureCategory.java` - Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/CoreValueTypeTest.java` **Interfaces:** - Produces exact enum and record names consumed by every later module. - `HttpMethod` excludes TRACE and provides `safe()` and `standardIdempotent()`. - [ ] **Step 1: Write failing validation and method semantic tests** ```java class CoreValueTypeTest { @Test void validatesStableNames() { assertThat(new ClientProfileName("payment-api").value()) .isEqualTo("payment-api"); assertThatThrownBy(() -> new OperationName("Create Payment")) .isInstanceOf(IllegalArgumentException.class); } @Test void exposesHttpMethodSemanticsWithoutTrace() { assertThat(HttpMethod.GET.safe()).isTrue(); assertThat(HttpMethod.PUT.standardIdempotent()).isTrue(); assertThat(HttpMethod.POST.standardIdempotent()).isFalse(); assertThat(Arrays.stream(HttpMethod.values()).map(Enum::name)) .doesNotContain("TRACE"); } } ``` - [ ] **Step 2: Run the test to verify missing types fail compilation** ```bash ./gradlew :modules:httpclient:httpclient-core-api:test \ --tests '*CoreValueTypeTest' ``` Expected: FAIL with unresolved `ClientProfileName`, `OperationName`, and `HttpMethod` symbols. - [ ] **Step 3: Implement the records and enums** ```java public record ClientProfileName(String value) { public ClientProfileName { if (value == null || !value.matches("[a-z][a-z0-9-]{1,62}")) { throw new IllegalArgumentException("invalid client profile name"); } } } public enum HttpMethod { GET(true, true), HEAD(true, true), POST(false, false), PUT(false, true), PATCH(false, false), DELETE(false, true), OPTIONS(true, true); private final boolean safe; private final boolean standardIdempotent; HttpMethod(boolean safe, boolean standardIdempotent) { this.safe = safe; this.standardIdempotent = standardIdempotent; } public boolean safe() { return safe; } public boolean standardIdempotent() { return standardIdempotent; } } ``` Implement the remaining records with non-null validation and the exact enum constants from the design. - [ ] **Step 4: Run the core test** ```bash ./gradlew :modules:httpclient:httpclient-core-api:test \ --tests '*CoreValueTypeTest' ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-core-api git commit -m "feat: define http client core semantics" ``` --- ### Task 3: Request Body와 Response 타입 계약 구현 **Files:** - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/BodySource.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/EmptyBody.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/ObjectBody.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/ByteArrayBody.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/ReopenableStreamBody.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/OneShotStreamBody.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/body/IOSupplier.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/ResponseType.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/ClassResponseType.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/GenericResponseType.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/EmptyResponseType.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/BlockingStreamingResponse.java` - Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/body/BodyReplayabilityTest.java` **Interfaces:** - Produces `BodySource.replayability()` and `knownLength()`. - Retry tasks consume these exact methods. - Blocking streaming response is `AutoCloseable`. - [ ] **Step 1: Write failing replayability and lifecycle tests** ```java class BodyReplayabilityTest { @Test void classifiesBodySources() { assertThat(new ByteArrayBody(new byte[] {1, 2}, "application/octet-stream") .replayability()).isEqualTo(BodyReplayability.REPLAYABLE); ReopenableStreamBody body = new ReopenableStreamBody( () -> new ByteArrayInputStream(new byte[] {1}), OptionalLong.of(1), "application/octet-stream"); assertThat(body.replayability()).isEqualTo(BodyReplayability.REOPENABLE); } @Test void oneShotBodyRejectsNullStream() { assertThatThrownBy(() -> new OneShotStreamBody( null, OptionalLong.empty(), "application/octet-stream")) .isInstanceOf(NullPointerException.class); } } ``` - [ ] **Step 2: Run the failing test** ```bash ./gradlew :modules:httpclient:httpclient-core-api:test \ --tests '*BodyReplayabilityTest' ``` Expected: FAIL because body and response contracts do not exist. - [ ] **Step 3: Implement the sealed body and response contracts** ```java public sealed interface BodySource permits EmptyBody, ObjectBody, ByteArrayBody, ReopenableStreamBody, OneShotStreamBody { BodyReplayability replayability(); OptionalLong knownLength(); String mediaType(); } public record ReopenableStreamBody( IOSupplier opener, OptionalLong knownLength, String mediaType) implements BodySource { public ReopenableStreamBody { Objects.requireNonNull(opener); Objects.requireNonNull(knownLength); Objects.requireNonNull(mediaType); } @Override public BodyReplayability replayability() { return BodyReplayability.REOPENABLE; } } ``` Implement `ByteArrayBody` with a defensive copy and `BlockingStreamingResponse` with `status()`, `headers()`, `body()`, and `close()`. - [ ] **Step 4: Run the core body tests** ```bash ./gradlew :modules:httpclient:httpclient-core-api:test \ --tests '*BodyReplayabilityTest' ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-core-api git commit -m "feat: add replayable body and response contracts" ``` --- ### Task 4: HttpOperation과 HttpCallResult 구현 **Files:** - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/operation/HttpOperation.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/HttpCallResult.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/RemoteProblem.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/result/IdempotencyKeyRequirement.java` - Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/operation/HttpOperationTest.java` **Interfaces:** - Produces the immutable operation model consumed by H2/H3 and retry. - `IDEMPOTENCY_KEY_REQUIRED` cannot be built without a key. - [ ] **Step 1: Write failing operation invariant tests** ```java class HttpOperationTest { @Test void requiresIdempotencyKeyWhenPolicyRequiresIt() { assertThatThrownBy(() -> new HttpOperation( new OperationName("create-payment"), HttpMethod.POST, "/payments", Map.of(), Map.of(), new EmptyBody(), OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED, Optional.empty(), Optional.empty())) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("idempotency key"); } @Test void storesUriTemplateRatherThanExpandedUrl() { HttpOperation operation = HttpOperation.get( new OperationName("get-user"), "/users/{id}", Map.of("id", "42")); assertThat(operation.uriTemplate()).isEqualTo("/users/{id}"); } } ``` - [ ] **Step 2: Run the test and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-core-api:test \ --tests '*HttpOperationTest' ``` Expected: FAIL because `HttpOperation` and `HttpCallResult` are missing. - [ ] **Step 3: Implement immutable invariants** ```java public record HttpOperation( OperationName operationName, HttpMethod method, String uriTemplate, Map uriVariables, Map> headers, BodySource body, OperationIdempotency idempotency, Optional idempotencyKey, Optional deadline) { public HttpOperation { Objects.requireNonNull(operationName); Objects.requireNonNull(method); Objects.requireNonNull(uriTemplate); Objects.requireNonNull(body); if (idempotency == OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED && idempotencyKey.isEmpty()) { throw new IllegalArgumentException("idempotency key is required"); } uriVariables = Map.copyOf(uriVariables); headers = headers.entrySet().stream().collect(Collectors.toUnmodifiableMap( Map.Entry::getKey, entry -> List.copyOf(entry.getValue()))); } } ``` Implement `HttpCallResult` with immutable headers and `attempts >= 1` validation. - [ ] **Step 4: Run core operation tests** ```bash ./gradlew :modules:httpclient:httpclient-core-api:test \ --tests '*HttpOperationTest' ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-core-api git commit -m "feat: add immutable http operation result model" ``` --- ### Task 5: 안정 예외 계층과 실패 Metadata 구현 **Files:** - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpFailureMetadata.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpClientException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpConfigurationException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpTargetRejectedException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpDnsException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpPoolAcquireTimeoutException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpConnectException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpProxyException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpTlsException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpRequestWriteException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpResponseTimeoutException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpResponseTruncatedException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpRemoteErrorException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpProblemDetailException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpRedirectRejectedException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpAuthenticationException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpSerializationException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpResponseTooLargeException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpDeadlineExceededException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpCircuitOpenException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpBulkheadRejectedException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpRateLimitRejectedException.java` - Create: `modules/httpclient/httpclient-core-api/src/main/java/io/backend/skeleton/httpclient/api/error/HttpAmbiguousExecutionException.java` - Test: `modules/httpclient/httpclient-core-api/src/test/java/io/backend/skeleton/httpclient/api/error/StableExceptionTest.java` **Interfaces:** - Every public failure extends `HttpClientException` and exposes `metadata()`. - No exception message contains full URL, body, token, or idempotency key. - [ ] **Step 1: Write failing stable metadata and redaction tests** ```java class StableExceptionTest { @Test void ambiguousFailurePreservesEvidenceWithoutSecrets() { HttpFailureMetadata metadata = Fixtures.ambiguousMetadata(); HttpAmbiguousExecutionException exception = new HttpAmbiguousExecutionException("remote outcome is unknown", metadata); assertThat(exception.metadata().evidence()) .isEqualTo(ExecutionEvidence.SENT_NO_RESPONSE); assertThat(exception.getMessage()) .doesNotContain("Authorization", "secret", "https://payment.example.com/42"); } } ``` - [ ] **Step 2: Run the failing test** ```bash ./gradlew :modules:httpclient:httpclient-core-api:test \ --tests '*StableExceptionTest' ``` Expected: FAIL because the stable exception hierarchy does not exist. - [ ] **Step 3: Implement the root and typed subclasses** ```java public abstract class HttpClientException extends RuntimeException { private final HttpFailureMetadata metadata; protected HttpClientException(String safeMessage, HttpFailureMetadata metadata, Throwable cause) { super(safeMessage, cause); this.metadata = Objects.requireNonNull(metadata); } public final HttpFailureMetadata metadata() { return metadata; } } ``` Each concrete subclass has constructors `(String safeMessage, HttpFailureMetadata metadata)` and `(String safeMessage, HttpFailureMetadata metadata, Throwable cause)`. Do not include raw URI or body in any constructor formatting. - [ ] **Step 4: Run exception tests** ```bash ./gradlew :modules:httpclient:httpclient-core-api:test \ --tests '*StableExceptionTest' ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-core-api git commit -m "feat: add stable http client failures" ``` --- ### Task 6: Named Client Profile 모델과 startup validation 구현 **Files:** - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientMode.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/TransportType.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/HttpProtocol.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientApiType.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/PoolSettings.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/TimeoutSettings.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/RedirectSettings.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/RequestLimits.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ResponseLimits.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/AuthenticationSettings.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/RetrySettings.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientObservabilitySettings.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientProfile.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientProfileValidator.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientProfileViolation.java` - Test: `modules/httpclient/httpclient-profile/src/test/java/io/backend/skeleton/httpclient/profile/ClientProfileValidatorTest.java` **Interfaces:** - Produces immutable `ClientProfile` and `ClientProfileValidator.validate(profile, environment)`. - Later auto-configuration and transport tasks consume this exact profile model. - [ ] **Step 1: Write failing unsafe configuration tests** ```java class ClientProfileValidatorTest { private final ClientProfileValidator validator = new ClientProfileValidator(); @Test void rejectsPlainHttpInProduction() { ClientProfile profile = ClientProfiles.trusted("payment", URI.create("http://payment.test")); assertThat(validator.validate(profile, RuntimeEnvironment.PRODUCTION)) .extracting(ClientProfileViolation::code) .contains("PLAINTEXT_PRODUCTION_TARGET"); } @Test void rejectsDynamicCredentialInheritance() { ClientProfile profile = ClientProfiles.dynamicWithOAuth("webhook-checker"); assertThat(validator.validate(profile, RuntimeEnvironment.PRODUCTION)) .extracting(ClientProfileViolation::code) .contains("DYNAMIC_DEFAULT_CREDENTIAL_FORBIDDEN"); } @Test void rejectsTotalTimeoutShorterThanConnectBudget() { ClientProfile profile = ClientProfiles.withTimeouts( Duration.ofSeconds(2), Duration.ofMillis(500)); assertThat(validator.validate(profile, RuntimeEnvironment.PRODUCTION)) .extracting(ClientProfileViolation::code) .contains("INVALID_TIMEOUT_BUDGET"); } } ``` - [ ] **Step 2: Run the tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-profile:test \ --tests '*ClientProfileValidatorTest' ``` Expected: FAIL because the profile records and validator are missing. - [ ] **Step 3: Implement immutable settings and deterministic validation** ```java public record ClientProfile( ClientProfileName name, ClientMode mode, URI baseUrl, Set allowedHosts, Set allowedPorts, ClientApiType api, TransportType transport, Set protocols, PoolSettings pool, TimeoutSettings timeout, RedirectSettings redirect, RequestLimits request, ResponseLimits response, AuthenticationSettings authentication, RetrySettings retry, ClientObservabilitySettings observability) { } ``` `ClientProfileValidator` must emit stable violation codes for every startup guard in the design: base URL, userinfo, allowed host/port, production plaintext, Dynamic credential, HTTP/3 Stable, Simple factory, timeout relationships, hard size maximum, redirect policy, and unsafe POST retry. - [ ] **Step 4: Run the profile tests** ```bash ./gradlew :modules:httpclient:httpclient-profile:test ``` Expected: PASS; violation order is deterministic and sorted by code. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-profile git commit -m "feat: add named http client profiles" ``` --- ### Task 7: Immutable ClientRuntime Registry와 generation 교체 구현 **Files:** - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntime.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntimeState.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntimeFactory.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntimeRegistry.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ClientRuntimeLease.java` - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/RuntimeGeneration.java` - Test: `modules/httpclient/httpclient-profile/src/test/java/io/backend/skeleton/httpclient/profile/ClientRuntimeRegistryTest.java` **Interfaces:** - Produces `ClientRuntimeRegistry.acquire(ClientProfileName)` returning `ClientRuntimeLease`. - Produces `swap(profileName, newRuntime, drainTimeout)` for secret, certificate, pool, or endpoint rotation. - [ ] **Step 1: Write failing atomic swap and drain tests** ```java class ClientRuntimeRegistryTest { @Test void newCallsUseNewGenerationWhileOldCallDrains() { ClientRuntime first = FakeRuntime.running(1); ClientRuntime second = FakeRuntime.running(2); ClientRuntimeRegistry registry = new ClientRuntimeRegistry(Map.of(first.name(), first)); ClientRuntimeLease oldLease = registry.acquire(first.name()); registry.swap(first.name(), second, Duration.ofSeconds(1)); try (ClientRuntimeLease newLease = registry.acquire(first.name())) { assertThat(newLease.runtime().generation().value()).isEqualTo(2); } assertThat(first.state()).isEqualTo(ClientRuntimeState.DRAINING); oldLease.close(); assertThat(first.state()).isEqualTo(ClientRuntimeState.CLOSED); } } ``` - [ ] **Step 2: Run the test and verify failure** ```bash ./gradlew :modules:httpclient:httpclient-profile:test \ --tests '*ClientRuntimeRegistryTest' ``` Expected: FAIL because runtime lifecycle types are absent. - [ ] **Step 3: Implement reference-counted runtime generations** ```java public final class ClientRuntimeRegistry { private final ConcurrentMap> runtimes; public ClientRuntimeLease acquire(ClientProfileName name) { ClientRuntime runtime = requireRuntime(name); if (!runtime.tryAcquire()) { return acquire(name); } return new ClientRuntimeLease(runtime, runtime::release); } public void swap(ClientProfileName name, ClientRuntime replacement, Duration drainTimeout) { ClientRuntime previous = runtimes.get(name).getAndSet(replacement); previous.beginDrain(drainTimeout); } } ``` `ClientRuntime` closes immediately after the last lease when draining, and forcibly closes at drain timeout. It rejects new retry attempts after state becomes `DRAINING`. - [ ] **Step 4: Run runtime lifecycle tests** ```bash ./gradlew :modules:httpclient:httpclient-profile:test \ --tests '*ClientRuntimeRegistryTest' ``` Expected: PASS with no leaked scheduled executor thread. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-profile git commit -m "feat: add immutable client runtime generations" ``` --- ### Task 8: Blocking·Reactive Transport SPI와 capability validation 구현 **Files:** - Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportId.java` - Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/BlockingTransportProvider.java` - Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/ReactiveTransportProvider.java` - Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/BlockingTransportCapabilities.java` - Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/ReactiveTransportCapabilities.java` - Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportFailureClassifier.java` - Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportLifecycleListener.java` - Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportCapabilityValidator.java` - Create: `modules/httpclient/httpclient-transport-spi/src/main/java/io/backend/skeleton/httpclient/transport/TransportFailure.java` - Test: `modules/httpclient/httpclient-transport-spi/src/test/java/io/backend/skeleton/httpclient/transport/TransportCapabilityValidatorTest.java` **Interfaces:** - Blocking provider produces Spring `ClientHttpRequestFactory`. - Reactive provider produces Spring `ClientHttpConnector`. - Public application modules never receive native engine clients. - [ ] **Step 1: Write failing capability mismatch tests** ```java class TransportCapabilityValidatorTest { @Test void rejectsHttp3OnNonHttp3Provider() { ClientProfile profile = ClientProfiles.http3Experimental("edge"); BlockingTransportCapabilities capabilities = BlockingTransportCapabilities.http11AndHttp2(); assertThatThrownBy(() -> new TransportCapabilityValidator() .validate(profile, capabilities)) .isInstanceOf(HttpConfigurationException.class) .hasMessageContaining("HTTP_3"); } } ``` - [ ] **Step 2: Run the SPI tests and verify failure** ```bash ./gradlew :modules:httpclient:httpclient-transport-spi:test \ --tests '*TransportCapabilityValidatorTest' ``` Expected: FAIL because provider and capability contracts are missing. - [ ] **Step 3: Implement the provider contracts** ```java public interface BlockingTransportProvider { TransportId id(); BlockingTransportCapabilities capabilities(); ClientHttpRequestFactory create( ClientProfile profile, TransportLifecycleListener listener); TransportFailureClassifier failureClassifier(); } public interface TransportFailureClassifier { TransportFailure classify(Throwable failure, AttemptStage lastObservedStage); } ``` `TransportCapabilityValidator` checks protocol, proxy, mTLS, route pool, pending queue, DNS pinning, and dynamic target capability. Error messages use profile and capability names only. - [ ] **Step 4: Run the SPI tests** ```bash ./gradlew :modules:httpclient:httpclient-transport-spi:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-transport-spi git commit -m "feat: define http transport provider spi" ``` --- ### Task 9: HTTP Client Testkit 기반 구성 **Files:** - Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/MockHttpServer.java` - Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/RecordedHttpRequest.java` - Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/HttpClientContract.java` - Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/TlsFixture.java` - Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/ProxyFixture.java` - Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/OAuth2Fixture.java` - Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/ToxiproxyFixture.java` - Create: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/MockHttpServerTest.java` - Create: `infra/httpclient/toxiproxy/compose.yaml` **Interfaces:** - Produces deterministic HTTP/1.1 fixtures used from Task 13 onward. - Later tasks extend the testkit with HTTP/2, TLS, OAuth2, proxy, and network failure behavior. - [ ] **Step 1: Write a failing server recording test** ```java class MockHttpServerTest { @Test void recordsMethodPathHeadersAndBody() throws Exception { try (MockHttpServer server = MockHttpServer.start()) { server.enqueueJson(200, "{\"ok\":true}"); HttpURLConnection connection = (HttpURLConnection) server.uri("/items/42").toURL().openConnection(); connection.setRequestMethod("POST"); connection.setDoOutput(true); connection.setRequestProperty("X-Test", "value"); connection.getOutputStream().write("body".getBytes(UTF_8)); assertThat(connection.getResponseCode()).isEqualTo(200); RecordedHttpRequest request = server.takeRequest(Duration.ofSeconds(1)); assertThat(request.method()).isEqualTo("POST"); assertThat(request.path()).isEqualTo("/items/42"); assertThat(request.firstHeader("X-Test")).contains("value"); assertThat(request.bodyUtf8()).isEqualTo("body"); } } } ``` - [ ] **Step 2: Run the test and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-testkit:test \ --tests '*MockHttpServerTest' ``` Expected: FAIL because the fixture classes are missing. - [ ] **Step 3: Implement MockWebServer-backed fixtures** ```java public final class MockHttpServer implements AutoCloseable { private final MockWebServer server; public static MockHttpServer start() throws IOException { MockWebServer delegate = new MockWebServer(); delegate.start(); return new MockHttpServer(delegate); } public void enqueueJson(int status, String body) { server.enqueue(new MockResponse() .setResponseCode(status) .setHeader("Content-Type", "application/json") .setBody(body)); } } ``` Implement `takeRequest` with a finite timeout and immutable header/body copies. Add Testcontainers and Toxiproxy dependencies only to `httpclient-testkit`. - [ ] **Step 4: Run the testkit suite** ```bash ./gradlew :modules:httpclient:httpclient-testkit:test ``` Expected: PASS and no listening socket remains after the test. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-testkit infra/httpclient/toxiproxy git commit -m "test: add http client contract fixtures" ``` --- ### Task 10: Effective Deadline과 단계별 시간 예산 구현 **Files:** - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/Deadline.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/DeadlineCalculator.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptBudget.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptBudgetCalculator.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/DeadlineGuard.java` - Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/DeadlineCalculatorTest.java` **Interfaces:** - Produces `DeadlineCalculator.effective(parent, totalCall, clock)`. - Produces `AttemptBudgetCalculator.nextAttempt(deadline, backoff, minimumAttempt, cleanupReserve)`. - [ ] **Step 1: Write failing parent deadline and backoff tests** ```java class DeadlineCalculatorTest { private final Clock clock = Clock.fixed(Instant.parse("2026-08-08T00:00:00Z"), UTC); @Test void usesShorterParentDeadline() { Deadline deadline = new DeadlineCalculator().effective( Optional.of(Instant.parse("2026-08-08T00:00:02Z")), Duration.ofSeconds(5), clock); assertThat(deadline.at()).isEqualTo(Instant.parse("2026-08-08T00:00:02Z")); } @Test void refusesAttemptWhenBackoffConsumesRemainingBudget() { Deadline deadline = new Deadline(Instant.parse("2026-08-08T00:00:01Z")); Optional result = new AttemptBudgetCalculator(clock) .nextAttempt(deadline, Duration.ofMillis(700), Duration.ofMillis(250), Duration.ofMillis(100)); assertThat(result).isEmpty(); } } ``` - [ ] **Step 2: Run the test and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-resilience:test \ --tests '*DeadlineCalculatorTest' ``` Expected: FAIL because deadline types are absent. - [ ] **Step 3: Implement monotonic budget calculations** ```java public final class DeadlineCalculator { public Deadline effective(Optional parent, Duration totalCall, Clock clock) { Instant local = clock.instant().plus(totalCall); return new Deadline(parent.map(p -> p.isBefore(local) ? p : local).orElse(local)); } } ``` `AttemptBudgetCalculator` subtracts backoff, minimum attempt duration, and cleanup reserve. It never returns a negative duration and `DeadlineGuard` throws `HttpDeadlineExceededException` before a new attempt starts. - [ ] **Step 4: Run deadline tests** ```bash ./gradlew :modules:httpclient:httpclient-resilience:test \ --tests '*DeadlineCalculatorTest' ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-resilience git commit -m "feat: enforce end to end http deadlines" ``` --- ### Task 11: Trusted URI, Header ownership, Body limit 정책 구현 **Files:** - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TrustedTargetPolicy.java` - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/UriTemplateExpander.java` - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/HeaderPolicy.java` - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/BodyLimitPolicy.java` - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/RedirectPolicy.java` - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/PreparedTarget.java` - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/PreparedOperation.java` - Test: `modules/httpclient/httpclient-security/src/test/java/io/backend/skeleton/httpclient/security/TrustedRequestPolicyTest.java` **Interfaces:** - Produces a `PreparedOperation` with canonical target, sanitized headers, and hard size budgets. - H2 cannot supply an absolute URI. - [ ] **Step 1: Write failing absolute URI, CRLF, and body size tests** ```java class TrustedRequestPolicyTest { @Test void rejectsAbsoluteUriInTrustedGenericGateway() { TrustedTargetPolicy policy = Policies.payment(); assertThatThrownBy(() -> policy.prepare(OperationFixtures.absoluteTarget())) .isInstanceOf(HttpTargetRejectedException.class); } @Test void rejectsHeaderInjection() { HeaderPolicy policy = HeaderPolicy.defaultPolicy(); assertThatThrownBy(() -> policy.validate(Map.of("X-Test", List.of("ok\r\nBad: x")))) .isInstanceOf(HttpTargetRejectedException.class); } @Test void rejectsKnownBodyLargerThanProfileLimit() { assertThatThrownBy(() -> BodyLimitPolicy.maxRequestBytes(4) .validate(new ByteArrayBody(new byte[5], "application/octet-stream"))) .isInstanceOf(HttpConfigurationException.class); } } ``` - [ ] **Step 2: Run the failing security tests** ```bash ./gradlew :modules:httpclient:httpclient-security:test \ --tests '*TrustedRequestPolicyTest' ``` Expected: FAIL because the request policy pipeline is missing. - [ ] **Step 3: Implement strict preparation rules** ```java public final class HeaderPolicy { private static final Set PLATFORM_OWNED = Set.of( "authorization", "proxy-authorization", "host", "content-length", "transfer-encoding", "traceparent", "tracestate", "baggage", "cookie"); public Map> validate(Map> input) { input.forEach((name, values) -> { if (name.indexOf('\r') >= 0 || name.indexOf('\n') >= 0) reject(name); values.forEach(value -> { if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0) reject(name); }); if (PLATFORM_OWNED.contains(name.toLowerCase(Locale.ROOT))) reject(name); }); return immutableCopy(input); } } ``` `UriTemplateExpander` uses Spring URI components in this integration module, encodes path and query components separately, and records the original template for observability. - [ ] **Step 4: Run security policy tests** ```bash ./gradlew :modules:httpclient:httpclient-security:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-security git commit -m "feat: enforce trusted http request policy" ``` --- ### Task 12: Low-cardinality 관측성과 Redaction primitive 구현 **Files:** - Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/HttpClientObservationNames.java` - Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/LogicalCallObservation.java` - Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/AttemptObservation.java` - Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/HttpClientTagPolicy.java` - Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/SensitiveValueRedactor.java` - Create: `modules/httpclient/httpclient-observability/src/main/java/io/backend/skeleton/httpclient/observation/SafeHttpLogEvent.java` - Test: `modules/httpclient/httpclient-observability/src/test/java/io/backend/skeleton/httpclient/observation/HttpClientTagPolicyTest.java` **Interfaces:** - Produces standard low-cardinality tags consumed by RestClient, WebClient, Retry, Auth, and Dynamic modules. - Rejects full URL and arbitrary labels rather than silently accepting them. - [ ] **Step 1: Write failing forbidden tag and redaction tests** ```java class HttpClientTagPolicyTest { @Test void rejectsFullUrlAsLowCardinalityTag() { HttpClientTagPolicy policy = HttpClientTagPolicy.standard(); assertThatThrownBy(() -> policy.tag("url", "https://api.test/users/42?q=secret")) .isInstanceOf(IllegalArgumentException.class); } @Test void redactsCredentialsAndQueryValues() { SensitiveValueRedactor redactor = SensitiveValueRedactor.standard(); assertThat(redactor.header("Authorization", "Bearer abc")).isEqualTo("[REDACTED]"); assertThat(redactor.uri(URI.create("https://api.test/a?q=secret")).toString()) .isEqualTo("https://api.test/a"); } } ``` - [ ] **Step 2: Run observability tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-observability:test \ --tests '*HttpClientTagPolicyTest' ``` Expected: FAIL because tag policy and redactor are missing. - [ ] **Step 3: Implement bounded vocabularies and safe events** ```java public final class HttpClientTagPolicy { private static final Set ALLOWED = Set.of( "clientName", "operationName", "method", "uriTemplate", "status", "outcome", "transport", "protocol", "timeoutType", "retryReason", "evidence", "circuitState"); public KeyValue tag(String name, String value) { if (!ALLOWED.contains(name)) { throw new IllegalArgumentException("forbidden low-cardinality tag: " + name); } return KeyValue.of(name, value); } } ``` `SafeHttpLogEvent` stores profile, operation, template, status, evidence, stage, attempt, elapsed, and trace ID only. It has no fields for body, authorization, Cookie, query, or expanded URL. - [ ] **Step 4: Run observability tests** ```bash ./gradlew :modules:httpclient:httpclient-observability:test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-observability git commit -m "feat: add safe http client observability" ``` --- ### Task 13: Apache HttpClient 5 Blocking Transport 구현 **Files:** - Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheBlockingTransportProvider.java` - Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheClientFactory.java` - Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheFailureClassifier.java` - Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApachePoolMetricsBinder.java` - Create: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheDnsResolverFactory.java` - Create: `modules/httpclient/httpclient-transport-apache/src/test/java/io/backend/skeleton/httpclient/apache/ApacheBlockingTransportProviderTest.java` - Create: `modules/httpclient/httpclient-transport-apache/src/test/java/io/backend/skeleton/httpclient/apache/ApachePoolSaturationTest.java` **Interfaces:** - Implements `BlockingTransportProvider` with ID `apache`. - Supports route pool, pending acquire, proxy, custom TLS, HTTP/1.1·2, validated DNS resolver. - [ ] **Step 1: Write failing pool and request contract tests** ```java class ApacheBlockingTransportProviderTest { @Test void sendsRequestThroughConfiguredFactory() throws Exception { try (MockHttpServer server = MockHttpServer.start()) { server.enqueueJson(200, "{\"value\":1}"); ClientProfile profile = ClientProfiles.apache(server.uri("/")); ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider(); ClientHttpRequestFactory factory = provider.create(profile, NoopLifecycleListener.INSTANCE); RestClient client = RestClient.builder().requestFactory(factory).build(); String body = client.get().uri(server.uri("/value")).retrieve().body(String.class); assertThat(body).contains("value"); } } } class ApachePoolSaturationTest { @Test void poolAcquireTimeoutIsClassifiedAsNotSent() { // server holds the first response; second request must exhaust a one-connection pool TransportFailure failure = ApacheFixtures.saturateAndCaptureFailure(); assertThat(failure.stage()).isEqualTo(AttemptStage.POOL_ACQUIRE); assertThat(failure.evidence()).isEqualTo(ExecutionEvidence.NOT_SENT); } } ``` - [ ] **Step 2: Run Apache transport tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-transport-apache:test \ --tests '*ApacheBlockingTransportProviderTest' \ --tests '*ApachePoolSaturationTest' ``` Expected: FAIL because the provider does not exist. - [ ] **Step 3: Implement Apache pool, lifecycle, and failure classification** ```java public final class ApacheBlockingTransportProvider implements BlockingTransportProvider { @Override public TransportId id() { return new TransportId("apache"); } @Override public ClientHttpRequestFactory create(ClientProfile profile, TransportLifecycleListener listener) { CloseableHttpClient client = new ApacheClientFactory().create(profile, listener); HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(client); factory.setConnectionRequestTimeout(profile.pool().pendingAcquireTimeout()); factory.setConnectTimeout(profile.timeout().connect()); return factory; } } ``` `ApacheClientFactory` creates a `PoolingHttpClientConnectionManager` with total·route limits, connection lifetime, validation after inactivity, idle eviction, proxy, TLS strategy, and profile-scoped DNS resolver. `ApacheFailureClassifier` maps pool timeout to `NOT_SENT`, connect and pre-request TLS failures to `NOT_SENT`, and request write or response timeout to conservative `SENT_NO_RESPONSE`. - [ ] **Step 4: Run Apache transport and pool tests** ```bash ./gradlew :modules:httpclient:httpclient-transport-apache:test ``` Expected: PASS; after every test the connection manager reports zero leased connections. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-transport-apache git commit -m "feat: add apache blocking http transport" ``` --- ### Task 14: JDK HttpClient Blocking Transport 구현 **Files:** - Create: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkBlockingTransportProvider.java` - Create: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkClientFactory.java` - Create: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkFailureClassifier.java` - Create: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkTransportCapabilityPolicy.java` - Test: `modules/httpclient/httpclient-transport-jdk/src/test/java/io/backend/skeleton/httpclient/jdk/JdkBlockingTransportProviderTest.java` - Test: `modules/httpclient/httpclient-transport-jdk/src/test/java/io/backend/skeleton/httpclient/jdk/JdkTransportCapabilityPolicyTest.java` **Interfaces:** - Implements `BlockingTransportProvider` with ID `jdk`. - Rejects profiles that require route-level pool, bounded pending queue, or Dynamic Target DNS pinning. - [ ] **Step 1: Write failing request and capability tests** ```java class JdkTransportCapabilityPolicyTest { @Test void rejectsFineGrainedRoutePoolRequirement() { ClientProfile profile = ClientProfiles.requiresRoutePool("inventory"); assertThatThrownBy(() -> new JdkTransportCapabilityPolicy().validate(profile)) .isInstanceOf(HttpConfigurationException.class) .hasMessageContaining("route pool"); } } class JdkBlockingTransportProviderTest { @Test void performsHttp2CapableBlockingRequest() throws Exception { try (MockHttpServer server = MockHttpServer.start()) { server.enqueueJson(200, "{\"ok\":true}"); ClientProfile profile = ClientProfiles.jdk(server.uri("/")); ClientHttpRequestFactory factory = new JdkBlockingTransportProvider() .create(profile, NoopLifecycleListener.INSTANCE); String body = RestClient.builder().requestFactory(factory).build() .get().uri(server.uri("/ok")).retrieve().body(String.class); assertThat(body).contains("ok"); } } } ``` - [ ] **Step 2: Run tests and verify failure** ```bash ./gradlew :modules:httpclient:httpclient-transport-jdk:test ``` Expected: FAIL because JDK transport classes are absent. - [ ] **Step 3: Implement JDK transport with conservative capabilities** ```java public final class JdkClientFactory { public java.net.http.HttpClient create(ClientProfile profile) { return java.net.http.HttpClient.newBuilder() .connectTimeout(profile.timeout().connect()) .followRedirects(HttpClient.Redirect.NEVER) .version(profile.protocols().contains(HttpProtocol.HTTP_2) ? HttpClient.Version.HTTP_2 : HttpClient.Version.HTTP_1_1) .sslContext(JdkTlsSupport.sslContext(profile)) .build(); } } ``` Wrap it with Spring `JdkClientHttpRequestFactory`, set response read timeout, and classify `HttpConnectTimeoutException` as `NOT_SENT`. Other generic I/O failures after request creation remain conservative. - [ ] **Step 4: Run JDK transport tests** ```bash ./gradlew :modules:httpclient:httpclient-transport-jdk:test ``` Expected: PASS; unsupported capability profiles fail before a network call. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-transport-jdk git commit -m "feat: add jdk blocking http transport" ``` --- ### Task 15: RestClient Runtime과 H2 Generic Blocking Gateway 구현 **Files:** - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/GenericHttpGateway.java` - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/DefaultGenericHttpGateway.java` - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/RestClientRuntimeFactory.java` - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingAttemptExecutor.java` - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/RestClientBodyWriter.java` - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/RestClientResponseReader.java` - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingOperationContext.java` - Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/DefaultGenericHttpGatewayTest.java` **Interfaces:** - Produces ` HttpCallResult exchange(ClientProfileName, HttpOperation, ResponseType)`. - Uses only registered profile-relative URI templates. - [ ] **Step 1: Write a failing end-to-end Generic Gateway test** ```java class DefaultGenericHttpGatewayTest { @Test void expandsRelativeTemplateAndReturnsTypedResult() throws Exception { try (MockHttpServer server = MockHttpServer.start()) { server.enqueueJson(200, "{\"id\":42}"); GenericHttpGateway gateway = TestGateways.apache(server.uri("/")); HttpOperation operation = HttpOperation.get( new OperationName("get-user"), "/users/{id}", Map.of("id", 42)); HttpCallResult result = gateway.exchange( new ClientProfileName("users"), operation, ResponseType.of(UserResponse.class)); assertThat(result.status().value()).isEqualTo(200); assertThat(result.body().id()).isEqualTo(42); assertThat(server.takeRequest(Duration.ofSeconds(1)).path()) .isEqualTo("/users/42"); } } } ``` - [ ] **Step 2: Run the gateway test and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-restclient:test \ --tests '*DefaultGenericHttpGatewayTest' ``` Expected: FAIL because the gateway and runtime factory are missing. - [ ] **Step 3: Implement the blocking gateway pipeline skeleton** ```java public final class DefaultGenericHttpGateway implements GenericHttpGateway { private final ClientRuntimeRegistry runtimes; private final TrustedTargetPolicy targetPolicy; private final BlockingAttemptExecutor executor; @Override public HttpCallResult exchange(ClientProfileName profileName, HttpOperation operation, ResponseType responseType) { try (ClientRuntimeLease lease = runtimes.acquire(profileName)) { PreparedOperation prepared = targetPolicy.prepare( lease.runtime().profile(), operation); return executor.execute(lease.runtime(), prepared, responseType); } } } ``` `RestClientRuntimeFactory` selects Apache or JDK provider, constructs an immutable RestClient, registers platform-owned interceptors, and stores the provider failure classifier in `ClientRuntime`. - [ ] **Step 4: Run gateway tests with both blocking transports** ```bash ./gradlew :modules:httpclient:httpclient-restclient:test \ -Phttpclient.contract.transports=apache,jdk ``` Expected: PASS for Apache and JDK contract variants. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-restclient git commit -m "feat: add generic blocking http gateway" ``` --- ### Task 16: H1 Blocking Typed Service Client Registry 구현 **Files:** - Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/HttpServiceRegistry.java` - Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/DefaultHttpServiceRegistry.java` - Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/HttpClientProfile.java` - Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/HttpOperationPolicy.java` - Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ServiceOperationDescriptor.java` - Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ServiceOperationDescriptorScanner.java` - Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/BlockingServiceInvocationHandler.java` - Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/OperationContextHolder.java` - Test: `modules/httpclient/httpclient-service-client/src/test/java/io/backend/skeleton/httpclient/service/BlockingHttpServiceRegistryTest.java` - Test: `modules/httpclient/httpclient-service-client/src/test/java/io/backend/skeleton/httpclient/service/ServiceSignatureValidationTest.java` **Interfaces:** - Produces ` T client(ClientProfileName, Class)`. - Operation descriptors use exact `operationName`, idempotency, retry policy, timeout policy, and streaming flag. - [ ] **Step 1: Write failing proxy and signature validation tests** ```java @HttpClientProfile("users") @HttpExchange("/users") interface UsersClient { @GetExchange("/{id}") @HttpOperationPolicy(name = "get-user", idempotency = OperationIdempotency.STANDARD_IDEMPOTENT) UserResponse get(@PathVariable long id); } class BlockingHttpServiceRegistryTest { @Test void createsTypedProxyBoundToNamedProfile() throws Exception { try (MockHttpServer server = MockHttpServer.start()) { server.enqueueJson(200, "{\"id\":7}"); HttpServiceRegistry registry = TestServiceRegistries.apache(server.uri("/")); assertThat(registry.client(new ClientProfileName("users"), UsersClient.class) .get(7).id()).isEqualTo(7); } } } class ServiceSignatureValidationTest { @Test void rejectsPostWithoutOperationPolicy() { assertThatThrownBy(() -> new ServiceOperationDescriptorScanner() .scan(InvalidPostClient.class)) .isInstanceOf(HttpConfigurationException.class); } } ``` - [ ] **Step 2: Run service client tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-service-client:test \ --tests '*BlockingHttpServiceRegistryTest' \ --tests '*ServiceSignatureValidationTest' ``` Expected: FAIL because annotations, scanner, and registry are missing. - [ ] **Step 3: Implement descriptor scanning and wrapper proxy** ```java public final class DefaultHttpServiceRegistry implements HttpServiceRegistry { @Override public T client(ClientProfileName profileName, Class serviceType) { List descriptors = scanner.scan(serviceType); Object springProxy = proxyFactory.create(profileName, serviceType); InvocationHandler handler = new BlockingServiceInvocationHandler( springProxy, descriptors, OperationContextHolder.instance()); return serviceType.cast(Proxy.newProxyInstance( serviceType.getClassLoader(), new Class[] {serviceType}, handler)); } } ``` The invocation handler sets the descriptor in a ThreadLocal only for the synchronous call and removes it in `finally`. Principal and user token are never loaded implicitly from this context. - [ ] **Step 4: Run blocking typed client tests** ```bash ./gradlew :modules:httpclient:httpclient-service-client:test \ -Phttpclient.contract.transports=apache,jdk ``` Expected: PASS; operation context is empty after successful and failed invocations. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-service-client git commit -m "feat: add typed blocking http service clients" ``` --- ### Task 17: Attempt progress와 Execution Evidence 분류 구현 **Files:** - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptProgress.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptProgressTracker.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ExecutionEvidenceClassifier.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/DefaultExecutionEvidenceClassifier.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ProtocolEvidence.java` - Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/ExecutionEvidenceClassifierTest.java` **Interfaces:** - Produces evidence from last observed stage, byte progress, response header, and optional protocol evidence. - Never guesses `NOT_SENT` after request write begins. - [ ] **Step 1: Write failing conservative classification tests** ```java class ExecutionEvidenceClassifierTest { private final ExecutionEvidenceClassifier classifier = new DefaultExecutionEvidenceClassifier(); @Test void poolTimeoutIsNotSent() { AttemptProgress progress = AttemptProgress.failedAt(AttemptStage.POOL_ACQUIRE); assertThat(classifier.classify(progress, ProtocolEvidence.none())) .isEqualTo(ExecutionEvidence.NOT_SENT); } @Test void responseHeaderTimeoutAfterBodyWriteIsAmbiguous() { AttemptProgress progress = new AttemptProgress( AttemptStage.RESPONSE_HEADERS, true, 128, false, 0, false); assertThat(classifier.classify(progress, ProtocolEvidence.none())) .isEqualTo(ExecutionEvidence.SENT_NO_RESPONSE); } @Test void emittedBodyByteIsPartialResponse() { AttemptProgress progress = new AttemptProgress( AttemptStage.RESPONSE_BODY, true, 0, true, 64, true); assertThat(classifier.classify(progress, ProtocolEvidence.none())) .isEqualTo(ExecutionEvidence.PARTIAL_RESPONSE); } } ``` - [ ] **Step 2: Run tests and verify failure** ```bash ./gradlew :modules:httpclient:httpclient-resilience:test \ --tests '*ExecutionEvidenceClassifierTest' ``` Expected: FAIL because progress and classifier types are missing. - [ ] **Step 3: Implement stage monotonicity and conservative evidence rules** ```java public final class DefaultExecutionEvidenceClassifier implements ExecutionEvidenceClassifier { @Override public ExecutionEvidence classify(AttemptProgress p, ProtocolEvidence protocol) { if (protocol.peerDidNotProcess()) return ExecutionEvidence.NOT_SENT; if (p.responseBytesDelivered() > 0 || p.firstByteDelivered()) return ExecutionEvidence.PARTIAL_RESPONSE; if (p.responseHeadersReceived()) return ExecutionEvidence.RESPONSE_RECEIVED; if (p.requestWriteStarted()) return ExecutionEvidence.SENT_NO_RESPONSE; return switch (p.stage()) { case VALIDATION, AUTHENTICATION, POOL_ACQUIRE, DNS, CONNECT, TLS_HANDSHAKE, PROXY_CONNECT -> ExecutionEvidence.NOT_SENT; default -> ExecutionEvidence.SENT_NO_RESPONSE; }; } } ``` `AttemptProgressTracker` forbids stage regression and records first-byte delivery exactly once. - [ ] **Step 4: Run evidence tests** ```bash ./gradlew :modules:httpclient:httpclient-resilience:test \ --tests '*ExecutionEvidenceClassifierTest' ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-resilience git commit -m "feat: classify http execution evidence" ``` --- ### Task 18: HTTP-specific Retry Eligibility Engine 구현 **Files:** - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryContext.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryDecision.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryAllowed.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryDenied.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AmbiguousFailure.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryEligibilityEngine.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/DefaultRetryEligibilityEngine.java` - Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/RetryEligibilityEngineTest.java` **Interfaces:** - Produces a pure deterministic decision without sleeping or issuing requests. - Consumes idempotency, key presence, replayability, evidence, status, failure, deadline, attempt, and budget. - [ ] **Step 1: Write failing safety matrix tests** ```java class RetryEligibilityEngineTest { private final RetryEligibilityEngine engine = new DefaultRetryEligibilityEngine(); @Test void allowsGetAfterConnectFailure() { assertThat(engine.decide(RetryContexts.getConnectFailure())) .isInstanceOf(RetryAllowed.class); } @Test void marksPostWithoutKeyAmbiguousAfterSend() { assertThat(engine.decide(RetryContexts.postSentNoResponseWithoutKey())) .isInstanceOf(AmbiguousFailure.class); } @Test void deniesOneShotBodyEvenForPut() { assertThat(engine.decide(RetryContexts.putOneShotNotSent())) .isInstanceOf(RetryDenied.class); } @Test void honorsRetryAfterOnlyInsideDeadline() { assertThat(engine.decide(RetryContexts.rateLimitedBeyondDeadline())) .isInstanceOf(RetryDenied.class); } } ``` - [ ] **Step 2: Run tests and verify failure** ```bash ./gradlew :modules:httpclient:httpclient-resilience:test \ --tests '*RetryEligibilityEngineTest' ``` Expected: FAIL because retry decision types are absent. - [ ] **Step 3: Implement the complete ordered decision table** ```java public final class DefaultRetryEligibilityEngine implements RetryEligibilityEngine { @Override public RetryDecision decide(RetryContext c) { if (c.attempt() >= c.maxAttempts()) return RetryDenied.maxAttempts(); if (!c.budget().available()) return RetryDenied.budgetExhausted(); if (!c.replayability().canReplay()) return RetryDenied.bodyNotReplayable(); if (c.firstByteDelivered()) return RetryDenied.responseAlreadyDelivered(); if (c.remainingDeadline().compareTo(c.minimumAttemptBudget()) <= 0) return RetryDenied.deadline(); if (c.evidence() == ExecutionEvidence.SENT_NO_RESPONSE && !isSafelyIdempotent(c)) { return AmbiguousFailure.remoteOutcomeUnknown(); } return statusOrFailureDecision(c); } } ``` Implement explicit branches for 408, 425, 429, 500, 502, 503, 504, 401-refresh-once, TLS permanent errors, pool/DNS/connect errors, response truncation, and `Retry-After`. - [ ] **Step 4: Run retry eligibility tests** ```bash ./gradlew :modules:httpclient:httpclient-resilience:test \ --tests '*RetryEligibilityEngineTest' ``` Expected: PASS; test parameterization covers all documented status and evidence combinations. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-resilience git commit -m "feat: decide safe http retries" ``` --- ### Task 19: Retry Coordinator, Backoff, Jitter, Retry Budget 구현 **Files:** - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryCoordinator.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/BlockingRetryCoordinator.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/BackoffStrategy.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ExponentialFullJitterBackoff.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/RetryBudget.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/TokenBucketRetryBudget.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/Sleeper.java` - Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/BlockingRetryCoordinatorTest.java` - Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/RetryBudgetTest.java` **Interfaces:** - Produces a blocking coordinator used by RestClient. - Later reactive task implements the same semantic without blocking sleep. - [ ] **Step 1: Write failing attempt-count, backoff, and budget tests** ```java class BlockingRetryCoordinatorTest { @Test void retriesOnceThenReturnsSuccessWithoutHoldingAttemptResourcesDuringBackoff() { FakeAttemptExecutor executor = FakeAttemptExecutor.failThenSucceed(); RecordingSleeper sleeper = new RecordingSleeper(); BlockingRetryCoordinator coordinator = Coordinators.blocking(executor, sleeper); HttpCallResult result = coordinator.execute(RetryFixtures.safeGet()); assertThat(result.attempts()).isEqualTo(2); assertThat(sleeper.durations()).hasSize(1); assertThat(executor.activeResourcesDuringSleep()).isZero(); } } class RetryBudgetTest { @Test void rejectsRetryWhenTokensAreExhausted() { RetryBudget budget = new TokenBucketRetryBudget(1, Duration.ofMinutes(1), Clock.systemUTC()); assertThat(budget.tryConsume()).isTrue(); assertThat(budget.tryConsume()).isFalse(); } } ``` - [ ] **Step 2: Run tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-resilience:test \ --tests '*BlockingRetryCoordinatorTest' \ --tests '*RetryBudgetTest' ``` Expected: FAIL because coordinator and budget are missing. - [ ] **Step 3: Implement coordinator around physical attempts** ```java public final class BlockingRetryCoordinator implements RetryCoordinator { public HttpCallResult execute(BlockingLogicalCall call) { for (int attempt = 1; ; attempt++) { AttemptOutcome outcome = call.attempt(attempt); RetryDecision decision = eligibility.decide(call.context(outcome, attempt)); if (decision instanceof RetryAllowed allowed) { if (!budget.tryConsume()) throw call.retryExhausted(attempt); sleeper.sleep(backoff.delay(attempt, allowed.retryAfter(), call.deadline())); continue; } if (decision instanceof AmbiguousFailure) throw call.ambiguous(outcome, attempt); return call.finish(outcome, attempt); } } } ``` Use an injectable `Sleeper` and `RandomGenerator` for deterministic tests. Never sleep past the effective deadline. - [ ] **Step 4: Run coordinator and budget tests** ```bash ./gradlew :modules:httpclient:httpclient-resilience:test \ --tests '*BlockingRetryCoordinatorTest' \ --tests '*RetryBudgetTest' ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-resilience git commit -m "feat: coordinate bounded http retries" ``` --- ### Task 20: Circuit Breaker·Rate Limiter·Bulkhead 물리 시도 Pipeline 구현 **Files:** - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptResiliencePipeline.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ResilienceRegistry.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/LogicalAdmissionLimiter.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/BlockingAttemptBulkhead.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptRateLimiter.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/AttemptCircuitBreaker.java` - Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/AttemptResiliencePipelineTest.java` **Interfaces:** - Retry Coordinator invokes `AttemptResiliencePipeline.execute(attemptSupplier)` for every physical attempt. - Pipeline order is Circuit → Rate Limiter → Bulkhead → HTTP call. - [ ] **Step 1: Write a failing decorator-order test** ```java class AttemptResiliencePipelineTest { @Test void appliesCircuitThenRateLimiterThenBulkheadPerAttempt() { RecordingResilienceComponents components = new RecordingResilienceComponents(); AttemptResiliencePipeline pipeline = components.pipeline(); assertThat(pipeline.execute(() -> "ok")).isEqualTo("ok"); assertThat(components.events()).containsExactly( "circuit-enter", "rate-enter", "bulkhead-enter", "call", "bulkhead-exit", "rate-exit", "circuit-exit"); } @Test void openCircuitDoesNotConsumeRateOrBulkheadPermit() { RecordingResilienceComponents components = RecordingResilienceComponents.openCircuit(); assertThatThrownBy(() -> components.pipeline().execute(() -> "never")) .isInstanceOf(HttpCircuitOpenException.class); assertThat(components.events()).containsExactly("circuit-reject"); } } ``` - [ ] **Step 2: Run tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-resilience:test \ --tests '*AttemptResiliencePipelineTest' ``` Expected: FAIL because the physical attempt pipeline is missing. - [ ] **Step 3: Implement fixed decorator order using Resilience4j primitives** ```java public final class AttemptResiliencePipeline { public T execute(CheckedSupplier call) { if (!circuit.tryAcquirePermission()) throw circuitOpen(); long started = System.nanoTime(); try { rateLimiter.acquirePermission(); T result = bulkhead.execute(call); circuit.onSuccess(System.nanoTime() - started, NANOSECONDS); return result; } catch (Throwable failure) { circuit.onError(System.nanoTime() - started, NANOSECONDS, failure); throw translate(failure); } } } ``` Use adapter classes around Resilience4j rather than leaking its exception types. `LogicalAdmissionLimiter` runs once before creating the Retry Coordinator; attempt rate and bulkhead run for every physical attempt. - [ ] **Step 4: Run resilience pipeline tests** ```bash ./gradlew :modules:httpclient:httpclient-resilience:test \ --tests '*AttemptResiliencePipelineTest' ``` Expected: PASS; no rate or bulkhead permit is consumed when the circuit is open. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-resilience git commit -m "feat: enforce http attempt resilience order" ``` --- ### Task 21: Response 크기 제한, RFC 9457, 안정 오류 변환 구현 **Files:** - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/ResponseSizeLimiter.java` - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingResponseMapper.java` - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/RemoteProblemDecoder.java` - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/StableBlockingExceptionMapper.java` - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BoundedErrorBody.java` - Modify: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingAttemptExecutor.java` - Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/BlockingResponseMapperTest.java` - Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/ResponseSizeLimiterTest.java` **Interfaces:** - Maps all non-success responses and transport failures to `HttpClientException` subclasses. - Preserves RFC 9457 fields under a byte and extension allowlist. - [ ] **Step 1: Write failing problem and oversized response tests** ```java class BlockingResponseMapperTest { @Test void mapsProblemJsonWithoutTrustingBodyStatus() { RemoteProblem problem = new RemoteProblemDecoder(4096, Set.of("code")) .decode(503, "application/problem+json", """{"type":"urn:test","title":"busy","status":400,"detail":"later","code":"UPSTREAM_BUSY"}""" .getBytes(UTF_8)); assertThat(problem.httpStatus().value()).isEqualTo(503); assertThat(problem.extensions()).containsEntry("code", "UPSTREAM_BUSY"); } } class ResponseSizeLimiterTest { @Test void abortsWhenDecodedBytesExceedLimit() { ResponseSizeLimiter limiter = new ResponseSizeLimiter(10, 20); assertThatThrownBy(() -> limiter.recordDecodedBytes(21)) .isInstanceOf(HttpResponseTooLargeException.class); } } ``` - [ ] **Step 2: Run response mapping tests and verify failure** ```bash ./gradlew :modules:httpclient:httpclient-restclient:test \ --tests '*BlockingResponseMapperTest' \ --tests '*ResponseSizeLimiterTest' ``` Expected: FAIL because response mapping components are absent. - [ ] **Step 3: Implement bounded response and stable exception mapping** ```java public final class RemoteProblemDecoder { public RemoteProblem decode(int actualStatus, String contentType, byte[] body) { if (!"application/problem+json".equalsIgnoreCase(contentType)) { return RemoteProblem.empty(new HttpStatus(actualStatus)); } byte[] bounded = body.length <= maxBytes ? body : Arrays.copyOf(body, maxBytes); ProblemPayload payload = objectMapper.readValue(bounded, ProblemPayload.class); return new RemoteProblem( optionalUri(payload.type()), payload.title(), new HttpStatus(actualStatus), payload.detail(), payload.instance(), allowedExtensions(payload.extensions())); } } ``` `BlockingResponseMapper` counts wire and decoded bytes, closes the body on every branch, and creates `HttpRemoteErrorException` or `HttpProblemDetailException` with sanitized metadata. It never stores the raw error body in the exception. - [ ] **Step 4: Run response mapping tests** ```bash ./gradlew :modules:httpclient:httpclient-restclient:test \ --tests '*BlockingResponseMapperTest' \ --tests '*ResponseSizeLimiterTest' ``` Expected: PASS; pool contract tests show zero leased connections after decode failure and size rejection. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-restclient git commit -m "feat: map bounded remote http failures" ``` --- ### Task 22: Static Credential과 OAuth2 Client 통합 구현 **Files:** - Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/CredentialType.java` - Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/RequestCredentials.java` - Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/CredentialRequest.java` - Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/RequestCredentialProvider.java` - Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/NoAuthCredentialProvider.java` - Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/BasicCredentialProvider.java` - Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/ApiKeyHeaderCredentialProvider.java` - Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/StaticBearerCredentialProvider.java` - Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/OAuth2CredentialProvider.java` - Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/OAuth2TokenCacheKey.java` - Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/SingleFlightTokenLoader.java` - Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/UnauthorizedRetryPolicy.java` - Test: `modules/httpclient/httpclient-auth/src/test/java/io/backend/skeleton/httpclient/auth/SingleFlightTokenLoaderTest.java` - Test: `modules/httpclient/httpclient-auth/src/test/java/io/backend/skeleton/httpclient/auth/UnauthorizedRetryPolicyTest.java` **Interfaces:** - Produces blocking credential materialization for RestClient. - Reactive credential provider is added with the WebClient task. - Token cache key includes registration, principal class, scopes, audience, tenant boundary, and mTLS identity. - [ ] **Step 1: Write failing concurrent refresh and 401 safety tests** ```java class SingleFlightTokenLoaderTest { @Test void concurrentRequestsShareOneTokenRefresh() throws Exception { AtomicInteger loads = new AtomicInteger(); SingleFlightTokenLoader loader = new SingleFlightTokenLoader(key -> { loads.incrementAndGet(); return AccessTokens.validFor(Duration.ofMinutes(5)); }); ExecutorService pool = Executors.newFixedThreadPool(20); List> futures = IntStream.range(0, 20) .mapToObj(i -> pool.submit(() -> loader.load(TokenKeys.payment()))) .toList(); for (Future future : futures) future.get(); assertThat(loads).hasValue(1); pool.shutdownNow(); } } class UnauthorizedRetryPolicyTest { @Test void denies401ReplayForOneShotPost() { assertThat(new UnauthorizedRetryPolicy().mayRetry( AuthRetryFixtures.oneShotPost401())).isFalse(); } } ``` - [ ] **Step 2: Run auth tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-auth:test \ --tests '*SingleFlightTokenLoaderTest' \ --tests '*UnauthorizedRetryPolicyTest' ``` Expected: FAIL because credential providers and token loader are missing. - [ ] **Step 3: Implement provider registry and Spring Security OAuth2 delegation** ```java public final class SingleFlightTokenLoader { private final ConcurrentMap> inFlight = new ConcurrentHashMap<>(); public AccessToken load(OAuth2TokenCacheKey key) { CompletableFuture future = inFlight.computeIfAbsent(key, ignored -> CompletableFuture.supplyAsync(() -> delegate.load(key))); try { return future.join(); } finally { if (future.isDone()) inFlight.remove(key, future); } } } ``` `OAuth2CredentialProvider` calls `OAuth2AuthorizedClientManager`, applies expiry skew, and returns only an immutable Authorization header. Token endpoint calls use a separate Named Client Profile. `UnauthorizedRetryPolicy` allows at most one refresh-and-replay for a replayable safe or explicitly contract-idempotent operation. - [ ] **Step 4: Run authentication tests** ```bash ./gradlew :modules:httpclient:httpclient-auth:test ``` Expected: PASS; test logs contain no access token, client secret, or authorization code. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-auth git commit -m "feat: add bounded http client authentication" ``` --- ### Task 23: TLS·mTLS Policy와 Certificate Runtime Rotation 구현 **Files:** - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsProfileId.java` - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsProfile.java` - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsPolicyValidator.java` - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsMaterialProvider.java` - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/ClientCertificateIdentity.java` - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/TlsRuntimeRotationCoordinator.java` - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/SslContextMaterial.java` - Modify: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheClientFactory.java` - Modify: `modules/httpclient/httpclient-transport-jdk/src/main/java/io/backend/skeleton/httpclient/jdk/JdkClientFactory.java` - Test: `modules/httpclient/httpclient-security/src/test/java/io/backend/skeleton/httpclient/security/TlsPolicyValidatorTest.java` - Test: `modules/httpclient/httpclient-security/src/test/java/io/backend/skeleton/httpclient/security/TlsRuntimeRotationCoordinatorTest.java` **Interfaces:** - Produces verified SSL material for Apache, JDK, Reactor, and Jetty providers. - Rotation builds a new `ClientRuntime` generation and drains the old generation. - [ ] **Step 1: Write failing unsafe TLS and rotation tests** ```java class TlsPolicyValidatorTest { @Test void rejectsTrustAllAndHostnameVerificationDisablement() { TlsProfile unsafe = TlsProfiles.trustAllWithoutHostnameVerification(); assertThat(new TlsPolicyValidator().validate(unsafe)) .extracting(TlsViolation::code) .contains("TRUST_ALL_FORBIDDEN", "HOSTNAME_VERIFICATION_REQUIRED"); } } class TlsRuntimeRotationCoordinatorTest { @Test void swapsRuntimeWhenCertificateIdentityChanges() { ClientRuntimeRegistry registry = RuntimeFixtures.registryWithCertificate("cert-v1"); TlsRuntimeRotationCoordinator coordinator = RotationFixtures.coordinator(registry); coordinator.rotate(new ClientCertificateIdentity("cert-v2")); try (ClientRuntimeLease lease = registry.acquire(new ClientProfileName("partner"))) { assertThat(lease.runtime().generation().value()).isEqualTo(2); } } } ``` - [ ] **Step 2: Run TLS tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-security:test \ --tests '*TlsPolicyValidatorTest' \ --tests '*TlsRuntimeRotationCoordinatorTest' ``` Expected: FAIL because TLS profile and rotation components are missing. - [ ] **Step 3: Implement strict TLS profiles and generation swap** ```java public record TlsProfile( TlsProfileId id, Set protocols, boolean hostnameVerification, TrustMaterialRef trustMaterial, Optional clientKeyMaterial, boolean allowPlainHttp) { } ``` `TlsPolicyValidator` permits only TLS 1.2 and 1.3 in production, requires hostname verification, and has no representation for trust-all. `TlsRuntimeRotationCoordinator` loads new material, builds and validates a replacement runtime, swaps it atomically, then drains the old pool. - [ ] **Step 4: Run TLS security and transport integration tests** ```bash ./gradlew :modules:httpclient:httpclient-security:test \ :modules:httpclient:httpclient-transport-apache:test \ :modules:httpclient:httpclient-transport-jdk:test ``` Expected: PASS; a hostname mismatch fails without a second network attempt. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-security \ modules/httpclient/httpclient-transport-apache \ modules/httpclient/httpclient-transport-jdk git commit -m "feat: enforce tls and mtls runtime policy" ``` --- ### Task 24: Redirect 실행과 Credential stripping 구현 **Files:** - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/RedirectDecision.java` - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/RedirectEvaluator.java` - Create: `modules/httpclient/httpclient-security/src/main/java/io/backend/skeleton/httpclient/security/SensitiveHeaderStripper.java` - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingRedirectCoordinator.java` - Modify: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingAttemptExecutor.java` - Test: `modules/httpclient/httpclient-security/src/test/java/io/backend/skeleton/httpclient/security/RedirectEvaluatorTest.java` - Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/BlockingRedirectCoordinatorTest.java` **Interfaces:** - Engine automatic redirect remains disabled. - Platform coordinator evaluates every hop and rebuilds request headers explicitly. - [ ] **Step 1: Write failing method-preservation and header-leak tests** ```java class RedirectEvaluatorTest { @Test void rejects307WhenBodyIsOneShot() { RedirectContext context = RedirectFixtures.oneShotPost307(); assertThat(new RedirectEvaluator().evaluate(context)) .isInstanceOf(RedirectDecision.Reject.class); } @Test void stripsCredentialsOnCrossOriginRedirect() { Map> result = SensitiveHeaderStripper.standard() .stripForCrossOrigin(Map.of( "Authorization", List.of("Bearer secret"), "Cookie", List.of("sid=x"), "Accept", List.of("application/json"))); assertThat(result).containsOnlyKeys("Accept"); } } ``` - [ ] **Step 2: Run redirect tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-security:test \ --tests '*RedirectEvaluatorTest' \ :modules:httpclient:httpclient-restclient:test \ --tests '*BlockingRedirectCoordinatorTest' ``` Expected: FAIL because redirect components are absent. - [ ] **Step 3: Implement bounded hop evaluation** ```java public final class RedirectEvaluator { public RedirectDecision evaluate(RedirectContext c) { if (!c.policy().enabled()) return RedirectDecision.reject("REDIRECT_DISABLED"); if (c.hop() >= c.policy().maxHops()) return RedirectDecision.reject("MAX_HOPS"); if ((c.status() == 307 || c.status() == 308) && !c.body().replayability().canReplay()) return RedirectDecision.reject("BODY_NOT_REPLAYABLE"); if (c.crossOrigin() && !c.policy().allowCrossOrigin()) return RedirectDecision.reject("CROSS_ORIGIN_FORBIDDEN"); return RedirectDecision.follow(c.target(), c.crossOrigin()); } } ``` `BlockingRedirectCoordinator` counts every redirect request as a physical attempt for rate and bulkhead purposes but not as a Retry caused by failure. It re-applies target security before each hop. - [ ] **Step 4: Run redirect contract tests** ```bash ./gradlew :modules:httpclient:httpclient-security:test \ :modules:httpclient:httpclient-restclient:test \ --tests '*Redirect*Test' ``` Expected: PASS; cross-origin recorded requests contain no Authorization, Cookie, or API key header. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-security \ modules/httpclient/httpclient-restclient git commit -m "feat: control outbound http redirects" ``` --- ### Task 25: H3 Dynamic Target SSRF 방어와 DNS/IP Pinning 구현 **Files:** - Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DynamicTargetGateway.java` - Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DynamicTargetPolicyName.java` - Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DynamicTargetPolicy.java` - Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/CanonicalTarget.java` - Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/TargetCanonicalizer.java` - Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/IpAddressClassifier.java` - Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/ValidatedDnsResolver.java` - Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/PinnedTarget.java` - Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DefaultDynamicTargetGateway.java` - Create: `modules/httpclient/httpclient-dynamic-target/src/main/java/io/backend/skeleton/httpclient/dynamic/DynamicCredentialBinding.java` - Test: `modules/httpclient/httpclient-dynamic-target/src/test/java/io/backend/skeleton/httpclient/dynamic/TargetCanonicalizerTest.java` - Test: `modules/httpclient/httpclient-dynamic-target/src/test/java/io/backend/skeleton/httpclient/dynamic/DynamicTargetSecurityTest.java` **Interfaces:** - Supports Apache first; Reactor integration is added after its transport task. - JDK and Jetty are rejected for H3 Stable until validated pinning capability exists. - [ ] **Step 1: Write failing SSRF matrix tests** ```java class DynamicTargetSecurityTest { @ParameterizedTest @ValueSource(strings = { "http://127.0.0.1/a", "https://[::1]/a", "https://169.254.169.254/latest/meta-data", "file:///etc/passwd", "https://user:pass@example.com/a" }) void rejectsForbiddenTargets(String raw) { DynamicTargetPolicy policy = DynamicPolicies.publicHttpsOnly(); assertThatThrownBy(() -> DynamicTargets.prepare(policy, URI.create(raw))) .isInstanceOf(HttpTargetRejectedException.class); } @Test void rejectsDnsAnswerWhenAnyAddressIsPrivate() { ValidatedDnsResolver resolver = DnsFixtures.resolvesTo( "mixed.test", "203.0.113.10", "10.0.0.4"); assertThatThrownBy(() -> resolver.resolve("mixed.test")) .isInstanceOf(HttpTargetRejectedException.class); } } ``` - [ ] **Step 2: Run Dynamic Target tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-dynamic-target:test \ --tests '*TargetCanonicalizerTest' \ --tests '*DynamicTargetSecurityTest' ``` Expected: FAIL because canonicalization and IP policy are missing. - [ ] **Step 3: Implement canonicalization, all-answer validation, and pinning** ```java public final class TargetCanonicalizer { public CanonicalTarget canonicalize(DynamicTargetPolicy policy, URI input) { if (input.getUserInfo() != null) reject("USERINFO_FORBIDDEN"); String scheme = input.getScheme().toLowerCase(Locale.ROOT); if (!policy.allowedSchemes().contains(scheme)) reject("SCHEME_FORBIDDEN"); String host = IDN.toASCII(stripTrailingDot(input.getHost()), IDN.USE_STD3_ASCII_RULES) .toLowerCase(Locale.ROOT); int port = effectivePort(input); if (!policy.allowedPorts().contains(port)) reject("PORT_FORBIDDEN"); return new CanonicalTarget(scheme, host, port, normalizedPath(input), input.getRawQuery()); } } ``` `ValidatedDnsResolver` validates every A and AAAA answer, normalizes IPv4-mapped IPv6, and returns a `PinnedTarget` containing the canonical host and exact approved addresses. Apache uses this resolver for the actual connection. Redirects restart the full validation flow. - [ ] **Step 4: Run the Dynamic Target security suite** ```bash ./gradlew :modules:httpclient:httpclient-dynamic-target:test ``` Expected: PASS for loopback, link-local, private, ULA, metadata, IDNA, mapped IPv6, mixed DNS answer, and redirect fixtures. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-dynamic-target git commit -m "feat: secure dynamic outbound http targets" ``` --- ### Task 26: Reactor Netty Reactive Transport 구현 **Files:** - Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorNettyTransportProvider.java` - Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorConnectionProviderFactory.java` - Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorHttpClientFactory.java` - Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorFailureClassifier.java` - Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorPoolMetricsBinder.java` - Create: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ValidatedAddressResolverGroup.java` - Test: `modules/httpclient/httpclient-transport-reactor-netty/src/test/java/io/backend/skeleton/httpclient/reactor/ReactorNettyTransportProviderTest.java` - Test: `modules/httpclient/httpclient-transport-reactor-netty/src/test/java/io/backend/skeleton/httpclient/reactor/ReactorCancellationTest.java` **Interfaces:** - Implements `ReactiveTransportProvider` with ID `reactor-netty`. - Supports profile-scoped pool, pending acquire, DNS pinning, proxy, TLS, HTTP/1.1·2, cancellation. - [ ] **Step 1: Write failing reactive request and cancellation tests** ```java class ReactorCancellationTest { @Test void cancellationReleasesConnection() { ReactorTransportFixture fixture = ReactorTransportFixture.slowBody(); StepVerifier.create(fixture.webClient().get().uri(fixture.uri()).retrieve() .bodyToFlux(DataBuffer.class).take(1)) .expectNextCount(1) .verifyComplete(); await().atMost(Duration.ofSeconds(2)) .untilAsserted(() -> assertThat(fixture.leasedConnections()).isZero()); } } ``` - [ ] **Step 2: Run Reactor transport tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-transport-reactor-netty:test ``` Expected: FAIL because the provider and pool factory are missing. - [ ] **Step 3: Implement profile-scoped Reactor Netty runtime** ```java public final class ReactorConnectionProviderFactory { public ConnectionProvider create(ClientProfile profile) { return ConnectionProvider.builder(profile.name().value()) .maxConnections(profile.pool().maxTotalConnections()) .pendingAcquireMaxCount(profile.pool().maxPendingAcquires()) .pendingAcquireTimeout(profile.pool().pendingAcquireTimeout()) .maxIdleTime(profile.pool().maxIdleTime()) .maxLifeTime(profile.pool().maxLifeTime()) .evictInBackground(profile.pool().evictionInterval()) .metrics(true) .build(); } } ``` Configure connect, response, TLS handshake, proxy, DNS resolver, protocol, and wire/decoded byte hooks. `doOnDiscard(DataBuffer.class, DataBufferUtils::release)` is registered in the WebClient integration rather than the transport provider. - [ ] **Step 4: Run Reactor transport and cancellation tests** ```bash ./gradlew :modules:httpclient:httpclient-transport-reactor-netty:test ``` Expected: PASS; cancellation, timeout, and decode error return the pool to zero leased connections. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-transport-reactor-netty git commit -m "feat: add reactor netty http transport" ``` --- ### Task 27: WebClient Reactive Gateway와 Non-blocking Retry Coordinator 구현 **Files:** - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveHttpGateway.java` - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/DefaultReactiveHttpGateway.java` - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/WebClientRuntimeFactory.java` - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveAttemptExecutor.java` - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/WebClientBodyWriter.java` - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/WebClientResponseMapper.java` - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveBodySource.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/ReactiveRetryCoordinator.java` - Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/ReactiveRequestCredentialProvider.java` - Test: `modules/httpclient/httpclient-webclient/src/test/java/io/backend/skeleton/httpclient/webclient/DefaultReactiveHttpGatewayTest.java` - Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/ReactiveRetryCoordinatorTest.java` **Interfaces:** - Produces `Mono> exchange(...)`. - Uses Reactor delay for backoff and never calls `Thread.sleep()` or `.block()`. - [ ] **Step 1: Write failing reactive retry and context tests** ```java class DefaultReactiveHttpGatewayTest { @Test void returnsTypedResultWithoutBlocking() { try (MockHttpServer server = MockHttpServer.start()) { server.enqueueJson(200, "{\"id\":9}"); ReactiveHttpGateway gateway = TestGateways.reactor(server.uri("/")); Mono> result = gateway.exchange( new ClientProfileName("users"), HttpOperation.get(new OperationName("get-user"), "/users/9", Map.of()), ResponseType.of(UserResponse.class)); StepVerifier.create(result) .assertNext(value -> assertThat(value.body().id()).isEqualTo(9)) .verifyComplete(); } } } class ReactiveRetryCoordinatorTest { @Test void backoffDoesNotBlockCallingThread() { VirtualTimeScheduler.getOrSet(); Mono> call = ReactiveRetryFixtures.failThenSucceed(); StepVerifier.withVirtualTime(() -> call) .thenAwait(Duration.ofMillis(100)) .assertNext(result -> assertThat(result.attempts()).isEqualTo(2)) .verifyComplete(); } } ``` - [ ] **Step 2: Run reactive gateway tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-webclient:test \ :modules:httpclient:httpclient-resilience:test \ --tests '*ReactiveRetryCoordinatorTest' ``` Expected: FAIL because reactive gateway and coordinator are missing. - [ ] **Step 3: Implement Reactor-context-aware non-blocking pipeline** ```java public final class ReactiveRetryCoordinator { public Mono> execute(ReactiveLogicalCall call) { return attempt(call, 1); } private Mono> attempt(ReactiveLogicalCall call, int number) { return call.attempt(number).flatMap(outcome -> { RetryDecision decision = eligibility.decide(call.context(outcome, number)); if (decision instanceof RetryAllowed allowed) { if (!budget.tryConsume()) return Mono.error(call.retryExhausted(number)); return Mono.delay(backoff.delay(number, allowed.retryAfter(), call.deadline())) .then(attempt(call, number + 1)); } if (decision instanceof AmbiguousFailure) return Mono.error(call.ambiguous(outcome, number)); return call.finish(outcome, number); }); } } ``` `DefaultReactiveHttpGateway` acquires and releases runtime leases with `Mono.usingWhen`, applies Reactor Context operation metadata, and registers buffer discard hooks. - [ ] **Step 4: Run reactive tests with BlockHound enabled** ```bash ./gradlew :modules:httpclient:httpclient-webclient:test \ :modules:httpclient:httpclient-resilience:test \ -Pblockhound.enabled=true ``` Expected: PASS with no blocking call detected on Reactor event-loop threads. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-webclient \ modules/httpclient/httpclient-resilience \ modules/httpclient/httpclient-auth git commit -m "feat: add reactive http gateway and retries" ``` --- ### Task 28: H1 Reactive Typed Service Client Registry 구현 **Files:** - Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ReactiveHttpServiceRegistry.java` - Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/DefaultReactiveHttpServiceRegistry.java` - Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ReactiveServiceInvocationHandler.java` - Create: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ReactiveOperationContext.java` - Modify: `modules/httpclient/httpclient-service-client/src/main/java/io/backend/skeleton/httpclient/service/ServiceOperationDescriptorScanner.java` - Test: `modules/httpclient/httpclient-service-client/src/test/java/io/backend/skeleton/httpclient/service/ReactiveHttpServiceRegistryTest.java` - Test: `modules/httpclient/httpclient-service-client/src/test/java/io/backend/skeleton/httpclient/service/BlockingReactiveSignatureSeparationTest.java` **Interfaces:** - Produces typed proxies returning `Mono`, `Flux`, and SSE types. - A service interface is classified as blocking or reactive at startup; mixed ambiguous signatures are rejected. - [ ] **Step 1: Write failing reactive proxy and mixed-signature tests** ```java @HttpClientProfile("events") @HttpExchange("/events") interface ReactiveEventsClient { @GetExchange("/{id}") @HttpOperationPolicy(name = "get-event", idempotency = OperationIdempotency.STANDARD_IDEMPOTENT) Mono get(@PathVariable String id); } class ReactiveHttpServiceRegistryTest { @Test void propagatesOperationDescriptorThroughReactorContext() { ReactiveHttpServiceRegistry registry = ReactiveServiceFixtures.registry(); StepVerifier.create(registry.client( new ClientProfileName("events"), ReactiveEventsClient.class).get("e1")) .expectNextMatches(event -> event.id().equals("e1")) .verifyComplete(); assertThat(ReactiveServiceFixtures.lastOperationName()).isEqualTo("get-event"); } } ``` - [ ] **Step 2: Run reactive service client tests and verify failure** ```bash ./gradlew :modules:httpclient:httpclient-service-client:test \ --tests '*ReactiveHttpServiceRegistryTest' \ --tests '*BlockingReactiveSignatureSeparationTest' ``` Expected: FAIL because reactive registry and handler are missing. - [ ] **Step 3: Implement Reactor Context wrapper proxy** ```java public final class ReactiveServiceInvocationHandler implements InvocationHandler { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { ServiceOperationDescriptor descriptor = descriptors.require(method); Object result = method.invoke(delegate, args); if (result instanceof Mono mono) { return mono.contextWrite(ctx -> ctx.put(ReactiveOperationContext.KEY, descriptor)); } if (result instanceof Flux flux) { return flux.contextWrite(ctx -> ctx.put(ReactiveOperationContext.KEY, descriptor)); } throw new HttpConfigurationException("reactive service method must return Mono or Flux", metadata); } } ``` Reject a single interface that combines synchronous values with `Mono`/`Flux`, and reject `.block()` adapters in the generated registry. - [ ] **Step 4: Run service client tests with context-loss tracking** ```bash ./gradlew :modules:httpclient:httpclient-service-client:test \ -Dreactor.trace.operatorStacktrace=true ``` Expected: PASS; operation descriptor is visible at subscription time and absent from unrelated subscriptions. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-service-client git commit -m "feat: add typed reactive http service clients" ``` --- ### Task 29: Streaming Upload·Download Lifecycle과 First-byte Boundary 구현 **Files:** - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/BlockingStreamingGateway.java` - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/DefaultBlockingStreamingResponse.java` - Create: `modules/httpclient/httpclient-restclient/src/main/java/io/backend/skeleton/httpclient/restclient/CountingBoundedInputStream.java` - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveStreamingGateway.java` - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/FirstByteDeliveryGuard.java` - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/BoundedDataBufferFlux.java` - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/MultipartReplayability.java` - Test: `modules/httpclient/httpclient-restclient/src/test/java/io/backend/skeleton/httpclient/restclient/BlockingStreamingLifecycleTest.java` - Test: `modules/httpclient/httpclient-webclient/src/test/java/io/backend/skeleton/httpclient/webclient/ReactiveStreamingLifecycleTest.java` - Test: `modules/httpclient/httpclient-webclient/src/test/java/io/backend/skeleton/httpclient/webclient/FirstByteRetryBoundaryTest.java` **Interfaces:** - Blocking response implements `AutoCloseable` and owns the response body lifecycle. - Reactive response emits bounded `DataBuffer` values and disables Retry after first `onNext`. - [ ] **Step 1: Write failing close, cancel, and first-byte tests** ```java class BlockingStreamingLifecycleTest { @Test void closeReturnsConnectionAfterPartialRead() throws Exception { StreamingFixture fixture = StreamingFixture.apacheLargeBody(); try (BlockingStreamingResponse response = fixture.gateway().download(fixture.operation())) { assertThat(response.body().readNBytes(16)).hasSize(16); } await().atMost(Duration.ofSeconds(2)) .untilAsserted(() -> assertThat(fixture.leasedConnections()).isZero()); } } class FirstByteRetryBoundaryTest { @Test void doesNotRetryAfterFirstBufferWasDelivered() { ReactiveStreamingFixture fixture = ReactiveStreamingFixture.emitThenReset(); StepVerifier.create(fixture.gateway().download(fixture.operation())) .expectNextCount(1) .expectError(HttpResponseTruncatedException.class) .verify(); assertThat(fixture.physicalRequestCount()).isEqualTo(1); } } ``` - [ ] **Step 2: Run streaming lifecycle tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-restclient:test \ --tests '*BlockingStreamingLifecycleTest' \ :modules:httpclient:httpclient-webclient:test \ --tests '*ReactiveStreamingLifecycleTest' \ --tests '*FirstByteRetryBoundaryTest' ``` Expected: FAIL because streaming gateways and guards are missing. - [ ] **Step 3: Implement bounded lifecycle wrappers** ```java public final class DefaultBlockingStreamingResponse implements BlockingStreamingResponse { private final InputStream body; private final Runnable closeAction; private final AtomicBoolean closed = new AtomicBoolean(); @Override public void close() { if (closed.compareAndSet(false, true)) { try { body.close(); } catch (IOException ignored) { } closeAction.run(); } } } ``` `CountingBoundedInputStream` throws `HttpResponseTooLargeException` when actual bytes exceed the profile limit and closes the underlying response. `FirstByteDeliveryGuard` atomically marks `firstByteDelivered` before forwarding the first buffer. `BoundedDataBufferFlux` releases the current and discarded buffers on error or cancellation. - [ ] **Step 4: Run streaming tests with leak detection** ```bash ./gradlew :modules:httpclient:httpclient-restclient:test \ :modules:httpclient:httpclient-webclient:test \ -Dio.netty.leakDetection.level=paranoid ``` Expected: PASS with zero leaked connection and zero Netty leak report. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-restclient \ modules/httpclient/httpclient-webclient git commit -m "feat: enforce http streaming lifecycle" ``` --- ### Task 30: SSE 연결·Idle Timeout·재연결 구현 **Files:** - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/ReactiveSseGateway.java` - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/DefaultReactiveSseGateway.java` - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/SseOperation.java` - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/SseReconnectPolicy.java` - Create: `modules/httpclient/httpclient-webclient/src/main/java/io/backend/skeleton/httpclient/webclient/SseIdleTimeoutException.java` - Test: `modules/httpclient/httpclient-webclient/src/test/java/io/backend/skeleton/httpclient/webclient/ReactiveSseGatewayTest.java` **Interfaces:** - Produces `Flux> connect(...)`. - Setup deadline, streaming idle timeout, max stream duration, and `Last-Event-ID` policy are separate. - [ ] **Step 1: Write failing event decode, idle, and reconnect tests** ```java class ReactiveSseGatewayTest { @Test void reconnectsWithLastEventIdWhenPolicyAllowsIt() { SseFixture fixture = SseFixture.disconnectAfterEvent("event-1"); StepVerifier.create(fixture.gateway().connect( fixture.profile(), fixture.operationWithReconnect(), ResponseType.of(EventPayload.class)).take(2)) .expectNextMatches(event -> event.id().equals("event-1")) .expectNextMatches(event -> event.id().equals("event-2")) .verifyComplete(); assertThat(fixture.secondRequestHeader("Last-Event-ID")) .contains("event-1"); } @Test void closesSilentStreamAtStreamingIdleTimeout() { SseFixture fixture = SseFixture.neverEmits(); StepVerifier.withVirtualTime(() -> fixture.gateway().connect( fixture.profile(), fixture.shortIdleOperation(), ResponseType.of(EventPayload.class))) .thenAwait(Duration.ofSeconds(5)) .expectError(SseIdleTimeoutException.class) .verify(); } } ``` - [ ] **Step 2: Run SSE tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-webclient:test \ --tests '*ReactiveSseGatewayTest' ``` Expected: FAIL because SSE contracts are missing. - [ ] **Step 3: Implement setup and stream-phase policies** ```java public final class DefaultReactiveSseGateway implements ReactiveSseGateway { @Override public Flux> connect(ClientProfileName profile, SseOperation operation, ResponseType eventType) { return open(profile, operation, eventType, Optional.empty()) .timeout(operation.streamingIdleTimeout(), Flux.error(new SseIdleTimeoutException(operation.operationName()))) .retryWhen(reconnectSpec(operation)); } } ``` `reconnectSpec` uses Retry Budget and only sets `Last-Event-ID` when the operation explicitly opts in. Application cancellation stops reconnect and closes the active connection. - [ ] **Step 4: Run SSE and cancellation tests** ```bash ./gradlew :modules:httpclient:httpclient-webclient:test \ --tests '*ReactiveSseGatewayTest' \ -Dio.netty.leakDetection.level=paranoid ``` Expected: PASS; a cancelled subscription produces no later reconnect request. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-webclient git commit -m "feat: add bounded reactive sse clients" ``` --- ### Task 31: Proxy 지원과 HTTP/2 Protocol Evidence 구현 **Files:** - Create: `modules/httpclient/httpclient-profile/src/main/java/io/backend/skeleton/httpclient/profile/ProxySettings.java` - Create: `modules/httpclient/httpclient-auth/src/main/java/io/backend/skeleton/httpclient/auth/ProxyCredentialProvider.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/Http2ProtocolEvidence.java` - Create: `modules/httpclient/httpclient-resilience/src/main/java/io/backend/skeleton/httpclient/resilience/Http2EvidenceMapper.java` - Modify: `modules/httpclient/httpclient-transport-apache/src/main/java/io/backend/skeleton/httpclient/apache/ApacheClientFactory.java` - Modify: `modules/httpclient/httpclient-transport-reactor-netty/src/main/java/io/backend/skeleton/httpclient/reactor/ReactorHttpClientFactory.java` - Create: `modules/httpclient/httpclient-testkit/src/main/java/io/backend/skeleton/httpclient/testkit/Http2FailureFixture.java` - Test: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/ForwardProxyContractTest.java` - Test: `modules/httpclient/httpclient-resilience/src/test/java/io/backend/skeleton/httpclient/resilience/Http2EvidenceMapperTest.java` **Interfaces:** - Proxy connect failure remains distinct from target connect and TLS failure. - `REFUSED_STREAM` and GOAWAY stream IDs can prove peer non-processing. - [ ] **Step 1: Write failing proxy isolation and H2 evidence tests** ```java class Http2EvidenceMapperTest { @Test void refusedStreamIsPeerNotProcessedEvidence() { Http2ProtocolEvidence evidence = Http2ProtocolEvidence.refusedStream(7); assertThat(new Http2EvidenceMapper().map(evidence)) .isEqualTo(ProtocolEvidence.peerDidNotProcess("REFUSED_STREAM")); } @Test void streamAfterGoAwayLastIdIsPeerNotProcessed() { Http2ProtocolEvidence evidence = Http2ProtocolEvidence.goAway(11, 15); assertThat(new Http2EvidenceMapper().map(evidence).peerDidNotProcess()).isTrue(); } } ``` - [ ] **Step 2: Run proxy and HTTP/2 tests and verify failure** ```bash ./gradlew :modules:httpclient:httpclient-testkit:test \ --tests '*ForwardProxyContractTest' \ :modules:httpclient:httpclient-resilience:test \ --tests '*Http2EvidenceMapperTest' ``` Expected: FAIL because proxy settings and H2 evidence mapping are missing. - [ ] **Step 3: Implement explicit proxy and H2 mappings** ```java public record ProxySettings( boolean enabled, String host, int port, ProxyType type, Optional credentialProvider, Duration connectTimeout) { } ``` Configure target and proxy credentials separately. Ignore ambient `NO_PROXY` in production unless explicitly imported into the validated profile. Map GOAWAY and REFUSED_STREAM only when the transport exposes reliable stream IDs; otherwise retain conservative evidence. - [ ] **Step 4: Run proxy, HTTP/2, Apache, and Reactor tests** ```bash ./gradlew :modules:httpclient:httpclient-testkit:test \ :modules:httpclient:httpclient-resilience:test \ :modules:httpclient:httpclient-transport-apache:test \ :modules:httpclient:httpclient-transport-reactor-netty:test ``` Expected: PASS; proxy authentication never appears in target requests or logs. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-profile \ modules/httpclient/httpclient-auth \ modules/httpclient/httpclient-resilience \ modules/httpclient/httpclient-transport-apache \ modules/httpclient/httpclient-transport-reactor-netty \ modules/httpclient/httpclient-testkit git commit -m "feat: add proxy and http2 failure semantics" ``` --- ### Task 32: Spring Boot Starter·Properties·Actuator 구현 **Files:** - Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientsProperties.java` - Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientProfileAutoConfiguration.java` - Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientTransportAutoConfiguration.java` - Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientResilienceAutoConfiguration.java` - Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientAuthenticationAutoConfiguration.java` - Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientSecurityAutoConfiguration.java` - Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientObservationAutoConfiguration.java` - Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpServiceClientAutoConfiguration.java` - Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/DynamicTargetAutoConfiguration.java` - Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientStartupValidator.java` - Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientActuatorEndpoint.java` - Create: `modules/httpclient/httpclient-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` - Test: `modules/httpclient/httpclient-spring-boot-starter/src/test/java/io/backend/skeleton/httpclient/autoconfigure/HttpClientAutoConfigurationTest.java` - Test: `modules/httpclient/httpclient-spring-boot-starter/src/test/java/io/backend/skeleton/httpclient/autoconfigure/UnsafeStartupConfigurationTest.java` **Interfaces:** - Binds `http-clients.*` properties into immutable profiles. - Startup fails on all unsafe conditions listed in the design. - [ ] **Step 1: Write failing safe binding and unsafe startup tests** ```java class UnsafeStartupConfigurationTest { private final ApplicationContextRunner runner = new ApplicationContextRunner() .withConfiguration(AutoConfigurations.of(HttpClientProfileAutoConfiguration.class)); @Test void productionTrustAllConfigurationFailsStartup() { runner.withPropertyValues( "spring.profiles.active=prod", "http-clients.payment.base-url=https://payment.test", "http-clients.payment.transport=APACHE", "http-clients.payment.tls.trust-all=true") .run(context -> assertThat(context).hasFailed()); } @Test void bindsNamedProfileAndCreatesTypedRegistry() { runner.withPropertyValues(ProfileProperties.validPayment()) .run(context -> { assertThat(context).hasSingleBean(ClientRuntimeRegistry.class); assertThat(context).hasSingleBean(HttpServiceRegistry.class); }); } } ``` - [ ] **Step 2: Run starter tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-spring-boot-starter:test ``` Expected: FAIL because property binding and auto-configuration are missing. - [ ] **Step 3: Implement typed properties and fail-fast startup** ```java @ConfigurationProperties("http-clients") public record HttpClientsProperties(Map clients) { public HttpClientsProperties { clients = Map.copyOf(clients); } } ``` `HttpClientStartupValidator` aggregates profile, TLS, transport capability, duplicate operation, Dynamic credential, production Simple factory, Retry owner, and HTTP/3 Stable violations and throws one `HttpConfigurationException` with stable violation codes. Actuator exposes only name, generation, transport, protocol, pool state, circuit state, credential type, TLS profile ID, and reload outcome. - [ ] **Step 4: Run starter and complete module tests** ```bash ./gradlew :modules:httpclient:httpclient-spring-boot-starter:test \ :modules:httpclient:httpclient-service-client:test ``` Expected: PASS; `/actuator/httpclients` output contains no base URL, credential, trust path, resolved IP, or secret. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-spring-boot-starter git commit -m "feat: add http client spring boot starter" ``` --- ### Task 33: RestTemplate Migration 호환 계층 구현 **Files:** - Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/RestTemplateInventory.java` - Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/RestTemplateInventoryScanner.java` - Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/RestTemplateToRestClientAdapter.java` - Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/MigrationFinding.java` - Create: `modules/httpclient/httpclient-resttemplate-migration/src/main/java/io/backend/skeleton/httpclient/migration/DeprecatedRestTemplateUsageArchRule.java` - Test: `modules/httpclient/httpclient-resttemplate-migration/src/test/java/io/backend/skeleton/httpclient/migration/RestTemplateToRestClientAdapterTest.java` - Test: `modules/httpclient/httpclient-resttemplate-migration/src/test/java/io/backend/skeleton/httpclient/migration/RestTemplateBoundaryTest.java` **Interfaces:** - Converts existing converter, interceptor, request factory settings into a migration report and RestClient builder. - Does not expose Dynamic Target, HTTP/3, or new resilience features through RestTemplate. - [ ] **Step 1: Write failing behavior parity and boundary tests** ```java class RestTemplateToRestClientAdapterTest { @Test void preservesExistingMessageConvertersAndInterceptors() { RestTemplate template = RestTemplateFixtures.withJsonAndCorrelationInterceptor(); RestClient client = new RestTemplateToRestClientAdapter().adapt(template); assertThat(RestTemplateFixtures.exchangeWith(client)).isEqualTo("ok"); assertThat(RestTemplateFixtures.recordedCorrelationHeader()).isPresent(); } } class RestTemplateBoundaryTest { @Test void productionModulesCannotDependOnMigrationModule() { JavaClasses classes = new ClassFileImporter().importPackages("io.backend.skeleton"); DeprecatedRestTemplateUsageArchRule.rule().check(classes); } } ``` - [ ] **Step 2: Run migration tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-resttemplate-migration:test ``` Expected: FAIL because migration adapter and ArchUnit rule are missing. - [ ] **Step 3: Implement audit-first migration** ```java public final class RestTemplateToRestClientAdapter { public RestClient adapt(RestTemplate template) { return RestClient.builder(template) .build(); } } ``` `RestTemplateInventoryScanner` reports request factory type, converters, interceptors, error handler, URI handler, and timeout gaps. The ArchUnit rule permits RestTemplate only inside the migration module and named legacy packages. - [ ] **Step 4: Run migration and architecture tests** ```bash ./gradlew :modules:httpclient:httpclient-resttemplate-migration:test ``` Expected: PASS; no new production module references `RestTemplate`. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-resttemplate-migration git commit -m "feat: add resttemplate migration path" ``` --- ### Task 34: Spring 7 HTTP Service Group 선택 통합 구현 **Files:** - Create: `modules/httpclient/httpclient-spring7-service-groups/src/main/java/io/backend/skeleton/httpclient/spring7/NamedHttpServiceGroupRegistrar.java` - Create: `modules/httpclient/httpclient-spring7-service-groups/src/main/java/io/backend/skeleton/httpclient/spring7/HttpServiceGroupProfileResolver.java` - Create: `modules/httpclient/httpclient-spring7-service-groups/src/main/java/io/backend/skeleton/httpclient/spring7/Spring7GroupCompatibility.java` - Test: `modules/httpclient/httpclient-spring7-service-groups/src/test/java/io/backend/skeleton/httpclient/spring7/NamedHttpServiceGroupRegistrarTest.java` - Create: `modules/httpclient/httpclient-spring7-service-groups/src/test/resources/application-groups.yml` **Interfaces:** - Compiles only in the Spring 7 compatibility test suite. - Reuses Named Client Profile and operation validation rather than creating a parallel configuration model. - [ ] **Step 1: Write a failing group-to-profile registration test** ```java class NamedHttpServiceGroupRegistrarTest { @Test void registersMultipleInterfacesAgainstOneNamedProfile() { ApplicationContext context = Spring7GroupFixtures.start( "catalog", CatalogClient.class, PriceClient.class); assertThat(context.getBean(CatalogClient.class)).isNotNull(); assertThat(context.getBean(PriceClient.class)).isNotNull(); assertThat(Spring7GroupFixtures.profileFor(CatalogClient.class)).isEqualTo("catalog"); assertThat(Spring7GroupFixtures.profileFor(PriceClient.class)).isEqualTo("catalog"); } } ``` - [ ] **Step 2: Run the Spring 7-only test and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-spring7-service-groups:test \ -PspringFrameworkLine=7.0 ``` Expected: FAIL because the group registrar is missing. - [ ] **Step 3: Implement the optional group adapter** ```java public final class HttpServiceGroupProfileResolver { public ClientProfileName resolve(String groupName) { return new ClientProfileName(groupName); } } ``` The registrar delegates interface validation to `ServiceOperationDescriptorScanner`, obtains the existing profile runtime, and configures the Spring 7 service group with the same RestClient/WebClient instance. It does not compile into the Spring 6.2 distribution. - [ ] **Step 4: Run Spring 6.2 common and Spring 7 group matrices** ```bash ./gradlew spring62CompatibilityTest spring70CompatibilityTest \ :modules:httpclient:httpclient-spring7-service-groups:test \ -PspringFrameworkLine=7.0 ``` Expected: PASS; common artifacts remain free of Spring 7-only class references. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-spring7-service-groups git commit -m "feat: integrate spring7 http service groups" ``` --- ### Task 35: Jetty HTTP/3 Experimental Transport 구현 **Files:** - Create: `modules/httpclient/httpclient-jetty-http3-experimental/src/main/java/io/backend/skeleton/httpclient/http3/JettyHttp3TransportProvider.java` - Create: `modules/httpclient/httpclient-jetty-http3-experimental/src/main/java/io/backend/skeleton/httpclient/http3/Http3ExperimentalAcknowledgement.java` - Create: `modules/httpclient/httpclient-jetty-http3-experimental/src/main/java/io/backend/skeleton/httpclient/http3/JettyHttp3FailureClassifier.java` - Create: `modules/httpclient/httpclient-jetty-http3-experimental/src/main/java/io/backend/skeleton/httpclient/http3/Http3CapabilityReport.java` - Test: `modules/httpclient/httpclient-jetty-http3-experimental/src/test/java/io/backend/skeleton/httpclient/http3/JettyHttp3TransportProviderTest.java` - Test: `modules/httpclient/httpclient-jetty-http3-experimental/src/test/java/io/backend/skeleton/httpclient/http3/Http3OptInTest.java` **Interfaces:** - Requires `experimental=true` and explicit acknowledgement string. - Never auto-configured by the Stable starter. - [ ] **Step 1: Write failing opt-in and QUIC capability tests** ```java class Http3OptInTest { @Test void rejectsHttp3WithoutExplicitAcknowledgement() { ClientProfile profile = ClientProfiles.http3WithoutAcknowledgement(); assertThatThrownBy(() -> new JettyHttp3TransportProvider().create( profile, NoopLifecycleListener.INSTANCE)) .isInstanceOf(HttpConfigurationException.class) .hasMessageContaining("experimental acknowledgement"); } } ``` - [ ] **Step 2: Run HTTP/3 tests and confirm failure** ```bash ./gradlew :modules:httpclient:httpclient-jetty-http3-experimental:test ``` Expected: FAIL because the Experimental provider is missing. - [ ] **Step 3: Implement isolated Jetty HTTP/3 transport** ```java public record Http3ExperimentalAcknowledgement(String value) { public static final String REQUIRED = "I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS"; public Http3ExperimentalAcknowledgement { if (!REQUIRED.equals(value)) { throw new IllegalArgumentException("invalid HTTP/3 experimental acknowledgement"); } } } ``` Create a Jetty QUIC transport with TLS 1.3, separate capability report, and failure classifier. Reuse stable result, error, deadline, retry, observation, and body lifecycle contracts. Keep Dynamic Target disabled in this module. - [ ] **Step 4: Run HTTP/3 tests in the dedicated environment** ```bash ./gradlew :modules:httpclient:httpclient-jetty-http3-experimental:test \ -Phttp3.tests.enabled=true ``` Expected: PASS when QUIC native support is present; otherwise the task fails with a clear missing-capability message rather than silently skipping release verification. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-jetty-http3-experimental git commit -m "feat: add experimental jetty http3 transport" ``` --- ### Task 36: 통합 장애·보안·관측 Contract Suite 구현 **Files:** - Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/BlockingTransportContract.java` - Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/ReactiveTransportContract.java` - Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/RetrySafetyContract.java` - Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/DynamicTargetSecurityContract.java` - Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/ObservabilityContract.java` - Create: `modules/httpclient/httpclient-testkit/src/testFixtures/java/io/backend/skeleton/httpclient/testkit/ResourceLifecycleContract.java` - Create: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/AllStableTransportsContractTest.java` - Create: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/FailureInjectionContractTest.java` - Create: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/SecurityContractTest.java` **Interfaces:** - Executes the same semantic contract against Apache, JDK, and Reactor. - Jetty HTTP/3 uses the subset declared by `Http3CapabilityReport`. - [ ] **Step 1: Write a failing cross-transport contract runner** ```java class AllStableTransportsContractTest { @ParameterizedTest @MethodSource("stableTransports") void notSentConnectFailureHasSameStableMetadata(HttpClientHarness harness) { HttpClientException failure = catchThrowableOfType( () -> harness.callBlackholedTarget(), HttpClientException.class); assertThat(failure.metadata().stage()).isEqualTo(AttemptStage.CONNECT); assertThat(failure.metadata().evidence()).isEqualTo(ExecutionEvidence.NOT_SENT); assertThat(failure.getClass()).isEqualTo(HttpConnectException.class); } } ``` - [ ] **Step 2: Run the contract runner and inspect current differences** ```bash ./gradlew :modules:httpclient:httpclient-testkit:test \ --tests '*AllStableTransportsContractTest' \ --tests '*FailureInjectionContractTest' \ --tests '*SecurityContractTest' ``` Expected: FAIL until every transport produces the same stable metadata and security behavior. - [ ] **Step 3: Implement the complete matrix and fix each adapter to satisfy it** The contract suite must contain executable cases for: ```text all supported methods and URI encoding pool, DNS, connect, TLS, proxy, header, body idle, total deadline GET, PUT, POST with and without idempotency key 408, 425, 429, 500, 502, 503, 504, Retry-After partial request write and partial response body not consumed, close, decode error, cancellation OAuth token cache, concurrent refresh, 401 replay, secret rotation loopback, private, link-local, ULA, metadata, IDNA, DNS rebinding public-to-private redirect and credential leakage full URL metric cardinality and secret redaction shutdown drain and retry suppression ``` Use Toxiproxy for TCP faults, WireMock for protocol status, TLS fixtures for certificate failures, and the HTTP/2 fixture for GOAWAY and REFUSED_STREAM. - [ ] **Step 4: Run the complete stable contract suite** ```bash ./gradlew httpClientStableContractTest \ -Dio.netty.leakDetection.level=paranoid \ -Pblockhound.enabled=true ``` Expected: PASS for Apache, JDK, and Reactor with no leaked connection, buffer, thread, secret, or forbidden metric label. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-testkit \ modules/httpclient/httpclient-transport-apache \ modules/httpclient/httpclient-transport-jdk \ modules/httpclient/httpclient-transport-reactor-netty \ modules/httpclient/httpclient-restclient \ modules/httpclient/httpclient-webclient git commit -m "test: certify http client failure semantics" ``` --- ### Task 37: 부하·Resource·Rotation 성능 인증 구현 **Files:** - Create: `modules/httpclient/httpclient-testkit/src/jmh/java/io/backend/skeleton/httpclient/testkit/BlockingClientBenchmark.java` - Create: `modules/httpclient/httpclient-testkit/src/jmh/java/io/backend/skeleton/httpclient/testkit/ReactiveClientBenchmark.java` - Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/PoolSaturationPerformanceTest.java` - Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/Http2StreamSaturationTest.java` - Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/LargeBodyResourceTest.java` - Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/RetryStormBudgetTest.java` - Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/OAuthRefreshContentionTest.java` - Create: `modules/httpclient/httpclient-testkit/src/performanceTest/java/io/backend/skeleton/httpclient/testkit/RuntimeRotationDrainTest.java` - Create: `docs/httpclient/performance-baseline.md` **Interfaces:** - Produces reproducible performance evidence, not runtime adaptive defaults. - Baseline records configuration, hardware, JVM, transport, protocol, payload, and concurrency. - [ ] **Step 1: Write failing hard resource-bound assertions** ```java class RetryStormBudgetTest { @Test void failedUpstreamCannotMultiplyPhysicalTrafficBeyondBudget() { LoadResult result = LoadHarness.failedUpstream() .logicalCalls(10_000) .retryBudgetRatio(0.10) .run(); assertThat(result.physicalAttempts()).isLessThanOrEqualTo(11_000); } } class LargeBodyResourceTest { @Test void streamingDownloadDoesNotBufferWholePayloadOnHeap() { ResourceSample sample = LoadHarness.download(512 * MEBIBYTE).streaming().run(); assertThat(sample.peakHeapIncrease()).isLessThan(64 * MEBIBYTE); } } ``` - [ ] **Step 2: Run performance tests and capture the failing baseline** ```bash ./gradlew httpClientPerformanceTest \ -Pperformance.assertions.enabled=true ``` Expected: FAIL until pool, streaming, retry, and rotation resource bounds are enforced. - [ ] **Step 3: Tune only explicit profile settings and record the baseline** Set and record: ```text max connections max pending acquires attempt bulkhead HTTP/2 stream concurrency request and response size limits total and stage timeouts retry budget and max attempts runtime drain timeout ``` Do not introduce hidden adaptive defaults. Update `performance-baseline.md` with command, commit, hardware, JVM flags, profile YAML, p50/p95/p99/max, heap, direct memory, threads, connections, attempts, and error count. - [ ] **Step 4: Run the performance certification suite** ```bash ./gradlew httpClientPerformanceTest jmh \ -Pperformance.assertions.enabled=true ``` Expected: PASS within the documented heap, direct memory, thread, connection, retry, and latency bounds. - [ ] **Step 5: Commit** ```bash git add modules/httpclient/httpclient-testkit docs/httpclient/performance-baseline.md git commit -m "perf: certify http client resource bounds" ``` --- ### Task 38: CI Matrix, Support Matrix, Runbook, Release Gate 완성 **Files:** - Create: `.github/workflows/httpclient-contract.yml` - Create: `.github/workflows/httpclient-nightly.yml` - Create: `.github/workflows/httpclient-release.yml` - Create: `docs/httpclient/support-matrix.md` - Create: `docs/httpclient/configuration-reference.md` - Create: `docs/httpclient/retry-and-ambiguity.md` - Create: `docs/httpclient/security.md` - Create: `docs/httpclient/streaming.md` - Create: `docs/httpclient/operations.md` - Create: `docs/httpclient/migration-guide.md` - Create: `docs/httpclient/release-checklist.md` - Create: `scripts/verify-httpclient-docs.py` - Test: `modules/httpclient/httpclient-testkit/src/test/java/io/backend/skeleton/httpclient/testkit/PublicApiArchitectureTest.java` **Interfaces:** - CI gates Spring 6.2·7.0, Apache, JDK, Reactor, HTTP/1.1·2, OAuth2, TLS, Dynamic Target, and fault injection. - HTTP/3 is a separate Experimental nightly job. - [ ] **Step 1: Write failing public API and documentation verification tests** ```java class PublicApiArchitectureTest { @Test void publicApiDoesNotExposeNativeEnginesOrUnsafeBuilders() { JavaClasses classes = new ClassFileImporter() .importPackages("io.backend.skeleton.httpclient"); noClasses().that().resideInAPackage("..api..") .should().dependOnClassesThat() .resideInAnyPackage( "org.apache.hc..", "reactor.netty..", "org.eclipse.jetty..", "java.net.http..", "io.github.resilience4j..") .check(classes); } } ``` `verify-httpclient-docs.py` must fail when a Stable profile, exception, configuration property, metric, or support matrix row exists in code but not in documentation. - [ ] **Step 2: Run final verification before CI files are complete** ```bash ./gradlew :modules:httpclient:httpclient-testkit:test \ --tests '*PublicApiArchitectureTest' python scripts/verify-httpclient-docs.py ``` Expected: FAIL because CI workflows and complete documentation are missing. - [ ] **Step 3: Add CI jobs and exact release commands** `httpclient-contract.yml` runs on every PR: ```yaml jobs: stable-contract: strategy: matrix: spring-line: ["6.2", "7.0"] transport: [apache, jdk, reactor] steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: "21" - run: ./gradlew httpClientStableContractTest -PspringFrameworkLine=${{ matrix.spring-line }} -Phttpclient.contract.transport=${{ matrix.transport }} ``` Nightly runs Toxiproxy, mTLS rotation, HTTP/2 failure, performance smoke, and HTTP/3 Experimental. Release runs all tests, documentation verifier, support matrix verifier, and dependency report. - [ ] **Step 4: Run the complete release gate** ```bash ./gradlew clean \ test \ spring62CompatibilityTest \ spring70CompatibilityTest \ httpClientStableContractTest \ httpClientSecurityTest \ httpClientFailureInjectionTest \ httpClientPerformanceTest python scripts/verify-httpclient-docs.py ``` Expected: PASS with zero failed test, zero documentation drift, zero forbidden dependency, and zero secret/cardinality violation. - [ ] **Step 5: Commit** ```bash git add .github/workflows docs/httpclient scripts/verify-httpclient-docs.py \ modules/httpclient/httpclient-testkit git commit -m "docs: finalize http client release gates" ``` --- ## 3. Plan Self-Review Checklist Before execution begins, verify the plan against the design using the following checklist. - [ ] Every design decision D-01 through D-18 maps to at least one Task. - [ ] H1, H2, H3, and H4 exposure rules are enforced by code or ArchUnit. - [ ] Apache, JDK, Reactor, and Experimental Jetty modules have explicit capability matrices. - [ ] `ExecutionEvidence`, `BodyReplayability`, and `OperationIdempotency` signatures are consistent across Tasks. - [ ] Retry Eligibility is a pure decision and Retry Coordinator performs timing and attempts. - [ ] Circuit → Rate Limiter → Bulkhead order is tested. - [ ] total deadline includes Retry backoff and shutdown suppresses new retries. - [ ] response body lifecycle is tested for success, partial read, error, size rejection, and cancel. - [ ] OAuth2 single-flight and 401 maximum-one-replay rules are tested. - [ ] TLS trust-all and hostname verification bypass are impossible to configure. - [ ] Dynamic Target validates every resolved address and pins the actual connection. - [ ] cross-origin redirect strips credentials. - [ ] first-byte delivery disables transparent Retry. - [ ] full URL and secret values cannot become low-cardinality tags. - [ ] Spring 6.2 common and Spring 7 optional paths are separate. - [ ] RestTemplate is limited to the migration module. - [ ] HTTP/3 requires explicit Experimental acknowledgement. - [ ] final CI executes contract, security, failure, compatibility, performance, and documentation gates. ## 4. Execution Handoff Implementation must begin with Task 1 and proceed in order. The recommended execution mode is `superpowers:subagent-driven-development`: one fresh implementation agent per Task, followed by a requirements review and a code-quality review before the next Task begins. An inline execution session may instead use `superpowers:executing-plans`, but it must retain the same red-green-commit boundaries and release gates.