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