Files
tech-log-backend/src/adapter/outbound/httpclient/build.gradle
T
DongHyeonkaandClaude Opus 5 0cd959a494 feat(httpclient): close the platform review's P0/P1/P2 findings
The review found one defect shape repeated across the platform: surfaces
that were declared, bound, and documented, but that nothing read. An
operator configuring fullUrlRecording, bodyLogging, retry.policy,
validatedDnsPinning, timeout.dns, or any of ten declared metric names got a
guarantee the code never delivered. Every such surface is now in exactly one
of three states -- wired for real, rejected at startup, or registered in a
test-enforced gap list with its reason. No silent no-ops remain.

P0:
- Activate the platform from bootstrap behind app.httpclient.enabled, with a
  single auto-configuration importing the nine child configurations.
- Give the platform a strict, repository-level ENV contract: 74 leaf fields
  derived from the settings record tree, unknown APP_HTTPCLIENT_* rejected.
- Route typed HTTP service clients through the call kernel via
  KernelHttpExchangeAdapter, so they stop bypassing platform policy.
- Pin dynamic-target DNS resolution to the socket for the life of a call,
  closing the resolve-then-connect TOCTOU / rebinding window.
- Actually transmit the idempotency key, and make retry eligibility depend on
  transmission rather than on merely holding one.
- Reject reactive authentication and reactive redirect at startup instead of
  declaring support that does not function.
- Fix the Reactor-only Stable contract row so the lane stops failing.
- Stop advertising HTTP/3 on a transport that negotiates HTTP/1.

P1 covers execution and retry accounting, redirect security (per-hop target
guarding, sensitive-header stripping, 303 body handling), runtime rotation
and transport resource ownership keyed by generation, dynamic-target
hardening (subdomain matching, global-unicast classification, strict CIDR
parsing), protocol intent, pool and timeout wiring, streaming and body
limits, observability parity, and OAuth single-flight refresh on a bounded
pool with a bounded wait.

P2 covers configuration and documentation drift, the Gradle check wiring for
the four hermetic lanes, and the CI gate matrix.

Two test-quality defects surfaced while closing these: the HTTP/2 stream
saturation test ran against cleartext HTTP/1.1 while asserting nothing about
the protocol, and an OAuth contention test slept on a latch that could fire
before the callers it meant to observe. Both now assert what their names
claim.

Verification run: :adapter:outbound:httpclient:check and :app-bootstrap:check
(checkstyle, spotless, spotbugs, and the four hermetic lanes),
verifyCleanArchitectureDependencies, verifyEnvKeys, verifyOneTypePerFile,
verifyDependencyLocks, the documentation and gate-matrix verifiers, and the
performance lane against a real TLS+ALPN HTTP/2 server.

Not executed, and tracked rather than claimed: Docker/Toxiproxy fault
injection, JMH, a real QUIC/HTTP3 server, a real Spring Framework 6.2
distribution (now a delegated-pending gate), live OAuth/TLS/proxy/DNS
integration, and a whole-repository check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 16:49:31 +09:00

243 lines
12 KiB
Groovy

// Outbound HTTP Client Platform leaf — see
// docs/superpowers/specs/2026-08-08-httpclient-platform-design.md (design package) and
// docs/httpclient/repository-adaptation.md (how the design's 19 library modules map here).
//
// The design models the platform as 19 separate Gradle modules. This repository's fail-closed
// 19-leaf registry (src/config/architecture/modules.json) outranks that layout, so the module
// boundaries are packages under dev.caskeleton.adapter.outbound.httpclient and
// HttpClientModuleBoundaryTest enforces the design's module dependency table.
description = 'Outbound adapter: HTTP client platform (typed clients, profiles, evidence-based retry)'
dependencies {
implementation project(':application-core')
implementation project(':shared-contract')
implementation project(':adapter:outbound:support')
// Spring client layer. `httpclient-core-api` must not reach these; ArchUnit enforces it.
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.springframework:spring-web'
implementation 'org.springframework:spring-webflux'
implementation 'io.projectreactor:reactor-core'
// Transport providers. Apache HC5 is the blocking default, JDK HttpClient is the lightweight
// alternative (JDK built-in), Reactor Netty is the reactive default, Jetty carries the
// Experimental HTTP/3 transport that the Stable starter never auto-configures.
implementation 'org.apache.httpcomponents.client5:httpclient5'
implementation 'io.projectreactor.netty:reactor-netty-http'
implementation 'org.eclipse.jetty:jetty-client'
// HTTP/3 is Experimental and off by default, so its transport is compileOnly plus a test
// dependency rather than a runtime one. It used to be `implementation`, which put the whole
// QUIC/HTTP-3/QPACK stack on every deployment's runtime classpath — megabytes and an attack
// surface — to serve a feature the Stable starter never auto-configures. A deployment that
// opts into HTTP/3 adds `org.eclipse.jetty.http3:jetty-http3-client-transport` itself, and
// Http3CapabilityReport already refuses the transport when those classes are absent, so the
// failure mode is a startup error rather than a NoClassDefFoundError mid-call.
compileOnly 'org.eclipse.jetty.http3:jetty-http3-client-transport'
testImplementation 'org.eclipse.jetty.http3:jetty-http3-client-transport'
// Resilience4j supplies the execution primitives only. HTTP retry *eligibility* is owned by
// this module (design D-09) and never delegated to a generic retry library.
implementation 'io.github.resilience4j:resilience4j-retry:2.2.0'
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
implementation 'io.github.resilience4j:resilience4j-ratelimiter:2.2.0'
implementation 'io.github.resilience4j:resilience4j-bulkhead:2.2.0'
implementation 'io.github.resilience4j:resilience4j-micrometer:2.2.0'
implementation 'org.springframework.security:spring-security-oauth2-client'
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'io.micrometer:micrometer-core'
implementation 'org.slf4j:slf4j-api'
// Testkit dependencies (design §28.1 test topology). They are test-scoped so no production
// module can depend on the testkit.
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
testImplementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testImplementation 'org.testcontainers:testcontainers-toxiproxy'
testImplementation 'io.projectreactor:reactor-test'
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
testImplementation 'io.projectreactor.tools:blockhound:1.0.17.RELEASE'
}
// Performance certification and JMH benchmarks are separate source sets: they are slow, they assert
// on resource bounds rather than behaviour, and they must never be part of the default unit lane.
sourceSets {
httpClientPerformanceTest {
java.srcDir 'src/httpClientPerformanceTest/java'
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += output + compileClasspath
}
jmh {
java.srcDir 'src/jmh/java'
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += output + compileClasspath
}
}
configurations {
httpClientPerformanceTestImplementation.extendsFrom testImplementation
httpClientPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
jmhImplementation.extendsFrom testImplementation
jmhRuntimeOnly.extendsFrom testRuntimeOnly
}
dependencies {
jmhImplementation 'org.openjdk.jmh:jmh-core:1.37'
jmhAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
// JMH generates its harness classes at compile time. They are not our source, so the
// compile-time checker and -Werror are switched off for that source set only; applying them
// would fail the build on generated code we cannot edit.
tasks.named('compileJmhJava', JavaCompile) {
options.errorprone.enabled = false
options.compilerArgs.removeIf { it == '-Werror' }
}
// The bytecode analyser is disabled for the same generated harness, for the same reason.
tasks.named('spotbugsJmh') {
enabled = false
}
Closure<Void> applyContractSelection = { Test task ->
// Cross-transport contract lane. The same semantic contract runs against every Stable transport;
// the transport under test is selected explicitly so a missing transport is an error, not a skip.
task.systemProperty 'httpclient.contract.transports',
(project.findProperty('httpclient.contract.transports') ?: 'apache,jdk,reactor').toString()
// Netty's strictest leak detector is on for every lane. It is only meaningful if it is actually
// live, so NettyLeakDetectionExtension asserts the level rather than trusting the flag reached
// the forked JVM.
task.systemProperty 'io.netty.leakDetection.level', 'paranoid'
// HTTP/3 is Experimental: it is never part of the default lane and never silently skipped.
task.systemProperty 'httpclient.http3.tests.enabled',
(project.findProperty('http3.tests.enabled') ?: 'false').toString()
}
tasks.named('test', Test) {
applyContractSelection(it)
// Two lanes are excluded from the default run for opposite reasons: the fault lane needs Docker
// and fails closed without it, and the BlockHound lane rewrites core JDK bytecode, which must
// not be imposed on every unit run.
useJUnitPlatform {
excludeTags 'quarantine', 'httpclient-fault', 'httpclient-blockhound'
}
}
tasks.register('httpClientBlockHoundTest', Test) {
group = 'verification'
description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-blockhound' }
applyContractSelection(it)
// BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them.
jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods'
// The lane exists to run BlockHound. Discovering nothing means it did not, which is a failure.
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('httpClientStableContractTest', Test) {
group = 'verification'
description = 'Runs the cross-transport stable contract suite (design §28.2, §33).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-contract' }
applyContractSelection(it)
outputs.upToDateWhen { false }
}
tasks.register('httpClientSecurityTest', Test) {
group = 'verification'
description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-security' }
applyContractSelection(it)
outputs.upToDateWhen { false }
}
tasks.register('httpClientFailureInjectionTest', Test) {
group = 'verification'
description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker (design §28.3).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-fault' }
applyContractSelection(it)
// The upstream image is mutable by default. Passing a digest here is what makes a red fault
// run attributable to this repository rather than to someone else's image push.
systemProperty 'httpclient.fault.httpbin.image',
(project.findProperty('httpclient.fault.httpbin.image') ?: 'kennethreitz/httpbin:latest').toString()
// A fault suite that never injected a fault must not report success, so a selected lane with no
// discovered test is an error rather than an empty pass.
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('httpClientPerformanceTest', Test) {
group = 'verification'
description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).'
testClassesDirs = sourceSets.httpClientPerformanceTest.output.classesDirs
classpath = sourceSets.httpClientPerformanceTest.runtimeClasspath
useJUnitPlatform()
applyContractSelection(it)
systemProperty 'performance.assertions.enabled',
(project.findProperty('performance.assertions.enabled') ?: 'false').toString()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('jmh', JavaExec) {
group = 'verification'
description = 'Runs the JMH benchmarks for the blocking and reactive clients (design §28.8).'
mainClass = 'org.openjdk.jmh.Main'
classpath = sourceSets.jmh.runtimeClasspath
args '-rf', 'json', '-rff', layout.buildDirectory.file('reports/jmh/result.json').get().asFile.absolutePath
}
// Spring 6.2 / 7.0 compatibility lanes. This repository's Spring Boot 4.0 baseline pins Spring
// Framework 7, so the 6.2 lane verifies the *API surface* the common packages compile against
// rather than executing on a 6.2 distribution; the limitation is recorded in
// docs/httpclient/support-matrix.md instead of being hidden behind a green check.
tasks.register('spring62ApiSurfaceScan', Test) {
group = 'verification'
description = 'Scans the common packages for Spring 6.2 API-surface confinement. NOT a 6.2 runtime.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-spring62-surface' }
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
// Which lanes gate an ordinary build, and which do not.
//
// The specialised lanes existed but hung off nothing: `check` ran only `test`, so the SSRF suite,
// the BlockHound lane, the cross-transport contract and the Spring 6.2 surface scan were green in
// CI only because a workflow happened to name them, and green locally because nobody ran them.
// The four below are hermetic and fast — no Docker, no network, no machine-dependent thresholds —
// so they belong in `check`.
//
// httpClientFailureInjectionTest (needs Docker), httpClientPerformanceTest (machine-dependent
// bounds) and jmh (minutes) stay out deliberately. Attaching them would make `check` fail on a
// laptop without Docker, which teaches people to skip `check`.
tasks.named('check') {
dependsOn 'httpClientStableContractTest',
'httpClientSecurityTest',
'httpClientBlockHoundTest',
'spring62ApiSurfaceScan'
}
tasks.register('spring70CompatibilityTest', Test) {
group = 'verification'
description = 'Runs the contract suite on the repository Spring 7 baseline (design §29).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-contract' }
applyContractSelection(it)
outputs.upToDateWhen { false }
}