feat(httpclient): close the platform review's P0/P1/P2 findings
The review found one defect shape repeated across the platform: surfaces that were declared, bound, and documented, but that nothing read. An operator configuring fullUrlRecording, bodyLogging, retry.policy, validatedDnsPinning, timeout.dns, or any of ten declared metric names got a guarantee the code never delivered. Every such surface is now in exactly one of three states -- wired for real, rejected at startup, or registered in a test-enforced gap list with its reason. No silent no-ops remain. P0: - Activate the platform from bootstrap behind app.httpclient.enabled, with a single auto-configuration importing the nine child configurations. - Give the platform a strict, repository-level ENV contract: 74 leaf fields derived from the settings record tree, unknown APP_HTTPCLIENT_* rejected. - Route typed HTTP service clients through the call kernel via KernelHttpExchangeAdapter, so they stop bypassing platform policy. - Pin dynamic-target DNS resolution to the socket for the life of a call, closing the resolve-then-connect TOCTOU / rebinding window. - Actually transmit the idempotency key, and make retry eligibility depend on transmission rather than on merely holding one. - Reject reactive authentication and reactive redirect at startup instead of declaring support that does not function. - Fix the Reactor-only Stable contract row so the lane stops failing. - Stop advertising HTTP/3 on a transport that negotiates HTTP/1. P1 covers execution and retry accounting, redirect security (per-hop target guarding, sensitive-header stripping, 303 body handling), runtime rotation and transport resource ownership keyed by generation, dynamic-target hardening (subdomain matching, global-unicast classification, strict CIDR parsing), protocol intent, pool and timeout wiring, streaming and body limits, observability parity, and OAuth single-flight refresh on a bounded pool with a bounded wait. P2 covers configuration and documentation drift, the Gradle check wiring for the four hermetic lanes, and the CI gate matrix. Two test-quality defects surfaced while closing these: the HTTP/2 stream saturation test ran against cleartext HTTP/1.1 while asserting nothing about the protocol, and an OAuth contention test slept on a latch that could fire before the callers it meant to observe. Both now assert what their names claim. Verification run: :adapter:outbound:httpclient:check and :app-bootstrap:check (checkstyle, spotless, spotbugs, and the four hermetic lanes), verifyCleanArchitectureDependencies, verifyEnvKeys, verifyOneTypePerFile, verifyDependencyLocks, the documentation and gate-matrix verifiers, and the performance lane against a real TLS+ALPN HTTP/2 server. Not executed, and tracked rather than claimed: Docker/Toxiproxy fault injection, JMH, a real QUIC/HTTP3 server, a real Spring Framework 6.2 distribution (now a delegated-pending gate), live OAuth/TLS/proxy/DNS integration, and a whole-repository check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5f10b791d3
commit
0cd959a494
@@ -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
|
||||
Reference in New Issue
Block a user