chore: initialize from backend template 0a6dd0e
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
# HTTP Client Platform — Configuration Reference
|
||||
|
||||
Every outbound call resolves exactly one **Named Client Profile**. The whole capability lives under
|
||||
the `app.httpclient` prefix: profiles under `app.httpclient.clients[N]`, Dynamic Target policies
|
||||
under `app.httpclient.dynamic-targets[N]`.
|
||||
|
||||
Design §30.1 forbids a production profile from inheriting large framework defaults. Anything a
|
||||
production deployment must decide has either no default or an unusable one, and
|
||||
`HttpClientStartupValidator` fails the context rather than guessing.
|
||||
|
||||
## The master switch
|
||||
|
||||
| Property | Type | Default | Environment |
|
||||
|---|---|---|---|
|
||||
| `app.httpclient.enabled` | boolean | `false` | `APP_HTTPCLIENT_ENABLED` |
|
||||
|
||||
Off is the shipped state and it is a structural one. `HttpClientPlatformAutoConfiguration` lives in
|
||||
a package the composition root's component scan excludes, so while the switch is absent or false the
|
||||
class is never processed and neither is anything it imports: no property is bound, and no transport
|
||||
provider, connection pool, TLS context, credential, thread, gateway or actuator endpoint exists. A
|
||||
malformed HTTP client setting cannot fail the startup of a deployment that never wanted outbound
|
||||
HTTP.
|
||||
|
||||
Anything that is not exactly `true` — `yes`, `1`, blank — leaves the platform off. Turning it on
|
||||
with no client declared is a startup failure carrying `HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS`: a
|
||||
platform with nothing to call still holds transport providers and gateways no caller can reach.
|
||||
|
||||
## Declaring clients from the environment
|
||||
|
||||
Clients are an indexed list carrying their own `name`, not a map keyed by name. A map key becomes a
|
||||
segment of the environment variable and the relaxed binder normalises it, so `payment-api` and
|
||||
`payment_api` would arrive as one entry with nothing said about the one that was lost. Both a
|
||||
duplicate name and a name that collides once normalised fail startup.
|
||||
|
||||
```dotenv
|
||||
APP_HTTPCLIENT_ENABLED=true
|
||||
|
||||
APP_HTTPCLIENT_CLIENTS_0_NAME=payment
|
||||
APP_HTTPCLIENT_CLIENTS_0_BASE_URL=https://payment.example
|
||||
APP_HTTPCLIENT_CLIENTS_0_ALLOWED_HOSTS_0=payment.example
|
||||
APP_HTTPCLIENT_CLIENTS_0_ALLOWED_PORTS_0=443
|
||||
APP_HTTPCLIENT_CLIENTS_0_REQUEST_MAX_BODY_BYTES=1048576
|
||||
APP_HTTPCLIENT_CLIENTS_0_TLS_PROFILE_ID=payment
|
||||
|
||||
APP_HTTPCLIENT_DYNAMIC_TARGETS_0_NAME=webhook
|
||||
APP_HTTPCLIENT_DYNAMIC_TARGETS_0_ALLOWED_SCHEMES_0=https
|
||||
```
|
||||
|
||||
`docs/httpclient/env-fields.yaml` is the registry of accepted variable names. It is
|
||||
derived from the settings record and held to it in both directions, and the platform refuses to
|
||||
start on an `APP_HTTPCLIENT_` variable that is not in it — so
|
||||
`APP_HTTPCLIENT_CLIENTS_0_TIMEUOT_TOTAL_CALL` fails startup instead of silently leaving the client
|
||||
on its default budget. Unknown keys supplied through a configuration file rather than the
|
||||
environment are refused by strict binding for the same reason.
|
||||
|
||||
Only `APP_HTTPCLIENT_ENABLED` appears in `src/.env` and `docs/registries/env-keys.yaml`. It is the
|
||||
one key with a deployment-independent value; templating an indexed client in `application.yml` would
|
||||
materialise a nameless client in every deployment, which the aggregate validation refuses.
|
||||
|
||||
## `app.httpclient.clients[N]`
|
||||
|
||||
| Property | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | — | Required, unique, and distinct from every other name once normalised for the environment |
|
||||
| `mode` | `TRUSTED` \| `DYNAMIC` | `TRUSTED` | A dynamic profile may not carry a default credential |
|
||||
| `base-url` | URI | — | Required for a trusted profile; no userinfo, no query |
|
||||
| `allowed-hosts` | list | empty | Required in production |
|
||||
| `allowed-ports` | list | empty | Compared against the effective port |
|
||||
| `api` | `REST_CLIENT` \| `WEB_CLIENT` | `REST_CLIENT` | Decides blocking or reactive runtime |
|
||||
| `transport` | `APACHE` \| `JDK` \| `REACTOR_NETTY` \| `JETTY` \| `SIMPLE` | `APACHE` | `SIMPLE` is rejected in production |
|
||||
| `protocols` | list | `HTTP_1_1` | The default transport is Apache, whose classic client is HTTP/1.1 only; a profile that wants HTTP/2 declares it together with a transport that can deliver it. `HTTP_3` requires the experimental acknowledgement |
|
||||
| `experimental-acknowledgement` | string | — | Must equal `I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS` |
|
||||
|
||||
### `pool`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `max-total-connections` | `50` | Socket ceiling for the runtime |
|
||||
| `max-connections-per-route` | `25` | Per-upstream ceiling |
|
||||
| `max-pending-acquires` | `100` | Waiting-request memory ceiling |
|
||||
| `pending-acquire-timeout` | `200ms` | Pool or stream wait ceiling |
|
||||
| `max-idle-time` | `30s` | Idle eviction |
|
||||
| `max-life-time` | `5m` | Picks up DNS, load-balancer, and certificate changes |
|
||||
| `validate-after-inactivity` | `5s` | Stale and half-open detection |
|
||||
| `eviction-interval` | `15s` | Background cleanup |
|
||||
| `shutdown-timeout` | `5s` | Drain deadline before forced close |
|
||||
| `requires-route-pool` | `false` | Set when route-scoped limits are mandatory; the JDK transport then refuses the profile |
|
||||
| `requires-bounded-pending-queue` | `false` | Same, for a bounded pending queue |
|
||||
|
||||
### `timeout`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `dns` | `300ms` | Hostname resolution |
|
||||
| `connect` | `500ms` | Socket connect |
|
||||
| `tls-handshake` | `1s` | TLS and ALPN |
|
||||
| `proxy-connect` | `500ms` | Proxy socket or CONNECT |
|
||||
| `request-write-idle` | `1s` | No progress writing the request |
|
||||
| `response-header` | `2s` | Until final response headers |
|
||||
| `read-idle` | `3s` | Between response chunks |
|
||||
| `total-call` | `4s` | The whole logical call, including retry backoff |
|
||||
| `streaming-idle` | `30s` | Silence on a long-lived stream |
|
||||
|
||||
`total-call` must not be shorter than `connect` or `response-header`; the validator emits
|
||||
`INVALID_TIMEOUT_BUDGET` otherwise.
|
||||
|
||||
### `redirect`, `request`, `response`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `redirect.enabled` | `false` | Engine redirect handling is always off; the platform follows hops itself |
|
||||
| `redirect.max-hops` | `0` | Enabling redirects with zero hops is a configuration error |
|
||||
| `redirect.allow-cross-origin` | `false` | When enabled, credentials are stripped on the hop |
|
||||
| `request.max-body-bytes` | `0` | Required in production |
|
||||
| `request.compression` | `false` | |
|
||||
| `response.max-wire-bytes` | `5242880` | Bytes on the wire |
|
||||
| `response.max-decoded-bytes` | `10485760` | Bytes after decoding; hard maximum is 64 MiB |
|
||||
| `response.allowed-content-types` | JSON + problem+json | Empty means "any" |
|
||||
|
||||
### `authentication`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `type` | `NONE` | One of the design §20.1 methods |
|
||||
| `registration-id` | — | Required for OAuth2 |
|
||||
| `scopes` | empty | Part of the token cache key |
|
||||
| `audience` | — | Part of the token cache key |
|
||||
| `header-name` | — | Required for `API_KEY_HEADER`; must be on the allowlist |
|
||||
| `secret-reference` | — | Resolved by the deployment's secret loader, never a literal |
|
||||
|
||||
### `retry`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `policy` | `none` | Named policy for reporting |
|
||||
| `max-attempts` | `1` | Attempts, not retries |
|
||||
| `base-backoff` | `50ms` | |
|
||||
| `max-backoff` | `200ms` | |
|
||||
| `jitter` | `FULL` | `NONE` \| `FULL` \| `DECORRELATED` |
|
||||
| `retry-after` | `HONOR` | `HONOR` \| `IGNORE` \| `CAP` |
|
||||
| `budget` | — | Shared token bucket name |
|
||||
|
||||
### `tls`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `profile-id` | — | Required in production; the only TLS identifier the actuator exposes |
|
||||
| `protocols` | `TLSv1.3, TLSv1.2` | Anything else is rejected |
|
||||
| `hostname-verification` | `true` | Setting it false fails startup |
|
||||
| `trust-all` | `false` | Exists only so the unsafe intent is rejectable; nothing acts on `true` |
|
||||
| `allow-plain-http` | `false` | Plaintext fallback fails startup in production |
|
||||
| `trust-material-reference` | — | Custom CA, resolved by the secret loader |
|
||||
| `key-material-reference` | — | Client certificate for mTLS |
|
||||
|
||||
### `proxy` and `observability`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `proxy.enabled` | `false` | |
|
||||
| `proxy.host` / `proxy.port` / `proxy.type` | — / `0` / `HTTP` | |
|
||||
| `proxy.credential-provider` | — | Proxy authentication is separate from target authentication |
|
||||
| `proxy.connect-timeout` | `500ms` | Recorded as its own metric |
|
||||
| `proxy.import-ambient-no-proxy` | `false` | Ambient `NO_PROXY` never widens a validated profile |
|
||||
| `observability.operation-name-required` | `true` | |
|
||||
| `observability.full-url-recording` | `false` | |
|
||||
| `observability.body-logging` | `false` | |
|
||||
|
||||
## `app.httpclient.dynamic-targets[N]`
|
||||
|
||||
| Property | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `name` | — | Required, unique, and subject to the same normalisation rule as a client name |
|
||||
| `allowed-schemes` | `https` | |
|
||||
| `allowed-ports` | `443` | |
|
||||
| `allowed-host-suffixes` | empty | |
|
||||
| `allowed-hosts` | empty | Empty means "any host that survives address validation" |
|
||||
| `max-redirect-hops` | `0` | Each hop repeats the full validation flow |
|
||||
| `trace-propagation` | `false` | Off by default for dynamic targets |
|
||||
| `blocked-cidrs` | empty | Organisation-defined internal ranges |
|
||||
|
||||
## Startup violation codes
|
||||
|
||||
`TRUSTED_BASE_URL_REQUIRED`, `BASE_URL_USERINFO_FORBIDDEN`, `BASE_URL_QUERY_FORBIDDEN`,
|
||||
`PLAINTEXT_PRODUCTION_TARGET`, `ALLOWED_HOST_MISMATCH`, `ALLOWED_PORT_MISMATCH`,
|
||||
`REDIRECT_POLICY_INVALID`, `REDIRECT_CROSS_ORIGIN_CREDENTIAL_POLICY_REQUIRED`,
|
||||
`INVALID_TIMEOUT_BUDGET`, `RESPONSE_HARD_MAXIMUM_EXCEEDED`, `PRODUCTION_SIMPLE_FACTORY_FORBIDDEN`,
|
||||
`JDK_FINE_GRAINED_POOL_UNSUPPORTED`, `HTTP3_STABLE_FORBIDDEN`,
|
||||
`DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED`, `DYNAMIC_DEFAULT_CREDENTIAL_FORBIDDEN`,
|
||||
`OAUTH2_REGISTRATION_REQUIRED`, `API_KEY_HEADER_NAME_REQUIRED`, `TRUST_ALL_FORBIDDEN`,
|
||||
`HOSTNAME_VERIFICATION_REQUIRED`, `PLAINTEXT_FALLBACK_FORBIDDEN`, `TLS_PROTOCOL_FORBIDDEN`,
|
||||
`RETRY_BACKOFF_REQUIRED`, `MISSING_PRODUCTION_SETTING`, `DUPLICATE_CLIENT_NAME`,
|
||||
`HTTPCLIENT_ACTIVE_WITHOUT_CLIENTS`, `DYNAMIC_BASE_URL_REQUIRED`,
|
||||
`DYNAMIC_TARGET_PROXY_UNSUPPORTED`, `REACTIVE_AUTHENTICATION_UNSUPPORTED`,
|
||||
`HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED`, `POOL_ROUTE_EXCEEDS_TOTAL`,
|
||||
`TLS_PROTOCOL_SET_REQUIRED`, `REACTIVE_REDIRECT_UNSUPPORTED`,
|
||||
`RETRY_POLICY_CONTRADICTS_ATTEMPTS`, `FULL_URL_RECORDING_FORBIDDEN`, `BODY_LOGGING_FORBIDDEN`,
|
||||
`DNS_TIMEOUT_UNSUPPORTED`, `PROXY_CREDENTIAL_UNSUPPORTED`, `PROXY_AMBIENT_NO_PROXY_UNSUPPORTED`.
|
||||
|
||||
The last three name settings the platform binds but cannot yet honour. Neither the Apache classic
|
||||
client nor the JDK client exposes a DNS-resolution timeout, and no proxy-credential path exists, so
|
||||
a non-default value is refused rather than accepted and ignored. Leaving the defaults alone is
|
||||
unaffected — only a deliberate, unmet request fails.
|
||||
|
||||
Three of these are about a guarantee that used to be silently unmet rather than refused:
|
||||
|
||||
- `HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED` — declaring `protocols: [HTTP_2]` alone states that HTTP/2
|
||||
is required. Only `REACTOR_NETTY` can be configured to offer H2 and nothing else; the JDK client
|
||||
treats it as a preference and negotiates HTTP/1.1, and Apache's classic client is HTTP/1.1 only.
|
||||
- `POOL_ROUTE_EXCEEDS_TOTAL` — a per-route ceiling above the total is incoherent, and on Reactor,
|
||||
where the per-route knob is the only one that exists, it silently becomes the effective limit.
|
||||
- `TLS_PROTOCOL_SET_REQUIRED` — an empty `tls.protocols` used to pass and then let the JVM choose,
|
||||
so emptying the list to "tighten" a profile loosened it.
|
||||
- `REACTIVE_REDIRECT_UNSUPPORTED` — engine redirect following is disabled on every transport and
|
||||
only the blocking stack has a coordinator that follows hops with per-hop re-validation. A
|
||||
`WEB_CLIENT` profile with `redirect.enabled=true` did not follow redirects; the caller received the
|
||||
3xx as an ordinary response. Refused until the reactive coordinator exists.
|
||||
- `RETRY_POLICY_CONTRADICTS_ATTEMPTS` — `retry.policy` was read by nothing on the execution path, so
|
||||
the actuator could report `none` for a profile retrying three times. The two settings must now
|
||||
agree: `policy: none` requires `max-attempts: 1`, and any other policy requires more than one.
|
||||
- `FULL_URL_RECORDING_FORBIDDEN` / `BODY_LOGGING_FORBIDDEN` — both settings were bindable and inert.
|
||||
Recording an expanded URL puts path identifiers and query strings into unbounded metric tags;
|
||||
recording bodies puts someone else's data into logs. Representable so the intent is rejectable,
|
||||
refused under a production profile.
|
||||
|
||||
`DYNAMIC_TARGET_PROXY_UNSUPPORTED` is worth spelling out: a forward proxy resolves the hostname on
|
||||
its own side, so the addresses this platform validated and pinned are not the addresses the
|
||||
connection reaches. The SSRF defence would be present, correct, and bypassed — so the combination is
|
||||
refused rather than served with a guarantee it cannot keep.
|
||||
@@ -0,0 +1,179 @@
|
||||
# HTTP Client platform — Java field path to environment variable template.
|
||||
#
|
||||
# The SSOT is HttpClientPlatformSettings. HttpClientEnvironmentKeys derives this list from the
|
||||
# record tree at runtime, HttpClientPlatformEnvManifestTest fails when the two disagree in either
|
||||
# direction, and the platform refuses to start on an APP_HTTPCLIENT_ variable that is not here. So a
|
||||
# field added with no entry, an entry whose field was renamed, and a misspelled variable in a
|
||||
# deployment are all failures rather than silence.
|
||||
#
|
||||
# `N` and `M` are list indices, not literals: `N` for the outermost list, `M` for a list inside it.
|
||||
# `app.httpclient.clients[N].base-url` is set as APP_HTTPCLIENT_CLIENTS_0_BASE_URL for the first
|
||||
# client, and `clients[N].allowed-hosts[M]` as APP_HTTPCLIENT_CLIENTS_0_ALLOWED_HOSTS_0.
|
||||
#
|
||||
# Only APP_HTTPCLIENT_ENABLED is registered in docs/registries/env-keys.yaml and shipped in
|
||||
# src/.env: it is the only key with a deployment-independent value, and it is the only one the
|
||||
# three-way verifyEnvKeys gate can express. Everything below is per deployment and is set directly
|
||||
# in the environment — templating an indexed client in application.yml would materialise a nameless
|
||||
# client in every deployment, which the settings' aggregate validation refuses.
|
||||
#
|
||||
# This file lives beside the HTTP Client documentation rather than in docs/registries, which is a
|
||||
# fail-closed catalog of exactly eight contract registries with a fixed row schema
|
||||
# (owner_branch/compatibility_impact/required_test per row). A field-to-variable mapping does not
|
||||
# have that shape, and admitting it would have meant loosening a gate rather than satisfying one.
|
||||
#
|
||||
# Secrets are referenced, never carried: authentication.secret-reference, tls.*-material-reference
|
||||
# and proxy.credential-provider name material that a secret backend resolves. Putting the material
|
||||
# itself in one of these variables defeats the indirection they exist for.
|
||||
fields:
|
||||
- field: enabled
|
||||
env: APP_HTTPCLIENT_ENABLED
|
||||
- field: clients[N].name
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_NAME
|
||||
- field: clients[N].mode
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_MODE
|
||||
- field: clients[N].base-url
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_BASE_URL
|
||||
- field: clients[N].allowed-hosts[M]
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_ALLOWED_HOSTS_M
|
||||
- field: clients[N].allowed-ports[M]
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_ALLOWED_PORTS_M
|
||||
- field: clients[N].api
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_API
|
||||
- field: clients[N].transport
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TRANSPORT
|
||||
- field: clients[N].protocols[M]
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROTOCOLS_M
|
||||
- field: clients[N].pool.max-total-connections
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_TOTAL_CONNECTIONS
|
||||
- field: clients[N].pool.max-connections-per-route
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_CONNECTIONS_PER_ROUTE
|
||||
- field: clients[N].pool.max-pending-acquires
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_PENDING_ACQUIRES
|
||||
- field: clients[N].pool.pending-acquire-timeout
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_PENDING_ACQUIRE_TIMEOUT
|
||||
- field: clients[N].pool.max-idle-time
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_IDLE_TIME
|
||||
- field: clients[N].pool.max-life-time
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_MAX_LIFE_TIME
|
||||
- field: clients[N].pool.validate-after-inactivity
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_VALIDATE_AFTER_INACTIVITY
|
||||
- field: clients[N].pool.eviction-interval
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_EVICTION_INTERVAL
|
||||
- field: clients[N].pool.shutdown-timeout
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_SHUTDOWN_TIMEOUT
|
||||
- field: clients[N].pool.requires-route-pool
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_REQUIRES_ROUTE_POOL
|
||||
- field: clients[N].pool.requires-bounded-pending-queue
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_POOL_REQUIRES_BOUNDED_PENDING_QUEUE
|
||||
- field: clients[N].timeout.dns
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_DNS
|
||||
- field: clients[N].timeout.connect
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_CONNECT
|
||||
- field: clients[N].timeout.tls-handshake
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_TLS_HANDSHAKE
|
||||
- field: clients[N].timeout.proxy-connect
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_PROXY_CONNECT
|
||||
- field: clients[N].timeout.request-write-idle
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_REQUEST_WRITE_IDLE
|
||||
- field: clients[N].timeout.response-header
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_RESPONSE_HEADER
|
||||
- field: clients[N].timeout.read-idle
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_READ_IDLE
|
||||
- field: clients[N].timeout.total-call
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_TOTAL_CALL
|
||||
- field: clients[N].timeout.streaming-idle
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TIMEOUT_STREAMING_IDLE
|
||||
- field: clients[N].redirect.enabled
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_ENABLED
|
||||
- field: clients[N].redirect.max-hops
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_MAX_HOPS
|
||||
- field: clients[N].redirect.allow-cross-origin
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_REDIRECT_ALLOW_CROSS_ORIGIN
|
||||
- field: clients[N].request.max-body-bytes
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_REQUEST_MAX_BODY_BYTES
|
||||
- field: clients[N].request.compression
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_REQUEST_COMPRESSION
|
||||
- field: clients[N].response.max-wire-bytes
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_MAX_WIRE_BYTES
|
||||
- field: clients[N].response.max-decoded-bytes
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_MAX_DECODED_BYTES
|
||||
- field: clients[N].response.allowed-content-types[M]
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RESPONSE_ALLOWED_CONTENT_TYPES_M
|
||||
- field: clients[N].authentication.type
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_TYPE
|
||||
- field: clients[N].authentication.registration-id
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_REGISTRATION_ID
|
||||
- field: clients[N].authentication.scopes[M]
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_SCOPES_M
|
||||
- field: clients[N].authentication.audience
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_AUDIENCE
|
||||
- field: clients[N].authentication.header-name
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_HEADER_NAME
|
||||
- field: clients[N].authentication.secret-reference
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_AUTHENTICATION_SECRET_REFERENCE
|
||||
- field: clients[N].retry.policy
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_POLICY
|
||||
- field: clients[N].retry.max-attempts
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_MAX_ATTEMPTS
|
||||
- field: clients[N].retry.base-backoff
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_BASE_BACKOFF
|
||||
- field: clients[N].retry.max-backoff
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_MAX_BACKOFF
|
||||
- field: clients[N].retry.jitter
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_JITTER
|
||||
- field: clients[N].retry.retry-after
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_RETRY_AFTER
|
||||
- field: clients[N].retry.budget
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_RETRY_BUDGET
|
||||
- field: clients[N].observability.operation-name-required
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_OPERATION_NAME_REQUIRED
|
||||
- field: clients[N].observability.full-url-recording
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_FULL_URL_RECORDING
|
||||
- field: clients[N].observability.body-logging
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_OBSERVABILITY_BODY_LOGGING
|
||||
- field: clients[N].tls.profile-id
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TLS_PROFILE_ID
|
||||
- field: clients[N].tls.protocols[M]
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TLS_PROTOCOLS_M
|
||||
- field: clients[N].tls.hostname-verification
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TLS_HOSTNAME_VERIFICATION
|
||||
- field: clients[N].tls.trust-all
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TLS_TRUST_ALL
|
||||
- field: clients[N].tls.allow-plain-http
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TLS_ALLOW_PLAIN_HTTP
|
||||
- field: clients[N].tls.trust-material-reference
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TLS_TRUST_MATERIAL_REFERENCE
|
||||
- field: clients[N].tls.key-material-reference
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_TLS_KEY_MATERIAL_REFERENCE
|
||||
- field: clients[N].proxy.enabled
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_ENABLED
|
||||
- field: clients[N].proxy.host
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_HOST
|
||||
- field: clients[N].proxy.port
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_PORT
|
||||
- field: clients[N].proxy.type
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_TYPE
|
||||
- field: clients[N].proxy.credential-provider
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_CREDENTIAL_PROVIDER
|
||||
- field: clients[N].proxy.connect-timeout
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_CONNECT_TIMEOUT
|
||||
- field: clients[N].proxy.import-ambient-no-proxy
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_PROXY_IMPORT_AMBIENT_NO_PROXY
|
||||
- field: clients[N].experimental-acknowledgement
|
||||
env: APP_HTTPCLIENT_CLIENTS_N_EXPERIMENTAL_ACKNOWLEDGEMENT
|
||||
- field: dynamic-targets[N].name
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_NAME
|
||||
- field: dynamic-targets[N].allowed-schemes[M]
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_SCHEMES_M
|
||||
- field: dynamic-targets[N].allowed-ports[M]
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_PORTS_M
|
||||
- field: dynamic-targets[N].allowed-host-suffixes[M]
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_HOST_SUFFIXES_M
|
||||
- field: dynamic-targets[N].allowed-hosts[M]
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_ALLOWED_HOSTS_M
|
||||
- field: dynamic-targets[N].max-redirect-hops
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_MAX_REDIRECT_HOPS
|
||||
- field: dynamic-targets[N].trace-propagation
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_TRACE_PROPAGATION
|
||||
- field: dynamic-targets[N].blocked-cidrs[M]
|
||||
env: APP_HTTPCLIENT_DYNAMIC_TARGETS_N_BLOCKED_CIDRS_M
|
||||
@@ -0,0 +1,64 @@
|
||||
# Migrating from `RestTemplate`
|
||||
|
||||
`RestTemplate` is permitted only inside `…httpclient.migration`; `RestTemplateBoundaryTest` enforces
|
||||
that. New retry, Dynamic Target, and HTTP/3 capabilities are deliberately unreachable from the
|
||||
migration path — a caller that wants them moves to a Named Client Profile.
|
||||
|
||||
## 1. Audit before changing anything
|
||||
|
||||
```java
|
||||
RestTemplateInventory inventory = new RestTemplateInventoryScanner().scan(existingTemplate);
|
||||
```
|
||||
|
||||
The inventory reports the request factory, message converters, interceptors, error handler, and URI
|
||||
template handler, plus findings:
|
||||
|
||||
| Code | Severity | Meaning |
|
||||
|---|---|---|
|
||||
| `SIMPLE_REQUEST_FACTORY` | blocking | no connection pool; unsupported in production |
|
||||
| `NO_MESSAGE_CONVERTERS` | blocking | the template cannot encode or decode a body |
|
||||
| `NO_INTERCEPTORS` | warning | confirm where correlation and timeouts are applied |
|
||||
| `TIMEOUTS_NOT_INTROSPECTABLE` | informational | declare timeouts explicitly on the target profile |
|
||||
|
||||
## 2. Bridge without changing behaviour
|
||||
|
||||
```java
|
||||
RestClient client = new RestTemplateToRestClientAdapter().adaptChecked(existingTemplate);
|
||||
```
|
||||
|
||||
`adaptChecked` refuses to migrate a template with a blocking finding. The bridge carries the
|
||||
existing converters, interceptors, error handler, and URI handler across, so this step changes the
|
||||
API and nothing else.
|
||||
|
||||
## 3. Move to a Named Client Profile
|
||||
|
||||
Turn the platform on with `APP_HTTPCLIENT_ENABLED=true` — it ships off, and while it is off none of
|
||||
the settings below are bound — then declare the upstream as `app.httpclient.clients[N]` with its
|
||||
`name` and an explicit base URL, transport, timeouts, pool, body limits, authentication, retry
|
||||
policy, redirect policy, and TLS profile. Startup validation will tell you exactly which of those is
|
||||
missing. See `docs/httpclient/configuration-reference.md` for the environment form.
|
||||
|
||||
## 4. Move to a typed client
|
||||
|
||||
```java
|
||||
@HttpClientProfile("payment")
|
||||
@HttpExchange("/payments")
|
||||
public interface PaymentClient {
|
||||
|
||||
@PostExchange
|
||||
@HttpOperationPolicy(
|
||||
name = "create-payment",
|
||||
idempotency = OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED,
|
||||
retryPolicy = "payment-write")
|
||||
PaymentResponse create(
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey, @RequestBody PaymentRequest request);
|
||||
}
|
||||
```
|
||||
|
||||
The interface fails startup validation unless it declares a profile, gives every method a stable
|
||||
operation name and an explicit idempotency, supplies a key parameter when the operation requires
|
||||
one, keeps a single execution model, and does not enable retry on a non-idempotent write.
|
||||
|
||||
## 5. Retire the template
|
||||
|
||||
Once no production package references `RestTemplate`, `RestTemplateBoundaryTest` keeps it that way.
|
||||
@@ -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,87 @@
|
||||
# HTTP Client Platform — Repository Adaptation Contract
|
||||
|
||||
**Design source:** `httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`
|
||||
**Plan source:** `httpclient-superpowers-package/docs/superpowers/plans/2026-08-08-httpclient-platform-implementation-plan.md`
|
||||
|
||||
The design package states its own adaptation rule:
|
||||
|
||||
> 실제 Backend Skeleton 저장소가 제공되지 않았으므로 package 경로와 Gradle 구조는 설계서의 명시적
|
||||
> 구현 가정이다. 구현 전 저장소의 기존 convention과 root package에 맞춰 경로만 조정하고 공개 계약과
|
||||
> 정책 의미론은 유지한다.
|
||||
|
||||
This file is the single record of *how* the design's assumed layout was mapped onto this repository.
|
||||
Only paths, build DSL, and composition-root ownership changed. Public contracts, policy order, and
|
||||
error semantics are implemented exactly as specified.
|
||||
|
||||
## 1. Why the module layout differs
|
||||
|
||||
The design assumes a greenfield library with 19 Gradle projects under `modules/httpclient/`.
|
||||
This repository is a Clean Architecture template whose **fail-closed registry**
|
||||
(`src/config/architecture/modules.json`, enforced by `src/settings.gradle` and
|
||||
`verifyCleanArchitectureDependencies`) declares **exactly 19 leaf identities**. Creating 19 more
|
||||
Gradle projects would violate HARD-STOP #5 in `AGENTS.md`.
|
||||
|
||||
Therefore the design's 19 library modules become **package boundaries inside the registered leaf**
|
||||
`:adapter:outbound:httpclient`, with two exceptions driven by this repository's own rules:
|
||||
|
||||
| Design module | Repository home | Reason |
|
||||
|---|---|---|
|
||||
| `httpclient-spring-boot-starter` | `:app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.httpclient`) | This repository's composition root owns wiring and canonical activation; an adapter leaf must not auto-configure itself. |
|
||||
| `httpclient-testkit` | `:adapter:outbound:httpclient` `src/testkit/java/**/testkit` | The design forbids production modules depending on the testkit; a source set whose dependencies are declared only on the test configurations gives the same guarantee without a new Gradle project. It is its own source set rather than part of `test` because three lanes consume it — `test`, `httpClientPerformanceTest` and `jmh` — and reaching into `sourceSets.test.output` from `jmh` compiled under Gradle but could not be modelled by an IDE, which classifies a source set as test source only when a `Test` task runs its output and forbids main source from reading test source. `PlatformClasses` excludes the source set's output so the boundary rules keep meaning production classes. |
|
||||
|
||||
The package boundary is enforced by ArchUnit rules (`PublicApiArchitectureTest`,
|
||||
`HttpClientModuleBoundaryTest`) that reproduce the design's module dependency table.
|
||||
|
||||
## 2. Package mapping
|
||||
|
||||
Root package: `io.backend.skeleton.httpclient` → `dev.caskeleton.adapter.outbound.httpclient`.
|
||||
|
||||
| Design module | Design package | Repository package |
|
||||
|---|---|---|
|
||||
| `httpclient-core-api` | `…httpclient.api` (+ `.body`, `.error`, `.operation`, `.result`) | `dev.caskeleton.adapter.outbound.httpclient.api` (+ same subpackages) |
|
||||
| `httpclient-profile` | `…httpclient.profile` | `…outbound.httpclient.profile` |
|
||||
| `httpclient-transport-spi` | `…httpclient.transport` | `…outbound.httpclient.transport` |
|
||||
| `httpclient-transport-apache` | `…httpclient.apache` | `…outbound.httpclient.apache` |
|
||||
| `httpclient-transport-jdk` | `…httpclient.jdk` | `…outbound.httpclient.jdk` |
|
||||
| `httpclient-restclient` | `…httpclient.restclient` | `…outbound.httpclient.restclient` |
|
||||
| `httpclient-resilience` | `…httpclient.resilience` | `…outbound.httpclient.resilience` |
|
||||
| `httpclient-auth` | `…httpclient.auth` | `…outbound.httpclient.auth` |
|
||||
| `httpclient-security` | `…httpclient.security` | `…outbound.httpclient.security` |
|
||||
| `httpclient-observability` | `…httpclient.observation` | `…outbound.httpclient.observation` |
|
||||
| `httpclient-transport-reactor-netty` | `…httpclient.reactor` | `…outbound.httpclient.reactor` |
|
||||
| `httpclient-webclient` | `…httpclient.webclient` | `…outbound.httpclient.webclient` |
|
||||
| `httpclient-service-client` | `…httpclient.service` | `…outbound.httpclient.service` |
|
||||
| `httpclient-dynamic-target` | `…httpclient.dynamic` | `…outbound.httpclient.dynamic` |
|
||||
| `httpclient-resttemplate-migration` | `…httpclient.migration` | `…outbound.httpclient.migration` |
|
||||
| `httpclient-spring7-service-groups` | `…httpclient.spring7` | `…outbound.httpclient.spring7` |
|
||||
| `httpclient-jetty-http3-experimental` | `…httpclient.http3` | `…outbound.httpclient.http3` |
|
||||
| `httpclient-spring-boot-starter` | `…httpclient.autoconfigure` | `dev.caskeleton.bootstrap.autoconfigure.httpclient` |
|
||||
| `httpclient-testkit` | `…httpclient.testkit` | `…outbound.httpclient.testkit` (`testkit` source set) |
|
||||
|
||||
## 3. Other deliberate substitutions
|
||||
|
||||
| Design assumption | Repository reality | Adaptation |
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL, `build-logic` convention plugin | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` dependency locking | Dependencies declared in `src/adapter/outbound/httpclient/build.gradle`; `gradle.lockfile` regenerated. |
|
||||
| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.0 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. |
|
||||
| `settings.gradle.kts` module registration | Fail-closed registry | No registry change; leaf identity, gradle path, allowed dependencies unchanged. |
|
||||
| Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable | Spring's blocking factory drives Apache's **classic** client, which is HTTP/1.1 only; HTTP/2 lives in Apache's async client | `ApacheBlockingTransportProvider` declares HTTP/1.1 and rejects an HTTP/2 profile at startup. Blocking HTTP/2 is served by the JDK transport, measured by `NegotiatedProtocolContractTest`. |
|
||||
| Design §28.1 names WireMock for stateful fixtures | WireMock's Jetty modules bind a different Jetty 12 ABI than the Boot-managed one this module already needs for HTTP/3, and fail at server start | `StatefulUpstream` provides path-keyed stateful responses on the existing fixture server; the WireMock dependency was removed rather than worked around with a shaded jar |
|
||||
| Per-task `git commit` | `AGENTS.md`: commit policy is `human-only` | Implementation is delivered unstaged; commits are the human's action. This is the only plan step intentionally not executed, and it is recorded here. |
|
||||
| `docs/httpclient/**`, `.github/workflows/httpclient-*.yml`, `scripts/verify-httpclient-docs.py` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. |
|
||||
|
||||
## 4. What is unchanged from the design
|
||||
|
||||
- H1 / H2 / H3 / H4 exposure rules and the forbidden native-engine signatures.
|
||||
- `ExecutionEvidence`, `BodyReplayability`, `OperationIdempotency`, `AttemptStage`, `FailureCategory`.
|
||||
- `HttpOperation`, `HttpCallResult`, `BodySource`, `ResponseType`, `BlockingStreamingResponse`.
|
||||
- The complete stable exception hierarchy and `HttpFailureMetadata` redaction rules.
|
||||
- Named Client Profile schema, startup validation codes, and operation override direction.
|
||||
- Effective deadline formula, attempt budget, and streaming setup/idle split.
|
||||
- Retry eligibility inputs, the ordered decision table, retry budget, and backoff rules.
|
||||
- Circuit → Rate Limiter → Bulkhead attempt order and logical admission placement.
|
||||
- OAuth2 cache key, single-flight refresh, and the 401 replay-at-most-once rule.
|
||||
- TLS allow/forbid lists and permanent-failure classification.
|
||||
- Dynamic Target canonicalization → all-answer DNS validation → pinning → redirect revalidation.
|
||||
- Low-cardinality tag allowlist, forbidden labels, trace and logging rules.
|
||||
- Runtime generation swap and drain semantics.
|
||||
@@ -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