Files
tech-log-backend/httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md
T

155 KiB

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. 확정 파일 구조

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. 핵심 패키지

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

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:

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:

./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:

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
./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
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

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
./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
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
./gradlew :modules:httpclient:httpclient-core-api:test \
  --tests '*CoreValueTypeTest'

Expected: PASS.

  • Step 5: Commit
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

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
./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
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
./gradlew :modules:httpclient:httpclient-core-api:test \
  --tests '*BodyReplayabilityTest'

Expected: PASS.

  • Step 5: Commit
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

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
./gradlew :modules:httpclient:httpclient-core-api:test \
  --tests '*HttpOperationTest'

Expected: FAIL because HttpOperation and HttpCallResult are missing.

  • Step 3: Implement immutable invariants
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
./gradlew :modules:httpclient:httpclient-core-api:test \
  --tests '*HttpOperationTest'

Expected: PASS.

  • Step 5: Commit
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

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
./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
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
./gradlew :modules:httpclient:httpclient-core-api:test \
  --tests '*StableExceptionTest'

Expected: PASS.

  • Step 5: Commit
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

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
./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
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
./gradlew :modules:httpclient:httpclient-profile:test

Expected: PASS; violation order is deterministic and sorted by code.

  • Step 5: Commit
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

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
./gradlew :modules:httpclient:httpclient-profile:test \
  --tests '*ClientRuntimeRegistryTest'

Expected: FAIL because runtime lifecycle types are absent.

  • Step 3: Implement reference-counted runtime generations
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
./gradlew :modules:httpclient:httpclient-profile:test \
  --tests '*ClientRuntimeRegistryTest'

Expected: PASS with no leaked scheduled executor thread.

  • Step 5: Commit
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

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
./gradlew :modules:httpclient:httpclient-transport-spi:test \
  --tests '*TransportCapabilityValidatorTest'

Expected: FAIL because provider and capability contracts are missing.

  • Step 3: Implement the provider contracts
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
./gradlew :modules:httpclient:httpclient-transport-spi:test

Expected: PASS.

  • Step 5: Commit
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

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
./gradlew :modules:httpclient:httpclient-testkit:test \
  --tests '*MockHttpServerTest'

Expected: FAIL because the fixture classes are missing.

  • Step 3: Implement MockWebServer-backed fixtures
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
./gradlew :modules:httpclient:httpclient-testkit:test

Expected: PASS and no listening socket remains after the test.

  • Step 5: Commit
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

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
./gradlew :modules:httpclient:httpclient-resilience:test \
  --tests '*DeadlineCalculatorTest'

Expected: FAIL because deadline types are absent.

  • Step 3: Implement monotonic budget calculations
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
./gradlew :modules:httpclient:httpclient-resilience:test \
  --tests '*DeadlineCalculatorTest'

Expected: PASS.

  • Step 5: Commit
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

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
./gradlew :modules:httpclient:httpclient-security:test \
  --tests '*TrustedRequestPolicyTest'

Expected: FAIL because the request policy pipeline is missing.

  • Step 3: Implement strict preparation rules
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
./gradlew :modules:httpclient:httpclient-security:test

Expected: PASS.

  • Step 5: Commit
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

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
./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
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
./gradlew :modules:httpclient:httpclient-observability:test

Expected: PASS.

  • Step 5: Commit
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

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
./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
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
./gradlew :modules:httpclient:httpclient-transport-apache:test

Expected: PASS; after every test the connection manager reports zero leased connections.

  • Step 5: Commit
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

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
./gradlew :modules:httpclient:httpclient-transport-jdk:test

Expected: FAIL because JDK transport classes are absent.

  • Step 3: Implement JDK transport with conservative capabilities
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
./gradlew :modules:httpclient:httpclient-transport-jdk:test

Expected: PASS; unsupported capability profiles fail before a network call.

  • Step 5: Commit
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

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
./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
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
./gradlew :modules:httpclient:httpclient-restclient:test \
  -Phttpclient.contract.transports=apache,jdk

Expected: PASS for Apache and JDK contract variants.

  • Step 5: Commit
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

@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
./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
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
./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
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

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
./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
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
./gradlew :modules:httpclient:httpclient-resilience:test \
  --tests '*ExecutionEvidenceClassifierTest'

Expected: PASS.

  • Step 5: Commit
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

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
./gradlew :modules:httpclient:httpclient-resilience:test \
  --tests '*RetryEligibilityEngineTest'

Expected: FAIL because retry decision types are absent.

  • Step 3: Implement the complete ordered decision table
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
./gradlew :modules:httpclient:httpclient-resilience:test \
  --tests '*RetryEligibilityEngineTest'

Expected: PASS; test parameterization covers all documented status and evidence combinations.

  • Step 5: Commit
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

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
./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
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
./gradlew :modules:httpclient:httpclient-resilience:test \
  --tests '*BlockingRetryCoordinatorTest' \
  --tests '*RetryBudgetTest'

Expected: PASS.

  • Step 5: Commit
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

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
./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
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
./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
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

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
./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
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
./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
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

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
./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
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
./gradlew :modules:httpclient:httpclient-auth:test

Expected: PASS; test logs contain no access token, client secret, or authorization code.

  • Step 5: Commit
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

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
./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
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
./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
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

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
./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
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
./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
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

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
./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
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
./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
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

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
./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
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
./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
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

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
./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
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
./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
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

@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
./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
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
./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
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

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
./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
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
./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
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

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
./gradlew :modules:httpclient:httpclient-webclient:test \
  --tests '*ReactiveSseGatewayTest'

Expected: FAIL because SSE contracts are missing.

  • Step 3: Implement setup and stream-phase policies
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
./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
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

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
./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
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
./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
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

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
./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
@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
./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
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

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
./gradlew :modules:httpclient:httpclient-resttemplate-migration:test

Expected: FAIL because migration adapter and ArchUnit rule are missing.

  • Step 3: Implement audit-first migration
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
./gradlew :modules:httpclient:httpclient-resttemplate-migration:test

Expected: PASS; no new production module references RestTemplate.

  • Step 5: Commit
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

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
./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
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
./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
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

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
./gradlew :modules:httpclient:httpclient-jetty-http3-experimental:test

Expected: FAIL because the Experimental provider is missing.

  • Step 3: Implement isolated Jetty HTTP/3 transport
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
./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
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

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
./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:

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
./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
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

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
./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:

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
./gradlew httpClientPerformanceTest jmh \
  -Pperformance.assertions.enabled=true

Expected: PASS within the documented heap, direct memory, thread, connection, retry, and latency bounds.

  • Step 5: Commit
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

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
./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:

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
./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
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.