Files
clean-architecture-backend-…/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md
T

3636 lines
155 KiB
Markdown

# 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<Test>().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<InputStream> 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<String, ?> uriVariables,
Map<String, List<String>> headers,
BodySource body,
OperationIdempotency idempotency,
Optional<IdempotencyKey> idempotencyKey,
Optional<Instant> 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<T>` 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<String> allowedHosts,
Set<Integer> allowedPorts,
ClientApiType api,
TransportType transport,
Set<HttpProtocol> 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<ClientProfileName, AtomicReference<ClientRuntime>> 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<AttemptBudget> 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<Instant> 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<String> PLATFORM_OWNED = Set.of(
"authorization", "proxy-authorization", "host", "content-length",
"transfer-encoding", "traceparent", "tracestate", "baggage", "cookie");
public Map<String, List<String>> validate(Map<String, List<String>> 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<String> 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 `<T> HttpCallResult<T> exchange(ClientProfileName, HttpOperation, ResponseType<T>)`.
- 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<UserResponse> 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 <T> HttpCallResult<T> exchange(ClientProfileName profileName,
HttpOperation operation,
ResponseType<T> 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> T client(ClientProfileName, Class<T>)`.
- 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> T client(ClientProfileName profileName, Class<T> serviceType) {
List<ServiceOperationDescriptor> 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<String> 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 <T> HttpCallResult<T> execute(BlockingLogicalCall<T> call) {
for (int attempt = 1; ; attempt++) {
AttemptOutcome<T> 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> T execute(CheckedSupplier<T> 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<Future<AccessToken>> futures = IntStream.range(0, 20)
.mapToObj(i -> pool.submit(() -> loader.load(TokenKeys.payment())))
.toList();
for (Future<AccessToken> 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<OAuth2TokenCacheKey, CompletableFuture<AccessToken>> inFlight =
new ConcurrentHashMap<>();
public AccessToken load(OAuth2TokenCacheKey key) {
CompletableFuture<AccessToken> 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<String> protocols,
boolean hostnameVerification,
TrustMaterialRef trustMaterial,
Optional<KeyMaterialRef> 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<String, List<String>> 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<HttpCallResult<T>> 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<HttpCallResult<UserResponse>> 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<HttpCallResult<String>> 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 <T> Mono<HttpCallResult<T>> execute(ReactiveLogicalCall<T> call) {
return attempt(call, 1);
}
private <T> Mono<HttpCallResult<T>> attempt(ReactiveLogicalCall<T> 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<EventResponse> 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<ServerSentEvent<T>> 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 <T> Flux<ServerSentEvent<T>> connect(ClientProfileName profile,
SseOperation operation,
ResponseType<T> 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<String> 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<String, ClientProperties> 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.