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:
DongHyeonka
2026-08-11 16:49:31 +09:00
co-authored by Claude Opus 5
parent 5f10b791d3
commit 0cd959a494
148 changed files with 26812 additions and 2368 deletions
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Fail when the HTTP Client Platform's code and documentation have drifted.
The design (§33 "Documentation") requires the support matrix, configuration reference, security
guide, runbook, and migration guide to match the code. Review cannot hold that line by itself, so
this verifier extracts the names that are part of the public contract -- stable exceptions, metric
names, configuration properties, startup violation codes, and transports -- and fails when one
exists in code but nowhere in the documentation.
It deliberately checks one direction only. A name documented but not yet implemented is a plan; a
name implemented but undocumented is a surprise for whoever is on call.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
PLATFORM = REPO_ROOT / "src/adapter/outbound/httpclient/src/main/java/dev/caskeleton/adapter/outbound/httpclient"
BOOTSTRAP = REPO_ROOT / "src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/httpclient"
DOCS_DIR = REPO_ROOT / "docs/httpclient"
ENV_FIELD_MANIFEST = REPO_ROOT / "docs/httpclient/env-fields.yaml"
REQUIRED_DOCS = [
"support-matrix.md",
"configuration-reference.md",
"retry-and-ambiguity.md",
"security.md",
"streaming.md",
"operations.md",
"migration-guide.md",
"release-checklist.md",
"performance-baseline.md",
"repository-adaptation.md",
]
def read_docs() -> str:
return "\n".join(
(DOCS_DIR / name).read_text(encoding="utf-8") for name in REQUIRED_DOCS
)
def stable_exceptions() -> list[str]:
error_dir = PLATFORM / "api/error"
return sorted(
path.stem
for path in error_dir.glob("Http*Exception.java")
if path.stem != "HttpClientException"
)
def metric_names() -> list[str]:
source = (PLATFORM / "observation/HttpClientObservationNames.java").read_text(encoding="utf-8")
return sorted(set(re.findall(r'"(http\.client\.[a-z_.]+)"', source)))
def violation_codes() -> list[str]:
codes: set[str] = set()
for source_file in [
PLATFORM / "profile/ClientProfileValidator.java",
PLATFORM / "security/TlsPolicyValidator.java",
BOOTSTRAP / "HttpClientStartupValidator.java",
]:
source = source_file.read_text(encoding="utf-8")
codes.update(re.findall(r'"([A-Z][A-Z0-9_]{4,})"', source))
return sorted(codes)
def configuration_properties() -> list[str]:
"""Every leaf property under `app.httpclient`, nested and dynamic blocks included.
Read from the environment-field manifest rather than from the record source. The manifest is
derived from `HttpClientPlatformSettings` by `HttpClientEnvironmentKeys` and held to it in both
directions by `HttpClientPlatformEnvManifestTest`, so it cannot drift from the code; parsing the
record here a second time, with a regex, could only agree with it by luck. The previous version
of this function did exactly that and saw eighteen top-level names, which is why a nested pool,
timeout or TLS setting could be added and documented nowhere.
"""
names: set[str] = set()
for line in ENV_FIELD_MANIFEST.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped.startswith("- field:"):
continue
path = stripped[len("- field:") :].strip()
leaf = path.split(".")[-1]
# `clients[N]` and `allowed-hosts[M]` are documented by name, not by position.
names.add(re.sub(r"\[[NM]\]$", "", leaf))
return sorted(names)
def transports() -> list[str]:
source = (PLATFORM / "profile/TransportType.java").read_text(encoding="utf-8")
body = source[source.index("public enum TransportType") :]
return sorted(set(re.findall(r"^\s{2}([A-Z][A-Z_]*),?$", body, flags=re.MULTILINE)))
def main() -> int:
missing_docs = [name for name in REQUIRED_DOCS if not (DOCS_DIR / name).is_file()]
if missing_docs:
print("FAIL missing documentation file(s): " + ", ".join(missing_docs))
return 1
documentation = read_docs()
failures: list[str] = []
checks = {
"stable exception": stable_exceptions(),
"metric": metric_names(),
"startup violation code": violation_codes(),
"configuration property": configuration_properties(),
"transport": transports(),
}
for kind, names in checks.items():
for name in names:
if name not in documentation:
failures.append(f"{kind} '{name}' exists in code but is not documented")
if failures:
print(f"FAIL httpclient documentation drift ({len(failures)} finding(s)):")
for failure in failures:
print(" - " + failure)
return 1
total = sum(len(names) for names in checks.values())
print(f"PASS httpclient documentation covers {total} code-derived name(s):")
for kind, names in checks.items():
print(f" {kind}: {len(names)}")
return 0
if __name__ == "__main__":
sys.exit(main())