chore: initialize from backend template 0a6dd0e

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