Files
DongHyeonkaandClaude Opus 5 0cd959a494 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>
2026-08-11 16:49:31 +09:00

135 lines
5.3 KiB
YAML

# Cluster lane. Six nodes: three primaries so cross-slot behaviour is observable at all, and three
# replicas so a promotion can be forced without losing a shard.
#
# Host networking for the same reason as the Sentinel lane, and a sharper one. A cluster client does
# not talk to one address: it reads `CLUSTER SHARDS`, learns every node's address, and connects to
# each of them itself. On a bridge those addresses are container-internal, so a client on the host
# resolves a topology it cannot dial and every redirect points somewhere unreachable. Sharing the
# host network namespace makes the addresses the cluster advertises the addresses the client can
# use, which is the difference between testing the SDK and testing Docker's network.
#
# Ports are fixed because they are written into the cluster's own configuration at creation time:
# the node identity a redirect names has to be an address the client can dial.
#
# nodes 7100..7105 · cluster bus 17100..17105
#
# The ACL file is loaded on every node. The accounts are the deployment's last enforcement boundary
# and a cluster enforces them per node, so "they exist on one node" is not evidence.
#
# min-replicas-to-write is set here for the same reason as on the Sentinel lane. A cluster promotes
# a replica without asking the client too, so a superseded primary keeps acknowledging writes it
# will discard on resync — the Sentinel lane measured 2,086 of them in one eleven-second window.
# Nothing about slot ownership changes that, and this lane was written without the setting at first
# precisely because the failure mode is easy to think of as Sentinel-specific. It is not.
x-node: &node
image: "redis:${REDIS_VERSION:-7.4}"
network_mode: host
volumes:
- ../acl:/etc/redis/acl:ro
entrypoint:
- /bin/sh
- -c
- |
exec redis-server \
--port $$NODE_PORT \
--cluster-enabled yes \
--cluster-config-file /tmp/nodes.conf \
--cluster-node-timeout 2000 \
--cluster-announce-ip 127.0.0.1 \
--appendonly no \
--save '' \
--min-replicas-to-write 1 \
--min-replicas-max-lag 1 \
--masteruser ca-skeleton-replication \
--masterauth fixture-replication \
--aclfile /etc/redis/acl/all-accounts.acl
healthcheck:
test: ["CMD-SHELL", "[ \"$$(redis-cli -p $$NODE_PORT --user ca-skeleton-application --pass fixture-application --no-auth-warning ping)\" = PONG ]"]
interval: 2s
timeout: 2s
retries: 15
services:
node-1:
<<: *node
environment:
NODE_PORT: "7100"
node-2:
<<: *node
environment:
NODE_PORT: "7101"
node-3:
<<: *node
environment:
NODE_PORT: "7102"
node-4:
<<: *node
environment:
NODE_PORT: "7103"
node-5:
<<: *node
environment:
NODE_PORT: "7104"
node-6:
<<: *node
environment:
NODE_PORT: "7105"
# The cluster is created after every node reports healthy, and the lane is not "up" until every
# slot is covered. A test that starts before slot assignment finishes sees MOVED and CLUSTERDOWN
# for reasons that have nothing to do with the SDK.
init:
image: "redis:${REDIS_VERSION:-7.4}"
network_mode: host
depends_on:
node-1: {condition: service_healthy}
node-2: {condition: service_healthy}
node-3: {condition: service_healthy}
node-4: {condition: service_healthy}
node-5: {condition: service_healthy}
node-6: {condition: service_healthy}
entrypoint:
- /bin/sh
- -c
- |
redis-cli --user ca-skeleton-cluster-bootstrap --pass fixture-bootstrap --no-auth-warning \
--cluster create \
127.0.0.1:7100 127.0.0.1:7101 127.0.0.1:7102 \
127.0.0.1:7103 127.0.0.1:7104 127.0.0.1:7105 \
--cluster-replicas 1 --cluster-yes
# Authenticated, like every other command against this fixture. The `default` user is off,
# so an unauthenticated CLUSTER INFO answers NOAUTH — which never matches, so this loop
# never ended, the helper never exited, and `up --wait` returned on the nodes' own health
# while slot assignment was still in flight. A lane that reports ready before it can serve
# a key produces failures that look like SDK defects and are not.
until redis-cli -p 7100 \
--user ca-skeleton-cluster-bootstrap --pass fixture-bootstrap --no-auth-warning \
cluster info | grep -q 'cluster_state:ok'; do sleep 1; done
echo "cluster ready"
# `up --wait` returns when every service is running or healthy, and a one-shot helper is neither
# for as long as it runs — so the wait ended while slots were still being assigned, and whichever
# test connected first saw a cluster that could not serve its keys. This gate is a service the
# wait can see: it cannot become healthy until the cluster reports a fully covered keyspace.
ready:
image: "redis:${REDIS_VERSION:-7.4}"
network_mode: host
depends_on:
init: {condition: service_completed_successfully}
command: ["sleep", "infinity"]
healthcheck:
test:
- CMD-SHELL
- >-
[ "$$(redis-cli -p 7100 --user ca-skeleton-cluster-bootstrap
--pass fixture-bootstrap --no-auth-warning cluster info
| tr -d '\r' | grep -c '^cluster_state:ok$$')" = 1 ]
interval: 1s
timeout: 3s
retries: 60