chore: record pre-existing uncommitted repository state

Snapshot of the in-flight state that already existed, identically, in both
this worktree and the main checkout before this session began: the initial
HTTP Client platform implementation (previously untracked), the redis-lab
removal, and the JPA / object-storage / notification integration work.

Kept separate from this session's HTTP Client review response, which lands
in the following commit, so the two bodies of work stay reviewable apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-11 16:48:43 +09:00
co-authored by Claude Opus 5
parent 1a3b560678
commit 5f10b791d3
1857 changed files with 130925 additions and 72491 deletions
+121
View File
@@ -0,0 +1,121 @@
# HTTP Client Platform — Operations Runbook
## Metrics
| Metric | Meaning |
|---|---|
| `http.client.requests` | Physical attempt timer (Spring standard name, kept deliberately) |
| `http.client.logical.calls` | User-visible logical call timer |
| `http.client.attempts` | Attempt counter |
| `http.client.retry.count` | Retries by reason |
| `http.client.retry.exhausted` | Retry budget exhausted |
| `http.client.ambiguous` | Ambiguous outcomes |
| `http.client.timeout` | Timeouts by stage |
| `http.client.request.bytes` | Request wire bytes |
| `http.client.response.bytes` | Response bytes |
| `http.client.active` | In-flight attempts |
| `http.client.pool.connections` | Leased and available connections |
| `http.client.pool.pending` | Pool waiters |
| `http.client.pool.acquire.duration` | Pool wait time |
| `http.client.dns.duration` | DNS time |
| `http.client.connect.duration` | Connect time |
| `http.client.tls.duration` | TLS time |
| `http.client.circuit.state` | Circuit state |
| `http.client.bulkhead.rejected` | Bulkhead rejections |
| `http.client.rate_limit.rejected` | Local rate-limit rejections |
| `http.client.oauth.refresh` | Token refresh outcomes |
| `http.client.ssrf.rejected` | Dynamic target rejections |
`http.client.requests` counts attempts and `http.client.logical.calls` counts user calls. When they
diverge, retries are absorbing failures — which is the first thing to look at during an incident.
## Reading an incident
| Symptom | Likely cause | Where to look |
|---|---|---|
| logical calls fine, attempts spiking | upstream degraded, retries absorbing it | `http.client.retry.count` by reason |
| `http.client.ambiguous` non-zero | non-idempotent writes reaching `SENT_NO_RESPONSE` | reconcile with the upstream; consider an idempotency key |
| pool pending climbing | pool too small or upstream slow | `http.client.pool.acquire.duration`, `pool.connections` |
| circuit open | sustained upstream failure | `http.client.circuit.state`; local rejections do not open it |
| `http.client.ssrf.rejected` non-zero | a caller is submitting internal URLs | Dynamic Target policy and audit trail |
## Actuator
`GET /actuator/httpclients` reports profile name, runtime generation, state, transport, API,
protocols, active leases, pool ceiling, credential type, TLS profile id, redirect flag, retry policy,
and capability warnings. Base URL, credentials, trust store paths, and resolved IPs are deliberately
absent: an actuator endpoint is reachable by more people than a secret store is.
## Rotation
Certificates and secrets rotate by building a new runtime generation and swapping the registry
pointer, never by mutating a live client. A connection pool holds sockets established under the
previous identity, so replacing material without replacing the pool leaves live connections
authenticated by a certificate that is meant to be gone.
```text
build new generation → validate → atomic swap → new calls use it
old generation → DRAINING → in-flight calls finish → no new retries → forced close at the drain deadline
```
## Shutdown
```text
RUNNING → DRAINING
new logical calls refused or routed to the new generation
in-flight attempts complete
new retries refused
shutdown timeout
remaining calls cancelled
pool closed
```
## Retry ownership
Exactly one of the application client, an external SDK, or the service mesh may own retries.
Two owners multiply traffic during an incident. Record the owner per upstream and check it whenever
a mesh retry policy changes.
## Error model
Every outbound failure is one of these stable types. The type is derived from the classified failure
category, not from whatever the engine happened to throw, so it means the same thing on Apache, JDK,
and Reactor Netty. Each carries `HttpFailureMetadata`: client, operation, method, URI **template**,
evidence, replayability, stage, retryability, attempt, elapsed, remaining deadline, status, trace id
— and nothing else.
| Exception | Raised when | Retryable |
|---|---|---|
| `HttpConfigurationException` | profile, operation, or capability configuration is invalid | never |
| `HttpTargetRejectedException` | target URI, host, port, header, or address policy refused the request | never |
| `HttpDnsException` | hostname resolution failed or timed out | yes, inside budget |
| `HttpPoolAcquireTimeoutException` | no connection or stream within the pending-acquire budget | yes, inside budget |
| `HttpConnectException` | socket connect failed | yes, inside budget |
| `HttpProxyException` | proxy connect, CONNECT tunnel, or proxy auth failed | yes, inside budget |
| `HttpTlsException` | TLS handshake failed | only a transient handshake timeout |
| `HttpRequestWriteException` | request headers or body could not be fully written | only when safely idempotent |
| `HttpResponseTimeoutException` | final headers or a body chunk did not arrive in time | only when safely idempotent |
| `HttpResponseTruncatedException` | the response ended before the body was complete | only when safely idempotent and undelivered |
| `HttpRemoteErrorException` | non-success status without a problem document | per the status rules |
| `HttpProblemDetailException` | non-success status with a bounded RFC 9457 document | per the status rules |
| `HttpRedirectRejectedException` | a hop violated hop count, origin, method, or replay policy | never |
| `HttpAuthenticationException` | credential materialization or refresh failed | never |
| `HttpSerializationException` | request encoding or response decoding failed | never |
| `HttpResponseTooLargeException` | wire or decoded bytes exceeded the profile limit | never |
| `HttpDeadlineExceededException` | the effective deadline was reached | never |
| `HttpCircuitOpenException` | the upstream circuit is open | never |
| `HttpBulkheadRejectedException` | no attempt or logical admission permit was available | never |
| `HttpRateLimitRejectedException` | the local attempt rate limit or retry budget rejected the attempt | never |
| `HttpAmbiguousExecutionException` | a non-idempotent request was sent and the outcome is unknown | never — reconcile instead |
## Traces
```text
http.client.operation logical internal span
└─ http.client.request attempt 1 CLIENT span
└─ http.client.request attempt 2 CLIENT span
```
W3C Trace Context is propagated with a Baggage allowlist. Dynamic Targets do not propagate trace
context by default. Retry reason and evidence are recorded as span events; credentials and remote
error bodies are never recorded as attributes.
+55
View File
@@ -0,0 +1,55 @@
# HTTP Client Platform — Performance Baseline
The certification lane asserts **resource bounds**, not throughput targets. Its purpose is to prove
that a failing upstream, a large body, or a rotation cannot consume unbounded memory, connections,
threads, or upstream traffic. Nothing here becomes a runtime adaptive default: every bound comes
from an explicit profile setting.
## How to run
```bash
# structural bounds only (default; still executes every test)
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --console=plain
# full certification, including machine-dependent bounds
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest \
-Pperformance.assertions.enabled=true --console=plain
# JMH benchmarks
./gradlew :adapter:outbound:httpclient:jmh --console=plain
```
Machine-dependent assertions are reported as explicitly skipped when the flag is absent — the lane
never silently degrades into a pass.
## Certified bounds
| Test | Bound | Kind |
|---|---|---|
| `RetryStormBudgetTest` | 10 000 logical calls against a failing upstream produce at most 11 000 physical attempts at a 10 % budget | structural |
| `LargeBodyResourceTest` | a 32 MiB streaming download consumes every byte without buffering the payload on the heap | structural + machine-dependent heap bound |
| `PoolSaturationPerformanceTest` | 24 concurrent calls against a 4-connection pool all reach a terminal outcome; none hang | structural |
| `Http2StreamSaturationTest` | 32 concurrent reactive streams share a 2-connection pool and complete | structural |
| `OAuthRefreshContentionTest` | 100 genuinely concurrent callers produce exactly one token request | structural |
| `RuntimeRotationDrainTest` | 50 rotations close all 50 retired generations and leave no drain thread | structural |
## Recording a baseline
When certifying a deployment, record alongside the numbers: the exact command, the commit, hardware,
JVM flags, the profile YAML under test, p50/p95/p99/max, peak heap, peak direct memory, thread count,
connection count, physical attempt count, and error count. A latency figure without its profile and
hardware is not a baseline; it is an anecdote.
| Field | Value |
|---|---|
| Command | _fill in at certification time_ |
| Commit | _fill in_ |
| Hardware / JVM | _fill in_ |
| Profile under test | _fill in_ |
| p50 / p95 / p99 / max | _fill in_ |
| Peak heap / direct memory | _fill in_ |
| Threads / connections | _fill in_ |
| Physical attempts / errors | _fill in_ |
The table is intentionally left unfilled in the repository: publishing numbers measured on a build
agent as if they were a certified baseline would be worse than having none.
+47
View File
@@ -0,0 +1,47 @@
# HTTP Client Platform — Release Checklist
A release is complete when each item below is demonstrated by a command, not by review.
## Gates
```bash
cd src
./gradlew :adapter:outbound:httpclient:test --console=plain
./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --console=plain
./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --console=plain
./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --console=plain
./gradlew :adapter:outbound:httpclient:spring62CompatibilityTest --console=plain
./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --console=plain
./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --console=plain # needs Docker
./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest \
-Pperformance.assertions.enabled=true --console=plain
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
python3 ../scripts/verify-httpclient-docs.py
```
## Completion criteria (design §33)
- [ ] Typed clients are the default entry point; H2 and H3 are separately authorised.
- [ ] H1H4 cannot bypass timeout, host, TLS, auth, size, or observation policy.
- [ ] Apache, JDK, and Reactor produce identical result and exception metadata.
- [ ] Pool, DNS, connect, TLS, and retry backoff all fit inside the effective deadline.
- [ ] Every extra attempt is explained by idempotency, replayability, evidence, deadline, and budget.
- [ ] Non-idempotent `SENT_NO_RESPONSE` surfaces as `HttpAmbiguousExecutionException`.
- [ ] Pool and buffers are reclaimed after unread bodies, decode errors, cancels, and size rejections.
- [ ] OAuth2 refresh is single-flight and 401 replay happens at most once.
- [ ] Trust-all and hostname-verification bypass fail at startup.
- [ ] Canonicalisation, DNS/IP validation, redirect revalidation, and egress control all pass.
- [ ] No transparent retry occurs after the first delivered byte.
- [ ] No platform code blocks a Reactor event loop, proven by a BlockHound self-check.
- [ ] The negotiated wire protocol matches what the support matrix claims per transport.
- [ ] Logical calls and attempts are separate metrics with no forbidden label.
- [ ] DNS, pool, TLS, reset, partial response, and HTTP/2 GOAWAY are reproducible.
- [ ] Thread, heap, direct memory, pool, and retry budget bounds hold.
- [ ] The support matrix, configuration reference, security guide, runbook, and migration guide match the code.
## Experimental
Jetty HTTP/3 stays Experimental until `Http3CapabilityReport` reports QUIC and TLS 1.3 and the
contract subset it declares passes in a dedicated environment. It is never auto-configured by the
Stable starter.
+71
View File
@@ -0,0 +1,71 @@
# Retry and Ambiguity
The platform never decides a retry from the HTTP method alone (design D-09). A second attempt
happens only when idempotency, body replayability, execution evidence, deadline, and retry budget
all permit it.
## Execution evidence
| Evidence | Meaning | Typical cause |
|---|---|---|
| `NOT_SENT` | Proven that the server never received the request | profile rejection, pool timeout, DNS failure, connect failure, pre-request TLS failure, HTTP/2 `REFUSED_STREAM` |
| `SENT_NO_RESPONSE` | Some or all of the request was written, no final header arrived | partial write, response-header timeout, connection reset |
| `RESPONSE_RECEIVED` | Final headers arrived, whatever the status | 2xx, 4xx, 5xx, redirect |
| `PARTIAL_RESPONSE` | Headers and part of the body arrived | reset during decode, interrupted stream |
`NOT_SENT` is only produced by a stage failure that proves it. A generic engine I/O error is never
upgraded to `NOT_SENT`, because that is exactly how a timeout becomes a duplicate payment.
## Body replayability
| Body | Replayability |
|---|---|
| immutable `byte[]` | `REPLAYABLE` |
| DTO plus a deterministic codec | `REPLAYABLE` |
| reopenable file or resource supplier | `REOPENABLE` |
| a single `InputStream` instance | `ONE_SHOT` |
| publisher factory | as declared |
| publisher instance | `ONE_SHOT` |
| multipart | the weakest part |
## Decision order
`DefaultRetryEligibilityEngine` evaluates in this order, and a later rule can never re-enable
something an earlier one forbade:
1. attempts exhausted → `RetryDenied.maxAttempts()`
2. retry budget empty → `RetryDenied.budgetExhausted()`
3. body not replayable → `RetryDenied.bodyNotReplayable()`
4. first byte already delivered → `RetryDenied.responseAlreadyDelivered()`
5. runtime draining → `RetryDenied.runtimeDraining()`
6. remaining deadline below the minimum attempt budget → `RetryDenied.deadline()`
7. permanent failure category → `RetryDenied.permanentFailure(...)`
8. `SENT_NO_RESPONSE` on an operation that is not safely idempotent → `AmbiguousFailure`
9. status- and failure-specific rules
## Status rules
| Status | Decision |
|---|---|
| 408 | retry inside deadline and budget |
| 425 | at most one retry, first attempt only |
| 429 | retry inside `Retry-After`, deadline, and budget |
| 401 | one refresh-and-replay, safe replayable operations only |
| 500 | denied unless the upstream registered it as transient **and** the operation is safely idempotent |
| 502, 503, 504 | retry for safely idempotent operations; ambiguous otherwise |
| other 4xx | denied |
## Ambiguity
A non-idempotent request that reached `SENT_NO_RESPONSE` raises
`HttpAmbiguousExecutionException`. It is a third answer on purpose: retrying may duplicate a side
effect, and reporting a plain failure would tell the caller the request did not happen, which may
be false. The caller reconciles, usually by querying the upstream or replaying with an idempotency
key.
## Budget and backoff
Retry tokens come from a per-upstream token bucket sized as a fraction of real traffic, so a failing
upstream cannot be flooded by retries from a healthy fleet. Backoff is exponential with full or
decorrelated jitter, bounded by `max-backoff`, by `Retry-After`, and by the remaining deadline. No
connection and no bulkhead permit is held while a backoff is waiting.
+75
View File
@@ -0,0 +1,75 @@
# HTTP Client Platform — Security Guide
## What the platform owns
`Authorization`, `Proxy-Authorization`, `Host`, `Content-Length`, `Transfer-Encoding`,
`Traceparent`, `Tracestate`, `Baggage`, and (unless a profile opts in) `Cookie` are platform-owned.
A caller cannot set them. `Idempotency-Key` is accepted only when the operation declares it. Any
header name or value containing CR or LF is rejected before the request is built.
## Target policy
A trusted profile accepts only a profile-relative URI template. An absolute URI is rejected rather
than sanitised: varying the destination is what H3 is for, and H3 has its own policy, credentials,
and address validation. Template variables are encoded per component, so a value containing `/`,
`?`, or `#` cannot change the shape of the request.
## TLS
Allowed: TLS 1.2 and 1.3, hostname verification, the JVM trust store, a per-profile custom CA, a
per-profile client certificate, mTLS, SNI and ALPN, and certificate rotation through a new runtime
generation.
Forbidden and unrepresentable: a trust-all trust manager, disabled hostname verification, ignoring
certificate errors, automatically trusting a production self-signed certificate, falling back to
plaintext after an HTTPS failure, and writing key material into configuration or logs.
Unknown CA, hostname mismatch, expired certificate, revoked certificate, protocol mismatch, and a
missing client certificate are permanent. Only a transient handshake timeout may be retried, inside
the deadline.
## Dynamic Target (SSRF)
Every hop — the first one included — runs the whole flow:
1. strict URI parse
2. scheme allowlist
3. reject userinfo and invalid ports
4. IDNA-canonicalise the host
5. host allowlist or suffix policy
6. resolve **every** A and AAAA answer
7. normalise each address, including IPv4-mapped IPv6
8. reject loopback, link-local, RFC1918, ULA, carrier-grade NAT, unspecified, multicast, cloud
metadata, and organisation-defined ranges
9. pin the connection to the approved addresses through the same validated resolver
10. apply response size and content policy
11. repeat for each redirect
Any forbidden address in the answer set rejects the whole target. Validating only the first answer
would let a host that resolves to one public and one private address through.
Dynamic profiles inherit no API key, OAuth token, Cookie, or default header, and no Cookie jar is
created. A specific host may be granted a credential only through an explicitly registered
`DynamicCredentialBinding`.
Application-level validation is not sufficient on its own. A network control — Kubernetes
NetworkPolicy, service-mesh egress policy, firewall, or proxy ACL — is an operational completion
requirement.
## Redirects
Disabled by default. Engine redirect handling is off in every transport so the platform can
re-validate each hop. 307 and 308 preserve method and body and are therefore allowed only for a
replayable body. Cross-origin hops are refused unless the profile opts in, and when they are
allowed `Authorization`, `Proxy-Authorization`, `Cookie`, and API-key headers are stripped.
## Observability
Allowed tags: `clientName`, `operationName`, `method`, `uriTemplate`, `status`, `outcome`,
`transport`, `protocol`, `timeoutType`, `retryReason`, `evidence`, `circuitState`.
Rejected outright: full URL, query parameters, path variable values, user ID, raw tenant ID,
resolved IP, API key, token, Cookie, idempotency key, request or response body, exception message.
Failures are logged once, structured, at the end of a logical call. Retry attempts are DEBUG or span
events. URLs appear only as templates.
+52
View File
@@ -0,0 +1,52 @@
# Streaming and Large Bodies
## Response lifecycle
A blocking streaming download returns `BlockingStreamingResponse`, never a bare `InputStream`.
Closing is idempotent and always releases the connection — after a full read, a partial read, a
decode failure, or a size rejection. The status is validated before any body byte is delivered, so a
failed download never becomes a half-consumed stream the caller has to reason about.
A reactive download emits bounded `DataBuffer` values. Buffers are released on completion, error,
and cancellation; a dropped buffer is direct memory nobody returns.
Wire bytes and decoded bytes are bounded independently, because a compressed payload passes a wire
check and then expands. Limits are enforced while reading, not after buffering.
## The first-byte boundary
```text
response headers received
→ nothing delivered yet
→ a read-only operation may still be retried
→ first InputStream read or first Flux onNext
→ transparent retry is permanently disabled
```
`FirstByteDeliveryGuard` latches once and never resets. Retrying after delivery would replay a
stream the caller has already partly consumed, producing duplicated or reordered data that no
downstream code can detect.
## Request bodies
A reopenable body is opened once per attempt, which is what makes it replayable; reusing the
previous stream would silently send an empty body on the retry. A one-shot stream or publisher
instance is never retried. `ReactiveBodySource` takes a publisher *factory* rather than a publisher
so a reactive body can honestly declare itself replayable.
A multipart body is exactly as replayable as its weakest part.
## Server-sent events
Three budgets stay separate:
- `setupDeadline` — establishing the stream
- `streamingIdleTimeout` — silence once it is open
- `maxStreamDuration` — optional total lifetime
Applying the request-shaped `total-call` timeout to an SSE subscription would terminate a perfectly
healthy stream on schedule, so it is not applied.
`Last-Event-ID` is opt-in. Replaying from an id is only correct when the producer guarantees it;
sending it blindly can skip or duplicate events. Reconnects consume the retry budget like any other
physical attempt, and cancelling the subscription stops both the stream and any pending reconnect.
+88
View File
@@ -0,0 +1,88 @@
# HTTP Client Platform — Support Matrix
Grades follow design §6 and §29. A row is **Stable** only when the cross-transport contract suite
proves it; anything the suite cannot prove is **Experimental** and says so.
## Spring API
| API | Grade | Role | Constraint |
|---|---|---|---|
| `RestClient` | Stable | Blocking execution | Bounded concurrency and an effective deadline are mandatory |
| `WebClient` | Stable | Reactive, streaming, SSE | No blocking work on the event loop |
| HTTP Service Client (`@HttpExchange`) | Default | Declarative typed client | Operation metadata is mandatory |
| `RestTemplate` | Migration only | Moving existing calls | No new profile or feature |
| Generic Exchange (H2) | Restricted | Dynamic method, path, body | Base URL and policy are immutable |
| Dynamic Target (H3) | Restricted | User-supplied URL | Separate SSRF policy; inherits no credential |
| Native engine | Internal | Engine-specific configuration | Never an application-facing API |
## Transports
| Transport | Blocking | Reactive | HTTP/1.1 | HTTP/2 | HTTP/3 | Grade | Verified by |
|---|---:|---:|---:|---:|---:|---|---|
| Apache HttpClient 5 (classic) | yes | no | yes | **no** | no | Stable (blocking default) | `httpClientStableContractTest`, `NegotiatedProtocolContractTest` |
| JDK HttpClient | yes | `sendAsync` | yes | yes (TLS/ALPN) | no | Stable (lightweight, blocking HTTP/2) | `NegotiatedProtocolContractTest` |
| Reactor Netty | limited | yes | yes | yes | experimental | Stable (reactive default) | `NegotiatedProtocolContractTest` |
| Jetty | facade | yes | yes | yes | yes | **Experimental** | `Http3OptInTest` only |
| Simple request factory | yes | no | limited | no | no | Local test only | rejected in production by `ClientProfileValidator` |
### Apache is HTTP/1.1 here, and why
Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable, and the library is — in its **async**
client. Spring's `HttpComponentsClientHttpRequestFactory` drives the **classic** client, which
speaks HTTP/1.1 only. `NegotiatedProtocolContractTest` measures this rather than assuming it: the
classic client fails outright against a prior-knowledge h2c server.
So `ApacheBlockingTransportProvider.capabilities()` declares HTTP/1.1, and a profile that pairs
Apache with `HTTP_2` is rejected at startup instead of quietly running HTTP/1.1 while this table
claims otherwise. **Blocking HTTP/2 is served by the JDK transport**; reactive HTTP/2 by Reactor
Netty. Both are measured from the client after a real TLS handshake, not read from configuration.
The JDK transport declares `routeScopedPool=false`, `boundedPendingAcquireQueue=false`, and
`dynamicTargetStable=false`. A profile that needs any of those is rejected at startup rather than
served with weaker guarantees. Choosing between Apache and JDK is therefore a real trade: Apache
gives route-scoped pooling and Dynamic Target pinning, JDK gives HTTP/2.
## Capability gates
| Capability | Gate |
|---|---|
| Dynamic Target (H3) | Apache and Reactor Netty only; JDK and Jetty are rejected |
| HTTP/3 | `experimentalAcknowledgement` must equal `I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS` |
| Cross-origin redirect | opt-in per profile; credentials are stripped on the hop |
| Retry | evidence-based; never enabled by HTTP method alone |
## CI matrix
| Profile | Frequency | Release gate | Task |
|---|---|---|---|
| Spring Framework 7.0 (repository baseline) | every PR | required | `spring70CompatibilityTest` |
| Spring Framework 6.2 API surface | every PR | required | `spring62CompatibilityTest` |
| Apache HC5 + RestClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=apache` |
| JDK HttpClient + RestClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=jdk` |
| Reactor Netty + WebClient | every PR | required | `httpClientStableContractTest -Phttpclient.contract.transports=reactor` |
| SSRF / cardinality suite | every PR | required | `httpClientSecurityTest` |
| Toxiproxy fault suite | nightly, release | required | `httpClientFailureInjectionTest` |
| Event-loop blocking (BlockHound) | every PR | required | `httpClientBlockHoundTest` |
| Performance certification | nightly, release | required | `httpClientPerformanceTest -Pperformance.assertions.enabled=true` |
| Jetty HTTP/3 | nightly | Experimental, non-blocking | `test -Phttp3.tests.enabled=true` |
### Known limitation of the Spring 6.2 lane
This repository's Spring Boot 4.0 baseline pins Spring Framework 7, so a real 6.2 runtime cannot be
resolved here. `spring62CompatibilityTest` therefore verifies the **API surface**: the common
packages must not reference any Spring 7-only type, and `org.springframework.web.service.registry`
is confined to `…httpclient.spring7`. Executing the suite against an actual 6.2 distribution
requires a host project on that line. This limitation is stated rather than hidden behind a passing
check.
## What the suites do not prove
Stated so the matrix is read as a measurement rather than an aspiration.
| Gap | Why | What is proven instead |
|---|---|---|
| HTTP/2 frame injection (`REFUSED_STREAM`, arbitrary `GOAWAY`) | The fixture server exposes no frame-level control, and a purpose-built h2 server is a larger dependency than the guarantee is worth here | `Http2EvidenceMapperTest` proves the frame → evidence mapping, and `NegotiatedProtocolContractTest` proves h2 is really negotiated |
| Netty buffer-leak detection | Netty reports a leak when an unreferenced buffer is collected, which the suite does not force | `NettyLeakDetectionExtension` asserts the PARANOID detector is live and reports nothing; explicit release assertions in the streaming suites are the primary guarantee |
| Spring 6.2 runtime | This repository's Boot 4.0 baseline pins Spring 7 | `spring62CompatibilityTest` confines the common packages to the 6.2 API surface |
| Performance latency baseline | Numbers measured on a build agent are not a certification | `httpClientPerformanceTest` asserts structural bounds unconditionally; latency and heap bounds run under `-Pperformance.assertions.enabled=true` |