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:
co-authored by
Claude Opus 5
parent
1a3b560678
commit
5f10b791d3
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
- [ ] H1–H4 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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` |
|
||||
Reference in New Issue
Block a user