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>
243 lines
12 KiB
Groovy
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 }
|
|
}
|