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
+99 -4
View File
@@ -24,6 +24,13 @@ gates:
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
- id: conditional-transport-qualification
release_blocking: true
mechanism: gradle-custom-task
ref: conditionalTransportQualification
workflow: ci-quality-gates.yml
job: quality-gates
execution: explicit
- id: clean-architecture-dependencies
release_blocking: true
mechanism: gradle-custom-task
@@ -101,12 +108,12 @@ gates:
workflow: ci-quality-gates.yml
job: gate-matrix-lint
execution: job
- id: redis-standalone
- id: redis-sdk
release_blocking: true
mechanism: workflow-job
ref: redis-standalone
ref: redis-sdk
workflow: ci-quality-gates.yml
job: redis-standalone
job: redis-sdk
execution: job
- id: jpa-candidate-evidence
release_blocking: true
@@ -171,7 +178,7 @@ gates:
workflow: object-storage-qualification.yml
job: minio-managed-contract
execution: explicit
- id: poster-image-v7-migration
- id: poster-image-migration
release_blocking: true
mechanism: gradle-custom-task
ref: posterImageMigrationTest
@@ -192,3 +199,91 @@ gates:
workflow: object-storage-qualification.yml
job: aws-managed-common-subset
execution: job
- id: redis-sdk-support-matrix
release_blocking: true
mechanism: contract-test
ref: adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/RedisSupportMatrixTest.java
workflow: ci-quality-gates.yml
job: quality-gates
execution: check
# Promoted from delegated-pending: the workflow is no longer manual-only. A pull request that
# touches the Redis leaf runs the standalone lane, and the full supported-version x topology
# matrix runs nightly and on a release candidate. While it was dispatch-only, a release could
# claim topology evidence that nobody had produced for that commit.
- id: redis-sdk-topology-evidence
release_blocking: conditional
mechanism: workflow-job
ref: topology-evidence
workflow: redis-sdk-topology.yml
job: topology-evidence
execution: job
- id: httpclient-stable-contract
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientStableContractTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-security-suite
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientSecurityTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-fault-injection
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientFailureInjectionTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-performance-certification
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientPerformanceTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-spring62-api-surface
release_blocking: true
mechanism: gradle-custom-task
ref: spring62ApiSurfaceScan
workflow: httpclient-release.yml
job: release-gate
execution: explicit
# The 6.2 API-surface scan above proves the common packages compile against the older surface. It
# does not prove they run on it, and the two were being conflated: a lane called
# "spring62CompatibilityTest" reads as a runtime compatibility proof. The Gradle task is renamed to
# say what it does, and the runtime claim is registered here as its own delegated-pending control
# so the gap is a tracked absence rather than an unstated one. Executing it needs a Spring
# Framework 6.2 distribution resolved into a separate test runtime, which this repository's
# Boot 4.0 baseline does not carry.
- id: httpclient-spring62-runtime
release_blocking: conditional
mechanism: delegated-pending
ref: spring62-runtime-lane
workflow: httpclient-release.yml
job: release-gate
execution: job
- id: httpclient-spring70-compatibility
release_blocking: true
mechanism: gradle-custom-task
ref: spring70CompatibilityTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
- id: httpclient-documentation-drift
release_blocking: true
mechanism: workflow-job
ref: httpclient-documentation
workflow: httpclient-release.yml
job: httpclient-documentation
execution: job
- id: httpclient-event-loop-blocking
release_blocking: true
mechanism: gradle-custom-task
ref: httpClientBlockHoundTest
workflow: httpclient-release.yml
job: release-gate
execution: explicit
+179 -16
View File
@@ -2,15 +2,34 @@
set -euo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
readonly REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
readonly EXPECTED_GATE_COUNT=26
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
printf '::error::gate-matrix-lint: script resolved outside the repository .github/scripts directory\n' >&2
exit 1
if (( $# > 1 )); then
printf '::error::gate-matrix-lint: expected zero arguments or one repository root\n' >&2
exit 2
fi
if (( $# == 1 )); then
if [[ ! -d "$1" ]]; then
printf '::error::gate-matrix-lint: repository root is not a directory: %s\n' "$1" >&2
exit 2
fi
REPO_ROOT="$(cd -- "$1" && pwd -P)"
else
REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
if [[ "${SCRIPT_DIR}" != "${EXPECTED_SCRIPT_DIR}" ]]; then
printf '::error::gate-matrix-lint: script location must be repository .github/scripts directory\n' >&2
exit 1
fi
fi
readonly REPO_ROOT
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
# Deliberately a literal: a gate silently appearing or disappearing is the drift this lint exists to
# catch, so growing the matrix is an explicit edit here. 38 as of the HTTP Client platform hardening,
# which registered httpclient-spring62-runtime as a delegated-pending control — the 6.2 *runtime*
# claim, distinct from the API-surface scan that was standing in for it.
readonly EXPECTED_GATE_COUNT=38
if [[ ! -f "${MATRIX}" ]]; then
printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2
exit 1
@@ -80,6 +99,146 @@ job_body() {
' "${workflow_file}"
}
gradle_command_has_safe_literal_grammar() {
local command="$1"
[[ "${command}" =~ ^\./gradlew([[:space:]]+[A-Za-z0-9_.:/@=,+-]+)+[[:space:]]*$ ]]
}
gradle_token_suppresses_execution() {
local token="$1"
case "${token}" in
'--dry-run'|'--dry-run='*|'-m'|'-x'|'-x'*|'--exclude-task'|'--exclude-task='*) return 0 ;;
*) return 1 ;;
esac
}
gradle_token_is_allowed_gate_argument() {
local token="$1"
case "${token}" in
'--no-daemon'|'--stacktrace'|'--warning-mode=fail') return 0 ;;
esac
[[ "${token}" =~ ^:?[A-Za-z0-9_][A-Za-z0-9_.-]*(:[A-Za-z0-9_][A-Za-z0-9_.-]*)*$ ]]
}
gradle_plugin_is_applied() {
local plugin_id="$1"
grep -RqsF --include='build.gradle' -- "id '${plugin_id}'" "${REPO_ROOT}/src" \
|| grep -RqsF --include='build.gradle' -- "id \"${plugin_id}\"" "${REPO_ROOT}/src" \
|| grep -RqsF --include='build.gradle' -- "apply plugin: '${plugin_id}'" "${REPO_ROOT}/src" \
|| grep -RqsF --include='build.gradle' -- "apply plugin: \"${plugin_id}\"" "${REPO_ROOT}/src"
}
gradle_custom_task_is_registered_in_build_file() {
local task_name="$1"
local build_file="$2"
if grep -qsE -- "tasks\\.register\\(['\"]${task_name}['\"]" "${build_file}"; then
return 0
fi
awk -v required_task="${task_name}" '
index($0, "registerStrictQualificationTest(") > 0 { inside_registration=1 }
inside_registration && /^[[:space:]]*name:[[:space:]]*/ {
candidate=$0
sub(/^[[:space:]]*name:[[:space:]]*/, "", candidate)
quote=substr(candidate, 1, 1)
if (quote != "\"" && quote != sprintf("%c", 39)) {
next
}
candidate=substr(candidate, 2)
closing_quote=index(candidate, quote)
if (closing_quote == 0) {
next
}
candidate=substr(candidate, 1, closing_quote - 1)
if (candidate == required_task) {
found=1
}
}
inside_registration && /\)[[:space:]]*$/ { inside_registration=0 }
END { exit found ? 0 : 1 }
' "${build_file}"
}
gradle_custom_task_is_registered() {
local task_name="$1"
local build_file
while IFS= read -r -d '' build_file; do
if gradle_custom_task_is_registered_in_build_file "${task_name}" "${build_file}"; then
return 0
fi
done < <(find "${REPO_ROOT}/src" -type f -name '*.gradle' -print0)
return 1
}
gradle_token_matches_registered_task() {
local token="$1"
local required_task="$2"
local project_path build_file
if [[ "${token}" == "${required_task}" || "${token}" == ":${required_task}" ]]; then
return 0
fi
if [[ "${token}" != :* || "${token}" != *:"${required_task}" ]]; then
return 1
fi
project_path="${token%:"${required_task}"}"
project_path="${project_path#:}"
project_path="${project_path%:}"
build_file="${REPO_ROOT}/src/${project_path//:/\/}/build.gradle"
[[ -f "${build_file}" ]] \
&& gradle_custom_task_is_registered_in_build_file "${required_task}" "${build_file}"
}
job_runs_gradle_task() {
local workflow_file="$1"
local job_id="$2"
local required_task="$3"
local command token
local found_task suppressed
local -a tokens=()
while IFS= read -r command; do
if ! gradle_command_has_safe_literal_grammar "${command}"; then
continue
fi
read -r -a tokens <<< "${command}"
if (( ${#tokens[@]} < 2 )) || [[ "${tokens[0]}" != './gradlew' ]]; then
continue
fi
found_task=0
suppressed=0
for token in "${tokens[@]:1}"; do
case "${token}" in
'&&'|'||'|';'|'|'|'#'*) break ;;
esac
if gradle_token_suppresses_execution "${token}"; then
suppressed=1
break
fi
if ! gradle_token_is_allowed_gate_argument "${token}"; then
suppressed=1
break
fi
if gradle_token_matches_registered_task "${token}" "${required_task}"; then
found_task=1
fi
done
if (( found_task == 1 && suppressed == 0 )); then
return 0
fi
done < <(
job_body "${workflow_file}" "${job_id}" | awk '
/^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/ {
command=$0
sub(/^[[:space:]]+(-[[:space:]]+)?run:[[:space:]]+/, "", command)
if (command !~ /^(\||>)/) {
print command
}
}
'
)
return 1
}
while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
[[ -z "${id}" ]] && continue
total=$((total + 1))
@@ -114,8 +273,11 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
case "${mechanism}" in
gradle-custom-task)
if ! grep -RqsE -- "tasks\\.register\\(['\"]${ref}['\"]" "${REPO_ROOT}/src" \
--include='build.gradle'; then
if [[ ! "${ref}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
failures+=("gate '${id}' has unsafe Gradle custom task ref '${ref}'")
continue
fi
if ! gradle_custom_task_is_registered "${ref}"; then
failures+=("gate '${id}' references unregistered Gradle task '${ref}'")
continue
fi
@@ -123,12 +285,13 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
gradle-plugin-task)
plugin="${ref%@*}"
task="${ref#*@}"
if [[ "${plugin}" == "${ref}" || -z "${task}" ]]; then
failures+=("gate '${id}' must use plugin@task for gradle-plugin-task")
if [[ "${plugin}" == "${ref}" \
|| ! "${plugin}" =~ ^[A-Za-z][A-Za-z0-9.-]*$ \
|| ! "${task}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
failures+=("gate '${id}' has unsafe Gradle plugin task ref '${ref}'")
continue
fi
if ! grep -RqsE -- "(id|apply plugin:)[[:space:]]+['\"]${plugin}['\"]" "${REPO_ROOT}/src" \
--include='build.gradle'; then
if ! gradle_plugin_is_applied "${plugin}"; then
failures+=("gate '${id}' references unapplied Gradle plugin '${plugin}'")
continue
fi
@@ -158,7 +321,7 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
case "${execution}" in
check)
if ! job_body "${workflow_file}" "${job}" | grep -Eqs -- '\./gradlew[[:space:]]+check([[:space:]]|$)'; then
if ! job_runs_gradle_task "${workflow_file}" "${job}" 'check'; then
failures+=("gate '${id}' expects Gradle check in job '${job}'")
continue
fi
@@ -170,7 +333,7 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
fi
;;
explicit)
if ! job_body "${workflow_file}" "${job}" | grep -Fqs -- "${ref}"; then
if ! job_runs_gradle_task "${workflow_file}" "${job}" "${ref}"; then
failures+=("gate '${id}' task '${ref}' is not explicit in job '${job}'")
continue
fi
+740
View File
@@ -0,0 +1,740 @@
#!/usr/bin/env bash
set -euo pipefail
readonly EXPECTED_DISTRIBUTION_SUFFIX='/gradle-9.0.0-bin.zip'
readonly EXPECTED_DISTRIBUTION_SHA256='8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b'
readonly EXPECTED_WRAPPER_JAR_SHA256='76805e32c009c0cf0dd5d206bddc9fb22ea42e84db904b764f3047de095493f3'
readonly EXPECTED_VALIDATION_ACTION='gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6'
readonly EXPECTED_DEPENDENCY_SUBMISSION_ACTION='gradle/actions/dependency-submission@748248ddd2a24f49513d8f472f81c3a07d4d50e1'
readonly EXPECTED_GUARDED_GRADLE_IF="\${{ always() && steps.gradle-wrapper-validation.outcome == 'success' }}"
# Workflow-lock update procedure (only after intentional review of the complete workflow diff):
# find .github/workflows -mindepth 1 -maxdepth 1 \
# \( -name '*.yml' -o -name '*.yaml' \) ! -type f -print # must print nothing
# find .github/workflows -mindepth 1 -maxdepth 1 -type f \
# \( -name '*.yml' -o -name '*.yaml' \) -print0 \
# | LC_ALL=C sort -z | xargs -0 sha256sum
# Replace this entire sorted array in the same reviewed change. Never refresh a single digest
# merely to make this verifier pass.
readonly EXPECTED_WORKFLOW_LOCK=(
'a5986c6d865e28d6160dc09c513c430c9d9c38d154c67423cb34448cb1e9863c .github/workflows/ci-quality-gates.yml'
'59de260a70c2c0a0d686d97035a189dc0567395977dfa18758f1a2d89d15a00d .github/workflows/dependency-vulnerability.yml'
'1b3220c922f954500f727c6a799b24e4962915845b9248e8e496e5050e829f28 .github/workflows/fileserver-nightly.yml'
'26812e16b8d6e4472543ddd49c7b16ee6b7697834ddbb653fa0424befd71c544 .github/workflows/fileserver-pr.yml'
'86a240c4ce7d0d293616e30de30ed77bcfdc700fedb8916f083eda9567099096 .github/workflows/fileserver-release.yml'
'58e28f3358d794ca08f4aa8df4516e03f50a9ee58488b3f0d2619998e069ef14 .github/workflows/httpclient-contract.yml'
'823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml'
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
)
readonly EXPECTED_WRAPPER_PROPERTIES=(
'distributionBase=GRADLE_USER_HOME'
'distributionPath=wrapper/dists'
"distributionUrl=https\://services.gradle.org/distributions${EXPECTED_DISTRIBUTION_SUFFIX}"
"distributionSha256Sum=${EXPECTED_DISTRIBUTION_SHA256}"
'networkTimeout=10000'
'validateDistributionUrl=true'
'zipStoreBase=GRADLE_USER_HOME'
'zipStorePath=wrapper/dists'
)
fail() {
printf 'gradle-wrapper-contract: FAIL: %s\n' "$1" >&2
exit 1
}
if [[ $# -ne 1 ]]; then
fail 'expected exactly one repository-root argument'
fi
readonly REPOSITORY_ROOT=$1
[[ -d "${REPOSITORY_ROOT}" ]] || fail "repository root is not a directory: ${REPOSITORY_ROOT}"
readonly WRAPPER_PROPERTIES="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.properties"
readonly WRAPPER_JAR="${REPOSITORY_ROOT}/src/gradle/wrapper/gradle-wrapper.jar"
readonly WORKFLOWS_DIRECTORY="${REPOSITORY_ROOT}/.github/workflows"
[[ -f "${WRAPPER_PROPERTIES}" ]] || fail "missing wrapper properties: ${WRAPPER_PROPERTIES}"
[[ -f "${WRAPPER_JAR}" ]] || fail "missing wrapper JAR: ${WRAPPER_JAR}"
[[ -d "${WORKFLOWS_DIRECTORY}" ]] || fail "missing workflows directory: ${WORKFLOWS_DIRECTORY}"
if ! printf '%s\n' "${EXPECTED_WRAPPER_PROPERTIES[@]}" | cmp -s - "${WRAPPER_PROPERTIES}"; then
fail 'wrapper properties must match the exact canonical Gradle 9.0.0 eight-line contract'
fi
readonly actual_wrapper_jar_sha256=$(sha256sum "${WRAPPER_JAR}" | awk '{print $1}')
[[ "${actual_wrapper_jar_sha256}" == "${EXPECTED_WRAPPER_JAR_SHA256}" ]] \
|| fail "wrapper JAR SHA-256 mismatch: ${actual_wrapper_jar_sha256}"
workflow_lock_valid=1
actual_workflow_lock=()
while IFS= read -r -d '' locked_workflow; do
locked_workflow_relative=${locked_workflow#"${REPOSITORY_ROOT}"/}
if [[ -L "${locked_workflow}" || ! -f "${locked_workflow}" ]]; then
locked_workflow_sha256='<invalid-file-type>'
else
locked_workflow_sha256=$(sha256sum -- "${locked_workflow}" | awk '{print $1}')
fi
actual_workflow_lock+=("${locked_workflow_sha256} ${locked_workflow_relative}")
done < <(
find "${WORKFLOWS_DIRECTORY}" -mindepth 1 -maxdepth 1 \
\( -name '*.yml' -o -name '*.yaml' \) -print0 \
| LC_ALL=C sort -z
)
workflow_lock_entry_count=${#EXPECTED_WORKFLOW_LOCK[@]}
if ((${#actual_workflow_lock[@]} > workflow_lock_entry_count)); then
workflow_lock_entry_count=${#actual_workflow_lock[@]}
fi
for ((workflow_lock_index = 0; workflow_lock_index < workflow_lock_entry_count; workflow_lock_index++)); do
expected_workflow_lock_entry=${EXPECTED_WORKFLOW_LOCK[workflow_lock_index]-<missing>}
actual_workflow_lock_entry=${actual_workflow_lock[workflow_lock_index]-<missing>}
if [[ "${actual_workflow_lock_entry}" != "${expected_workflow_lock_entry}" ]]; then
printf 'gradle-wrapper-contract: workflow lock mismatch: expected %q; actual %q\n' \
"${expected_workflow_lock_entry}" "${actual_workflow_lock_entry}" >&2
workflow_lock_valid=0
fi
done
workflow_count=0
gradle_job_count=0
while IFS= read -r -d '' workflow; do
if ! awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" '
function reset_step(known_field) {
step_active = 0
run_block = 0
for (known_field in step_fields) {
delete step_fields[known_field]
}
}
function reset_job() {
job = ""
in_steps = 0
steps_count = 0
reset_step()
}
function indentation(line, first_non_space) {
if (line ~ /^ *$/) {
return length(line)
}
first_non_space = match(line, /[^ ]/)
return first_non_space - 1
}
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}
function grammar_error(message) {
printf "%s: job %s %s\n", workflow, job == "" ? "<unknown>" : job, message > "/dev/stderr"
invalid = 1
}
function workflow_grammar_error(message) {
printf "%s: %s\n", workflow, message > "/dev/stderr"
invalid = 1
}
function validate_job_shape() {
if (job != "" && steps_count != 1) {
grammar_error("must contain exactly one canonical steps block")
}
}
function is_allowed_step_field(field) {
return field == "name" \
|| field == "id" \
|| field == "uses" \
|| field == "run" \
|| field == "if" \
|| field == "shell" \
|| field == "with" \
|| field == "env" \
|| field == "working-directory" \
|| field == "continue-on-error" \
|| field == "timeout-minutes"
}
function validate_uses_scalar(value, first, quote, closing, index_value, suffix, action, single_quote) {
value = trim(value)
if (value == "" || index(value, "\\") != 0) {
grammar_error("has unsupported uses scalar")
return
}
first = substr(value, 1, 1)
single_quote = sprintf("%c", 39)
if (first == "\"" || first == single_quote) {
quote = first
closing = 0
for (index_value = 2; index_value <= length(value); index_value++) {
if (substr(value, index_value, 1) == quote) {
closing = index_value
break
}
}
if (closing == 0) {
grammar_error("has unsupported uses scalar")
return
}
suffix = substr(value, closing + 1)
if (suffix !~ /^[[:space:]]*(#.*)?$/) {
grammar_error("has unsupported uses scalar")
return
}
action = substr(value, 2, closing - 2)
if (index(action, quote) != 0) {
grammar_error("has unsupported uses scalar")
return
}
} else {
action = value
sub(/[[:space:]]+#.*$/, "", action)
action = trim(action)
if (action ~ /["'"'"'\\]/ || action ~ /^[*!&|>]/) {
grammar_error("has unsupported uses scalar")
return
}
}
if (action !~ /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*@[A-Za-z0-9_.\/-]+$/ \
&& action !~ /^\.\/[A-Za-z0-9_.\/-]+$/ \
&& action !~ /^docker:\/\/[^[:space:]]+$/) {
grammar_error("has unsupported uses scalar")
}
}
function validate_run_scalar(value, first) {
value = trim(value)
if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) {
run_block = 1
return
}
first = substr(value, 1, 1)
if (value == "" || first == "\"" || first == sprintf("%c", 39) \
|| first ~ /[*&!|>]/ || index(value, "\\") != 0) {
grammar_error("has unsupported run scalar")
}
}
function validate_step_field(content, field, value, separator) {
content = trim(content)
if (content ~ /^[{[]/) {
grammar_error("contains unsupported flow-style step syntax")
return
}
if (content ~ /^<</) {
grammar_error("contains a forbidden step merge key")
return
}
if (content ~ /^[*&!]/) {
grammar_error("contains unsupported step anchor, alias, or tag syntax")
return
}
if (content !~ /^[A-Za-z][A-Za-z0-9-]*:/) {
grammar_error("contains unsupported step field syntax")
return
}
separator = index(content, ":")
field = substr(content, 1, separator - 1)
value = substr(content, separator + 1)
sub(/^[[:space:]]*/, "", value)
if (!is_allowed_step_field(field)) {
grammar_error("contains unsupported step field: " field)
return
}
if (field in step_fields) {
grammar_error("contains duplicate step field: " field)
return
}
step_fields[field] = 1
if (field == "uses") {
validate_uses_scalar(value)
} else if (field == "run") {
validate_run_scalar(value)
}
}
BEGIN {
in_jobs = 0
invalid = 0
jobs_count = 0
single_quote = sprintf("%c", 39)
reset_job()
}
/^jobs:/ {
if ($0 !~ /^jobs:[[:space:]]*(#.*)?$/) {
workflow_grammar_error("jobs container must use a canonical block mapping")
next
}
jobs_count++
if (jobs_count != 1) {
workflow_grammar_error("workflow must contain exactly one canonical jobs block")
}
in_jobs = 1
next
}
/^"jobs":/ {
workflow_grammar_error("jobs container must use a canonical block mapping")
next
}
substr($0, 1, 7) == single_quote "jobs" single_quote ":" {
workflow_grammar_error("jobs container must use a canonical block mapping")
next
}
run_block == 0 && /^<<:/ {
workflow_grammar_error("workflow contains a forbidden merge key")
next
}
in_jobs && /^[^[:space:]#]/ {
validate_job_shape()
reset_job()
in_jobs = 0
}
in_jobs && /^ [^[:space:]#]/ {
if ($0 !~ /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/) {
grammar_error("job declaration must use a canonical block mapping")
next
}
validate_job_shape()
reset_job()
job = $0
sub(/^ /, "", job)
sub(/:.*/, "", job)
next
}
in_jobs && job != "" {
raw = $0
line_indent = indentation(raw)
if (run_block != 0) {
if (raw ~ /^ *$/ || line_indent > 8) {
next
}
run_block = 0
}
if (raw ~ /^ *#/) {
next
}
if (raw ~ /^ steps:/ || raw ~ /^ "steps":/ \
|| substr(raw, 1, 11) == " " single_quote "steps" single_quote ":") {
if (raw != " steps:") {
grammar_error("steps container must use a canonical block sequence")
next
}
steps_count++
if (steps_count != 1) {
grammar_error("must contain exactly one canonical steps block")
}
in_steps = 1
reset_step()
next
}
if (in_steps != 0 && line_indent == 4) {
in_steps = 0
reset_step()
}
if (raw ~ /^ *<<:/) {
grammar_error("contains a forbidden merge key")
next
}
if (in_steps != 0 && raw ~ /^ - /) {
reset_step()
step_active = 1
content = substr(raw, 9)
validate_step_field(content)
next
}
if (in_steps != 0 && raw ~ /^ -[[:space:]]*$/) {
grammar_error("contains unsupported empty step syntax")
next
}
if (in_steps != 0 && step_active != 0 && line_indent == 8) {
content = substr(raw, 9)
validate_step_field(content)
next
}
if (in_steps != 0 && line_indent == 6 && raw !~ /^ *$/) {
grammar_error("contains unsupported step-list syntax")
}
}
END {
validate_job_shape()
if (jobs_count != 1) {
workflow_grammar_error("workflow must contain exactly one canonical jobs block")
}
if (invalid) {
exit 1
}
}
' "${workflow}"; then
fail "workflow structural validation failed: ${workflow#"${REPOSITORY_ROOT}"/}"
fi
if ! grep -Fq -- './gradlew' "${workflow}" \
&& ! grep -Fq -- 'gradle/actions/dependency-submission@' "${workflow}"; then
continue
fi
((workflow_count += 1))
if ! jobs_in_workflow=$(
awk -v workflow="${workflow#"${REPOSITORY_ROOT}"/}" \
-v validation_action="${EXPECTED_VALIDATION_ACTION}" \
-v dependency_action="${EXPECTED_DEPENDENCY_SUBMISSION_ACTION}" \
-v guarded_gradle_if="${EXPECTED_GUARDED_GRADLE_IF}" '
function reset_step(known_field) {
step_active = 0
run_block = 0
step_kind = ""
step_name = ""
step_id = ""
step_uses = ""
step_uses_action = ""
step_if = ""
step_if_present = 0
step_continue_on_error = 0
step_gradle = 0
step_gradle_line = 0
step_unsupported_gradle = 0
step_field_count = 0
step_name_line = 0
step_id_line = 0
step_uses_line = 0
step_extra_field = ""
for (known_field in step_fields) {
delete step_fields[known_field]
delete step_field_raw[known_field]
}
}
function reset_job() {
job = ""
checkout_line = 0
validation_line = 0
gradle_line = 0
in_steps = 0
unsupported_gradle = 0
reset_step()
}
function indentation(line, first_non_space) {
if (line ~ /^ *$/) {
return length(line)
}
first_non_space = match(line, /[^ ]/)
return first_non_space - 1
}
function has_gradle_reference(line) {
return index(line, "./gradlew") != 0 \
|| index(line, "gradle/actions/dependency-submission@") != 0
}
function trim(value) {
sub(/^[[:space:]]+/, "", value)
sub(/[[:space:]]+$/, "", value)
return value
}
function normalize_action(value, scalar, first, quote, closing, index_value) {
scalar = trim(value)
first = substr(scalar, 1, 1)
if (first == "\"" || first == single_quote) {
quote = first
closing = index(substr(scalar, 2), quote)
if (closing == 0) {
return ""
}
return substr(scalar, 2, closing - 1)
}
sub(/[[:space:]]+#.*$/, "", scalar)
return trim(scalar)
}
function record_gradle(line_number) {
step_gradle = 1
if (step_gradle_line == 0) {
step_gradle_line = line_number
}
if (gradle_line == 0) {
gradle_line = line_number
}
}
function record_uses(value, line_number, action) {
if (step_kind == "run") {
if (index(value, "gradle/actions/dependency-submission@") != 0) {
step_unsupported_gradle = 1
}
return
}
step_kind = "uses"
action = normalize_action(value)
step_uses = trim(value)
step_uses_action = action
step_uses_line = line_number
if (checkout_line == 0 && action ~ /^actions\/checkout@/) {
checkout_line = line_number
}
if (action == dependency_action) {
record_gradle(line_number)
} else if (index(action, "gradle/actions/dependency-submission@") != 0) {
record_gradle(line_number)
step_unsupported_gradle = 1
}
}
function record_run(value, line_number) {
if (step_kind == "uses") {
if (index(value, "./gradlew") != 0) {
step_unsupported_gradle = 1
}
return
}
step_kind = "run"
if (value ~ /^(\||>)[+-]?([[:space:]]+#.*)?$/) {
run_block = 1
} else if (index(value, "./gradlew") != 0) {
record_gradle(line_number)
}
}
function record_step_field(content, line_number, separator, field, value) {
separator = index(content, ":")
field = substr(content, 1, separator - 1)
value = substr(content, separator + 1)
sub(/^[[:space:]]*/, "", value)
step_fields[field] = 1
step_field_raw[field] = trim(content)
step_field_count++
if (field == "name") {
step_name = trim(value)
step_name_line = line_number
} else if (field == "id") {
step_id = trim(value)
step_id_line = line_number
} else if (field == "uses") {
record_uses(value, line_number)
} else if (field == "run") {
record_run(trim(value), line_number)
} else if (field == "if") {
step_if_present = 1
step_if = trim(value)
} else if (field == "continue-on-error") {
step_continue_on_error = 1
}
if (field != "name" && field != "id" && field != "uses" && step_extra_field == "") {
step_extra_field = step_field_raw[field]
}
}
function validate_wrapper_step() {
if (step_uses_action != validation_reference) {
return
}
if (step_extra_field != "") {
printf "%s: job %s wrapper validation step contains unsupported field: %s\n", workflow, job, step_extra_field > "/dev/stderr"
invalid = 1
return
}
if (step_field_count != 3 \
|| step_name != "Validate Gradle wrapper" \
|| step_id != "gradle-wrapper-validation" \
|| step_uses != validation_action \
|| !(step_name_line < step_id_line && step_id_line < step_uses_line)) {
printf "%s: job %s wrapper validation step must contain exact name, id, and uses fields only\n", workflow, job > "/dev/stderr"
invalid = 1
return
}
if (validation_line == 0) {
validation_line = step_uses_line
}
}
function validate_gradle_step() {
if (step_gradle == 0 && step_unsupported_gradle == 0) {
return
}
if (step_unsupported_gradle != 0 || ("uses" in step_fields && "run" in step_fields)) {
unsupported_gradle = 1
}
if (step_if_present != 0 && step_if != guarded_gradle_if) {
printf "%s: job %s has Gradle step with unsupported if condition: %s\n", workflow, job, step_if > "/dev/stderr"
invalid = 1
}
if (step_continue_on_error != 0) {
printf "%s: job %s has Gradle step with unsupported field: %s\n", workflow, job, step_field_raw["continue-on-error"] > "/dev/stderr"
invalid = 1
}
}
function finalize_step() {
if (step_active == 0) {
return
}
validate_wrapper_step()
validate_gradle_step()
}
function start_step() {
finalize_step()
reset_step()
step_active = 1
}
function validate_job() {
finalize_step()
if (job == "" || (gradle_line == 0 && unsupported_gradle == 0)) {
return
}
gradle_jobs++
if (unsupported_gradle != 0) {
printf "%s: job %s uses a Gradle invocation outside the canonical workflow structure\n", workflow, job > "/dev/stderr"
invalid = 1
}
if (gradle_line == 0) {
return
} else if (checkout_line == 0) {
printf "%s: job %s invokes Gradle without checkout\n", workflow, job > "/dev/stderr"
invalid = 1
} else if (validation_line == 0) {
printf "%s: job %s invokes Gradle without the exact pinned wrapper validation action\n", workflow, job > "/dev/stderr"
invalid = 1
} else if (!(checkout_line < validation_line && validation_line < gradle_line)) {
printf "%s: job %s must order checkout, exact wrapper validation, then Gradle\n", workflow, job > "/dev/stderr"
invalid = 1
}
}
BEGIN {
in_jobs = 0
invalid = 0
gradle_jobs = 0
single_quote = sprintf("%c", 39)
validation_reference = validation_action
sub(/[[:space:]]+#.*$/, "", validation_reference)
reset_job()
}
/^jobs:[[:space:]]*(#.*)?$/ {
in_jobs = 1
next
}
in_jobs && /^[^[:space:]#]/ {
validate_job()
reset_job()
in_jobs = 0
}
in_jobs && /^ [A-Za-z0-9_.-]+:[[:space:]]*(#.*)?$/ {
validate_job()
reset_job()
job = $0
sub(/^ /, "", job)
sub(/:.*/, "", job)
next
}
in_jobs && job != "" {
raw = $0
line_indent = indentation(raw)
if (run_block != 0) {
if (raw ~ /^ *$/) {
next
}
if (line_indent > 8) {
if (index(raw, "./gradlew") != 0) {
record_gradle(NR)
}
if (index(raw, "gradle/actions/dependency-submission@") != 0) {
step_unsupported_gradle = 1
}
next
}
run_block = 0
}
if (raw ~ /^ *#/) {
next
}
if (raw == " steps:") {
in_steps = 1
reset_step()
next
}
if (in_steps != 0 && line_indent == 4) {
finalize_step()
in_steps = 0
reset_step()
}
if (in_steps != 0 && raw ~ /^ - /) {
start_step()
content = substr(raw, 9)
record_step_field(content, NR)
next
}
if (in_steps != 0 && step_active != 0 && line_indent == 8) {
content = substr(raw, 9)
record_step_field(content, NR)
next
}
if (has_gradle_reference(raw)) {
unsupported_gradle = 1
}
}
END {
validate_job()
print gradle_jobs
if (invalid) {
exit 1
}
}
' "${workflow}"
); then
fail "workflow validation failed: ${workflow#"${REPOSITORY_ROOT}"/}"
fi
[[ "${jobs_in_workflow}" =~ ^[0-9]+$ ]] \
|| fail "workflow parser returned an invalid Gradle job count: ${workflow#"${REPOSITORY_ROOT}"/}"
((jobs_in_workflow > 0)) \
|| fail "Gradle-running workflow contains no detected Gradle job: ${workflow#"${REPOSITORY_ROOT}"/}"
((gradle_job_count += jobs_in_workflow))
done < <(find "${WORKFLOWS_DIRECTORY}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0)
((workflow_count > 0)) || fail 'no Gradle-running workflow was found'
((gradle_job_count > 0)) || fail 'no individual Gradle-running job was found'
((workflow_lock_valid != 0)) \
|| fail 'workflow lock mismatch: workflow set or bytes differ from the reviewed embedded manifest'
printf 'gradle-wrapper-contract: PASS\n'
+132
View File
@@ -0,0 +1,132 @@
name: httpclient-contract
# Per-PR gate for the HTTP Client Platform (design §29). Each transport runs the same semantic
# contract in its own job, so a transport that stops satisfying it fails on its own row instead of
# disappearing into an aggregate run.
on:
workflow_dispatch:
pull_request:
paths:
- 'src/adapter/outbound/httpclient/**'
- 'src/app-bootstrap/src/**/httpclient/**'
- 'docs/httpclient/**'
- 'scripts/verify-httpclient-docs.py'
- '.github/workflows/httpclient-contract.yml'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
httpclient-unit-and-boundaries:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the focused module suite and the architecture gate
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:test
verifyCleanArchitectureDependencies
--no-daemon
--stacktrace
httpclient-stable-contract:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
transport: [apache, jdk, reactor]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Certify one transport against the shared contract
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientStableContractTest
-Phttpclient.contract.transports=${{ matrix.transport }}
--no-daemon
--stacktrace
httpclient-security-and-compatibility:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Run the SSRF, cardinality, and Spring compatibility lanes
working-directory: src
run: >-
./gradlew
:adapter:outbound:httpclient:httpClientSecurityTest
:adapter:outbound:httpclient:httpClientBlockHoundTest
:adapter:outbound:httpclient:spring62ApiSurfaceScan
:adapter:outbound:httpclient:spring70CompatibilityTest
--no-daemon
--stacktrace
httpclient-composition:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Verify composition and architecture in the bootstrap module
working-directory: src
run: >-
./gradlew
:app-bootstrap:test --tests '*httpclient*' --tests '*CleanArchitectureTest'
--no-daemon
--stacktrace
+70
View File
@@ -0,0 +1,70 @@
name: httpclient-release
# Release gate for the HTTP Client Platform (design §38 step 4). Each declared gate runs as its own
# single-line `./gradlew <task>` step, because .github/scripts/verify-gate-matrix.sh reads these
# commands to prove the gate is actually executed — a folded or flag-laden command would make the
# declaration in .github/ci-gate-matrix.yml unverifiable.
on:
workflow_dispatch:
push:
tags:
- 'v*'
permissions:
contents: read
jobs:
release-gate:
runs-on: ubuntu-latest
timeout-minutes: 60
defaults:
run:
working-directory: src
env:
# A project property rather than a command-line flag, so each run command stays a plain,
# verifiable task invocation while the machine-dependent bounds are still asserted.
GRADLE_OPTS: -Dorg.gradle.project.performance.assertions.enabled=true
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- name: Validate Gradle wrapper
id: gradle-wrapper-validation
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
with:
distribution: temurin
java-version: "21.0.11+10"
cache: gradle
cache-dependency-path: |
src/**/*.gradle
src/**/gradle-wrapper.properties
src/**/gradle.lockfile
- name: Focused module tests
run: ./gradlew :adapter:outbound:httpclient:test --no-daemon --stacktrace
- name: Spring 6.2 API surface lane
run: ./gradlew :adapter:outbound:httpclient:spring62ApiSurfaceScan --no-daemon --stacktrace
- name: Spring 7.0 compatibility lane
run: ./gradlew :adapter:outbound:httpclient:spring70CompatibilityTest --no-daemon --stacktrace
- name: Stable cross-transport contract suite
run: ./gradlew :adapter:outbound:httpclient:httpClientStableContractTest --no-daemon --stacktrace
- name: SSRF and cardinality suite
run: ./gradlew :adapter:outbound:httpclient:httpClientSecurityTest --no-daemon --stacktrace
- name: Event-loop blocking suite
run: ./gradlew :adapter:outbound:httpclient:httpClientBlockHoundTest --no-daemon --stacktrace
- name: Toxiproxy fault-injection suite
run: ./gradlew :adapter:outbound:httpclient:httpClientFailureInjectionTest --no-daemon --stacktrace
- name: Resource-bound performance certification
run: ./gradlew :adapter:outbound:httpclient:httpClientPerformanceTest --no-daemon --stacktrace
- name: Architecture dependency gate
run: ./gradlew verifyCleanArchitectureDependencies --no-daemon --stacktrace
httpclient-documentation:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5.6.0
with:
python-version: '3.12'
- name: Verify documentation matches the code
run: python3 scripts/verify-httpclient-docs.py
+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.
+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/test/java/**/testkit` | The design forbids production modules depending on the testkit; a test source set gives the same guarantee without a new Gradle project. |
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` (test 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.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,366 @@
# Redis Optionality and Composition Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use
> checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make Redis genuinely optional at both ends — `APP_REDIS_ENABLED=false` loads, binds,
validates and allocates nothing Redis-shaped, and `APP_REDIS_ENABLED=true` assembles a validated,
fail-fast Redis runtime — and close the SDK correctness defects that must not be wired live.
**Architecture:** A single conditional composition root (`RedisSdkAutoConfiguration`) owns
`RedisSdkSettings`, its validation, its secret/credential resolution, and its resource loading.
Nothing Redis-shaped is registered by the global `@ConfigurationPropertiesScan`. Secret requirements
move from the unconditional bootstrap list into that conditional owner. The SDK stays an
implementation detail of the `adapter:outbound:cache-redis` leaf; provider-neutral semantic ports
are re-implemented on top of it in a later phase.
**Tech Stack:** Java 21, Spring Boot 4.0.0, Lettuce, Gradle (fail-closed 19-leaf registry), JUnit 5,
AssertJ, ArchUnit.
## Status — 2026-08-10
| Review item | State | Where |
| --- | --- | --- |
| P1 #1 optionality (settings/validation half) | done | `RedisSdkAutoConfiguration`, `RedisSdkSettings`, `RedisOptionalityContractTest` |
| P1 #1 optionality (client/runtime half) | done | Phase D: `RedisTopologyClientFactory`, `RedisRuntimeOwner`, `RedisStartupProbe`, health contributors |
| P1 #2 production Redis secrets | done | `SecretSourceValidator`, `RedisActivationValidator` |
| P1 #3 env SSOT for the 34 settings | done | `env-keys.yaml`, `verifyEnvKeys` check E |
| P1 #4 semantic adapters | 4 of 5 | rate-limit, lease, idempotency V2, cache done. **Session is blocked, not deferred**: no provider-neutral session contract exists in `application-core` or `shared-contract` — it was deleted with the previous generation and the bootstrap references it only by bean name. Restoring it is a contract design task, not a port implementation, and the review does not specify that contract. |
| P1 #5 counter TTL | done | `AtomicCounterScripts` |
| P1 #6 transaction slot (aggregate check) | done | `LettuceRedisTransactionOperations.AttemptSlot` |
| P1 #6 transaction exclusive connection lease | done | typed `RedisLease` with `invalidate()`; the TRANSACTION lane is bounded and a poisoned connection is never pooled |
| P1 #7 telemetry isolation | done | `NoThrowObservationSink`, all three executors |
| P1 #8 topology lane fail-closed | done | `cache-redis/build.gradle` |
| P1 #9 README three-state split | done | `cache-redis/README.md` |
| TLS lane | done | `infra/redis-sdk/tls/compose.yml`, plaintext port off, certificates generated at start-up |
| P1 #9 PR/nightly/RC release gates | done | `redis-sdk-topology.yml` PR/schedule/RC matrix + evidence artifacts; gate promoted from `delegated-pending` |
| P1 #10 Netty floor | done | `ext['netty.version'] = '4.2.17.Final'`, all lockfiles |
| Phase B3 orphan configuration removal | done | 4 blocks removed from `application.yml`, 33 `.env` keys dropped, registry rows deprecated |
### P2/P3 hardening
| Item | State | Where |
| --- | --- | --- |
| Multi-key permit dead branch | done | `CommandPolicyGuard.requirePermits`; set algebra and blocking list now present a multi-key permit |
| Codec type safety | done | `RedisCodecRegistry` records the declared type and refuses a mismatched lookup |
| Error metadata on decode failure | done | `RedisFailureMetadata.storedDataCorruption`, deployment mode threaded from the caller |
| Pub/Sub codec per target | done | per-channel codec map; pattern subscriptions must agree on one codec |
| Pub/Sub backpressure | done | `SubscriptionFlux` bounded buffer + explicit overflow policy, decode failure terminates |
| Admin `CONFIG GET` | done | fixed allowlisted projection, secret-shaped values redacted, no caller pattern |
| Reply budget | done (consolidated) | dead `CommandPolicyGuard.validateReply` removed; `RedisOperationContext.requireReplyWithinBudget` is the single authority |
| Sentinel durability probe | done | `min-replicas-max-lag` now required alongside the replica count |
| Missing raw allowlist resource | done | `RedisSdkAutoConfiguration` opens it at startup |
| ACL fixture | done | `user default off`, fixture-only header, named-credential instructions |
| Readiness false-green | done | `validate-group-membership: true`, group names only contributors that exist |
| Dependency drift | done | unused `spring-data-redis`/`micrometer-core` removed, Reactor declared directly |
| JSON framing | done | control characters escaped, schema identifier constrained by regex |
| Connection lifecycle state machine | done | `RedisRuntimeOwner` `OPEN→DRAINING→CLOSED` |
| Gateway/`CommandRequest` visibility | **open** | needs `sdk.programmability`, `sdk.raw`, `sdk.admin` and `sdk.extensions` to stop constructing requests directly; a package restructuring, not a rename |
| Raw movable keys (`SORT BY/GET/STORE`) | done | `RawMovableKeys` settles SORT/SORT_RO locally including the STORE destination; BY/GET stay refused because their patterns cannot be namespace-checked, and an unknown option is a rejection rather than a guess |
| Batch observed-aggregate reply bytes | done | `BatchExecution` accumulates measured replies and fails the item that crosses the ceiling |
Residual limitation on P1 #6: keys queued inside the callback are only knowable after `MULTI`, so
the aggregate slot is enforced as each key becomes known — the offending command is refused before
it is written and the window is discarded, rather than the whole attempt being refused before
`WATCH`. Refusing before `WATCH` in every case needs a declared-keys transaction API, which Phase E
would revisit anyway.
## Global Constraints
- Registry SSOT for module identity, Gradle paths and allowed edges is
`src/config/architecture/modules.json`. Never infer a Gradle path.
- Commit policy is `human-only`. Agents do not stage, commit, amend, or push.
- `domain-core` must stay free of framework/transport/database/cloud dependencies.
- `application-core` must never see an SDK type, a Redis key, a topology or a connection type.
- Global Redis activation is exactly one switch: `APP_REDIS_ENABLED`. `APP_CACHE_REDIS_ENABLED`
must not be a second master switch.
- Every new `APP_*` key must land in all four places or `verifyEnvKeys` fails:
`src/app-bootstrap/src/main/resources/application.yml`, `src/.env`,
`docs/registries/env-keys.yaml`, and (when secret-classified)
`docs/registries/secrets-classification.yaml`.
- `SecretsClassificationRegistryTest` asserts `SecretSourceValidator.REQUIRED_PROD_SECRETS` matches
`docs/registries/secrets-classification.yaml` 1:1. Changing one requires changing the other.
- Netty floor: `4.2.16` or higher (CVE-2026-42577 epoll `<4.2.13`, CVE-2026-59901
codec-compression `<4.2.16`).
- Topology lane modes allowlist: exactly `STANDALONE`, `SENTINEL`, `CLUSTER`.
- Verification commands run from `src/`.
## Current-state facts this plan is written against
Established by direct inspection on 2026-08-10, working tree (not HEAD):
- `CaSkeletonApplication` scans `dev.caskeleton.adapter` for `@ConfigurationProperties`, so
`RedisSdkSettings` (`ca-skeleton.capabilities.redis-sdk`) is registered with Redis off.
- `RedisSdkSettings.validate()` has no production caller.
- The `cache-redis` leaf has **no** `@Bean`, `@Configuration`, or `@AutoConfiguration` in main
source: nothing constructs a client, connection, gateway, or health contributor.
- 240 tracked main-source files under `cache-redis` are deleted in the working tree; the SDK
(~300 files under `…cache.redis.sdk`) is untracked. The semantic cache/session/idempotency/
rate-limit/lease adapters are gone.
- `ca-skeleton.providers.redis.*`, `ca-skeleton.capabilities.cache.*`, and
`ca-skeleton.security.redis-session.*` in `application.yml` bind to **no** Java type — orphan
configuration from the previous generation.
- `SecretSourceValidator.REQUIRED_PROD_SECRETS` requires `APP_CACHE_REDIS_PASSWORD` and
`APP_CACHE_REDIS_KEY_HMAC_SECRET` unconditionally in prod; the other Redis roles have
conditional skips.
- `verifyEnvKeys` compares only the three text sets (`.env`, `application.yml` placeholders,
`env-keys.yaml`); it never reads `spring-configuration-metadata.json`, so a typed property with
no env name passes.
- `redisTopologyTest` builds its tag as `lane-${declaredMode}` from an unvalidated project
property, with no mode allowlist and no positive test-count postcondition — an unknown mode
selects zero tests and exits 0.
- `src/app-bootstrap/gradle.lockfile` pins `io.netty:*:4.2.7.Final` on
`productionRuntimeClasspath`, and still carries a `redisCompositionTestRuntimeClasspath`
configuration whose source set no longer exists.
---
## Phase A — Redis optionality (P1 #1, #2) and the dead second switch
### Task A1: Remove the unconditional production Redis secret requirement
**Files:**
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidator.java`
- Modify: `docs/registries/secrets-classification.yaml`
- Test: `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/runtime/SecretSourceValidatorTest.java`
**Interfaces:**
- Produces: `SecretSourceValidator.REQUIRED_PROD_SECRETS` without any `APP_CACHE_REDIS_*` entry;
`isCacheRedisMaterial(String)` + `isRedisGloballyEnabled()` private helpers gating every
remaining Redis-prefixed secret on `app.redis.enabled`.
- [ ] **Step 1: Write the failing test** — prod profile, Redis off, no Redis secrets present,
validator must not throw.
- [ ] **Step 2: Run it and watch it fail** on the two cache secrets.
- [ ] **Step 3: Gate every Redis secret on `app.redis.enabled` plus its role selector.**
- [ ] **Step 4: Re-run the focused test class.**
- [ ] **Step 5: Update `secrets-classification.yaml` `required_in_prod` metadata to match.**
### Task A2: Stop the global scan from registering `RedisSdkSettings`
**Files:**
- Modify: `src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java`
(exclude the SDK config package) **or** move `RedisSdkSettings` out of a scanned package —
preferred: keep the class where it is and drop `@ConfigurationProperties` from it, binding it
instead from the conditional configuration with `@ConfigurationProperties` on the `@Bean` method.
- Test: new bootstrap contract test asserting zero `RedisSdkSettings` beans when
`app.redis.enabled` is absent or false.
### Task A3: `RedisSdkAutoConfiguration` — the ON/OFF composition root
**Files:**
- Create: `src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfiguration.java`
- Create: `src/adapter/outbound/cache-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`
- Test: `…/sdk/config/RedisSdkAutoConfigurationTest.java` (ApplicationContextRunner)
Conditions: `@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true")`.
Inside: bind settings, call `validate()` and fail the context on `IllegalStateException`, log
warnings, then (Phase D) build the topology client.
### Task A4: Retire `APP_CACHE_REDIS_ENABLED` as a second master switch
**Files:**
- Modify: `src/app-bootstrap/src/main/resources/application.yml` (add `app.redis.enabled`)
- Modify: `src/.env`, `docs/registries/env-keys.yaml`
---
## Phase B — env SSOT migration (P1 #3)
### Task B1: Register `APP_REDIS_ENABLED` and the 34 SDK settings
Names are fixed by the review's env contract table. Each `env-keys.yaml` row carries
`property`, `owner_module`, `type`, `default`, `secret`, `required_when`, and (where one exists)
`deprecated_alias` + `removal_deadline`.
### Task B2: Extend `verifyEnvKeys` to read `spring-configuration-metadata.json`
Bidirectional: a typed `app.redis.*` property with no registry row fails; a registry row whose
`property` matches no metadata entry fails.
### Task B3: Remove the orphan generations
Delete `ca-skeleton.providers.redis.*`, `ca-skeleton.capabilities.cache.*`, and
`ca-skeleton.security.redis-session.*` from `application.yml` once a migration table records the
old→new mapping; drop the now-orphaned `.env` keys; mark the registry rows deprecated rather than
deleting their metadata.
---
## Phase C — SDK correctness (P1 #5, #6, #7)
### Task C1: Atomic counter must not add a TTL to a pre-existing persistent key
**Files:**
- Modify: `…/sdk/lettuce/operations/AtomicCounterScripts.java`
- Test: `…/sdk/lettuce/operations/AtomicCounterScriptsTest.java`
Both scripts must record existence **before** the increment and apply the initial expiry only when
the key was absent:
```lua
local existed = redis.call('EXISTS', KEYS[1])
local value = redis.call('INCRBY', KEYS[1], ARGV[1])
if existed == 0 then
if ARGV[3] == 'AT' then
redis.call('PEXPIREAT', KEYS[1], ARGV[2])
else
redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
end
return value
```
### Task C2: Validate the transaction's whole key set against one slot
**Files:**
- Modify: `…/sdk/programmability/LettuceRedisTransactionOperations.java`
- Test: `…/sdk/programmability/LettuceRedisTransactionOperationsTest.java`
Collect watched + queued keys per attempt and validate the aggregate slot before `MULTI`, instead
of validating the WATCH bundle and each queued write independently.
### Task C3: A throwing observation sink must not fail a successful command
**Files:**
- Create: `…/sdk/lettuce/observability/NoThrowObservationSink.java`
- Modify: `…/sdk/lettuce/command/SyncRedisCommandExecutor.java`
- Modify: `…/sdk/lettuce/command/ReactiveRedisCommandExecutor.java`
- Test: `…/sdk/lettuce/command/ObservationIsolationTest.java`
---
## Phase D — Runtime composition (P1 #4 prerequisite, deferred)
Topology strategy (standalone/sentinel/cluster), authentication/TLS, shared vs dedicated
connection lanes, lifecycle owner, capability/durability probe, health contributors.
## Phase E — Semantic adapter restoration (P1 #4, deferred)
Re-implement the provider-neutral ports on top of the SDK: cache, session, idempotency V2,
rate-limit, efficiency-only lease. This is the restoration of the 240 deleted files' behaviour and
is the largest single body of work in this plan.
## Phase F — Release gates, evidence and dependencies (P1 #8, #9, #10)
### Task F1: `redisTopologyTest` fails closed
Mode allowlist, `failOnNoDiscoveredTests = true`, per-lane required tag/class presence, and a
`>= 1` executed-test postcondition.
### Task F2: Netty floor `4.2.16`
Add a platform constraint, regenerate every lockfile, rerun the dependency scan.
### Task F3: README status split
`API implemented` / `Spring composition implemented` / `production-qualified` as three separate
states.
## Phase G — P2/P3 hardening (deferred)
Gateway/request visibility, multi-key permit dead branch, connection lifecycle state machine,
reply budgets, admin `CONFIG GET` projection, pub/sub codec mapping and backpressure, codec type
safety, error metadata, raw movable keys, Sentinel durability probe, ACL fixture, readiness
false-green, missing raw resource, dependency drift, JSON framing.
---
## Round 2 — the defects a real server found that this plan did not
Everything above was written before any of it had run against Redis. A second review started four
Docker lanes, wired the production code to them, and found that several items marked done were
done in the sense that the code existed, not in the sense that it worked. What follows is what that
round changed, and what it changed because of.
### The readiness group could not start at all
`management.endpoint.health.group.readiness.include` named `redisRequired`, a contributor that only
exists when a correctness role selected Redis. Boot validates group membership and does **not**
tolerate a conditional member being absent, so every Redis-off and cache-only deployment failed at
startup with `Included health contributor 'redisRequired' in group 'readiness' does not exist`. The
comment in `application.yml` asserted the opposite.
The group now names only unconditional contributors, and
`RedisReadinessGroupPostProcessor` appends `redisRequired` from `RedisCorrectnessRoles` — the same
predicate the bean's `@Conditional` asks, so membership and existence cannot drift.
`RedisReadinessGroupPostProcessorTest` boots a real Actuator context in each of the three shapes;
putting the name back in the shipped file makes two of them fail exactly as production did.
### Redis on composed no capability
`APP_REDIS_ENABLED=true` produced a client, an owner and a health contributor. Every semantic port
count was zero, so a deployment that selected `redis` for its rate limiter started, reported
healthy, and had no rate limiter. `RedisCapabilityConfig` composes cache, rate limit, lease and the
owner-safe idempotency store, each on its own selector.
The idempotency guard was also counting `application.idempotency.IdempotencyStorePortV2`, which no
provider implements — the implemented contract is the one in `…idempotency.v2`. Selecting `redis`
therefore required a bean nothing could supply. Driving the V2 store from an executor remains
outstanding and is named as such rather than covered by a guard that cannot see it.
### Four key prefixes, and an ACL that matched none of them
Each capability joined its own `namespace-application` / `namespace-environment` pair in its own
order, so the cache wrote `ca-skeleton:prod:…` while the ACL granted `~prod:*`. `CapabilityKeyspace`
renders every capability below one `RedisNamespace`, and the per-capability namespace keys are
deprecated.
The scripted capabilities also ran `EVALSHA` on the application account, which does not have it.
Lanes now carry a `RedisCredentialRole`; the topology factory builds one client per configured
role, so the `SCRIPT` lane authenticates as the advanced account and the account that reads a cache
entry still cannot execute a script. `LiveRedisSemanticPortsTest` proves both directions against a
real server.
### Cluster transactions were impossible, and multi-key WATCH was refused
`beginTransaction()` on a live cluster failed by design: every lane opened the slot-routing
connection, which cannot own a window. `RedisTransactionRunner` derives a routing key and pins the
lane to the node that owns the slot. Fixing that surfaced a second defect a cluster was not needed
for — `watch()` presented no multi-key permit, so watching more than one key was rejected
unconditionally, which is most optimistic transactions.
### The fixtures could not fail
Every ACL account was `nopass`, which accepts any password: every assertion about authentication
passed for the same reason a typo would have. The accounts carry real passwords and a wrong one is
now asserted to produce `WRONGPASS`. The cluster lane's readiness helper checked
`CLUSTER INFO` unauthenticated, so it never matched, never exited, and `up --wait` returned while
slots were still being assigned; a `ready` gate now blocks on `cluster_state:ok`.
### TLS was reachable only by hand
`tls` is a lane of `redisTopologyTest` and of the CI matrix. Trust material resolved with
`new File(...)` broke `classpath:` references, and resolving it purely through the resource loader
breaks mounted paths — both shapes are ordinary, and both are supported.
### Gates that could report success for a lane they did not run
`afterTest` fires for skipped tests too, so the "ran something" check could be satisfied by a run
that skipped everything. Lanes now declare the classes they exist to run and a floor for the
executed count, and a skipped test fails the run. `verifyEnvKeys` gained a check for registered
keys that nothing reads — no typed property, no yaml reference, no `.env` entry, no Java consumer —
which found eight orphaned Redis keys beyond the two the review named.
### Verified
| Lane | Result |
| --- | --- |
| standalone | 25 tests |
| sentinel | 27 tests |
| cluster | 29 tests, including a same-slot transaction and a cross-slot refusal |
| tls | 4 tests, filesystem and classpath CA |
Repository: 3594 tests, 0 failures. `verifyCleanArchitectureDependencies`,
`verifyPublicPathSnapshot`, `verifyEnvKeys`, `CleanArchitectureTest`, `verify-gate-matrix.sh`
(37 gates) and `verify-gradle-wrapper.sh` all pass.
### Still open
- **Session port.** No provider-neutral session contract exists in `application-core` or
`shared-contract`; it went with the previous generation. That is a contract to design, not a port
to implement, and inventing one here would be guessing at its shape.
- **V2 idempotency executor.** `IdempotencyExecutorV2` targets a contract no provider implements.
- **Gateway / `CommandRequest` visibility.** Narrowing it is a package restructuring across
`sdk.programmability`, `sdk.raw`, `sdk.admin` and `sdk.extensions`, not an access-modifier change.
+148
View File
@@ -0,0 +1,148 @@
# Redis SDK topology lanes
These lanes exist to answer the questions the deterministic in-memory gateway cannot: how Lettuce
actually behaves during a Sentinel promotion, what a Cluster resharding does to an in-flight
command, and whether the ACL accounts grant exactly what the SDK issues.
All four have now run on Redis 7.4 and the evidence is recorded in
`docs/redis/support-matrix.md`. `.github/workflows/redis-sdk-topology.yml` runs the standalone lane
on any pull request that touches the Redis leaf, the full supported-version x topology matrix
nightly, and the same matrix on demand for a release candidate.
TLS is a lane of that matrix rather than something to wire up by hand. It is `tls`, not a
deployment mode: its shape is standalone and what it qualifies is the transport, so
`redisTopologyTest` maps the lane name to `standalone` for the tests and keeps the tag filter and
the required trust material on the lane.
## The TLS lane
`tls/compose.yml` is the standalone shape with the transport swapped. The plaintext port is turned
off entirely (`--port 0`), which is the only configuration that proves anything: a lane accepting
both would let a client that failed to negotiate TLS fall back silently and still pass.
Certificates are generated at start-up into a named volume rather than checked in — a private key
in the repository is a private key in the repository, however the file is named — and they last a
day, so a stale lane fails visibly instead of drifting.
```bash
REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/tls/compose.yml up -d --wait
# The client needs the generated CA; copy it out of the volume first.
docker compose -f infra/redis-sdk/tls/compose.yml cp redis:/tls/ca.crt /tmp/redis-lane-ca.pem
cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \
-Predis.topology.host=127.0.0.1 -Predis.topology.port=6390 \
-Predis.topology.mode=tls -Predis.topology.trust-material=/tmp/redis-lane-ca.pem
```
The lane refuses to run without `redis.topology.trust-material`. A TLS lane that trusts anything
qualifies nothing, so "no CA configured" is an error rather than a client with verification off.
## The ACL fixture
`acl/all-accounts.acl` provisions the accounts every lane uses. Two things about it matter, and
neither can be written in the file itself — **Redis refuses to start if an `aclfile` contains a
comment line**, so the whole file is directives and the explanation lives here.
`user default off` is the first line and is deliberate. Redis ships `default` enabled and
passwordless; while it is on, every restriction in the remaining accounts can be bypassed by simply
not authenticating, which makes the fixture decorative. Disabling it is what forces a client — and
the compose healthchecks — to pick a named account.
Every named account carries a real password — `>fixture-application`, `>fixture-advanced`, and so
on. They were `nopass`, which was the more dangerous kind of wrong: an account that accepts any
password made every assertion about authentication pass for the same reason a typo would have, so
the lane's coverage of AUTH, rotation and secret wiring was indistinguishable from no coverage.
`LiveRedisCompositionTest` now presents a wrong password on purpose and requires `WRONGPASS`, which
is only a meaningful assertion because the accounts enforce one.
The passwords are fixture values in a throwaway container and are **not** a deployment template: a
real deployment resolves each account's credential through `secret://` and never writes one into
configuration.
The accounts are also split by role, because that is how the SDK uses them. `ca-skeleton-application`
runs ordinary data commands and cannot execute a script; `ca-skeleton-application-advanced` holds
`SCRIPT LOAD` and `EVALSHA` and nothing else needs to. That separation is real rather than
decorative: `LiveRedisSemanticPortsTest` runs the rate limiter without the advanced account and
requires it to come back `Unavailable`.
## Running one
Each lane has its own endpoint, because the address a client is given is not the same kind of thing
in each topology. Standalone declares a data node; Sentinel declares a *sentinel*, from which the
primary is resolved and re-resolved when it is promoted; Cluster declares any node, from which the
rest of the topology is discovered.
```bash
# Standalone
REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/standalone/compose.yml up -d --wait
cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \
-Predis.topology.host=localhost -Predis.topology.port=6379 -Predis.topology.mode=standalone
# Sentinel — the port is a sentinel, and the monitored primary has to be named
REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/sentinel/compose.yml up -d --wait
cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \
-Predis.topology.host=localhost -Predis.topology.port=27010 \
-Predis.topology.mode=sentinel -Predis.topology.master=skeleton
# Cluster — `up --wait` waits for the `ready` gate, not just for six servers that answer PING.
# Slot assignment finishes after the nodes are healthy, and a client that connects in between sees
# CLUSTERDOWN for reasons that have nothing to do with the SDK.
REDIS_VERSION=7.4 docker compose -f infra/redis-sdk/cluster/compose.yml up -d --wait
cd src && ./gradlew :adapter:outbound:cache-redis:redisTopologyTest \
-Predis.topology.host=localhost -Predis.topology.port=7100 -Predis.topology.mode=cluster
```
Tear a lane down with `docker compose -f infra/redis-sdk/<lane>/compose.yml down -v`.
| Lane | Ports | Notes |
| --- | --- | --- |
| standalone | 6379 | bridge network, published port |
| sentinel | primary 7010, replica 7011, sentinels 2701027012 | host network |
| cluster | nodes 71007105, bus 1710017105 | host network; `ready` gates on `cluster_state:ok` |
| tls | 6390 | published port, no plaintext port at all; CA generated per run |
## Why the Sentinel and Cluster lanes use host networking
Neither topology proxies. Sentinel answers `SENTINEL get-master-addr-by-name` with the address it
monitors and the client dials that itself; a cluster client reads `CLUSTER SHARDS` and connects to
every node it names. On a bridge network those are container-internal addresses, so a client on the
host resolves a topology it cannot reach — and after a promotion it resolves a *different* one it
also cannot reach. Sharing the host network namespace makes the address the topology advertises the
address the client can use, which is the difference between testing the SDK and testing Docker's
network.
That is also why their ports are fixed rather than parameterised: the addresses are written into
Sentinel's and the cluster's own configuration at creation time, and a lane whose two halves can
disagree fails for reasons that are not the SDK's.
## Selection is by lane, not by hand
`redisTopologyTest` derives its JUnit tag expression from the declared mode: `redis-topology &
lane-<mode>`. A promotion test is meaningless without sentinels and a cross-slot test is meaningless
without a cluster, but expressing that as a runtime assumption would turn "the lane was never
started" into a green skip. Selecting by tag keeps it fail-closed — what a mode cannot prove is not
selected, and what is selected must pass.
The lane also fails closed on its endpoint: selecting `redisTopologyTest` without host, port, and
mode (and `redis.topology.master` on the Sentinel lane) is an error, never a skip. A topology test
that silently passes because it did not connect is worse than no topology test.
## `min-replicas-to-write` on the Sentinel lane
The Sentinel lane sets `min-replicas-to-write 1` and `min-replicas-max-lag 1`, and this is not
incidental configuration. Without them the lane measured a promotion in which the superseded primary
kept answering `+OK` for eleven seconds after it had been replaced: **2,086 writes acknowledged to
the caller and then discarded**, with exactly one command failing. With them the same promotion lost
one write and refused 2,020 with `NOREPLICAS`, which the SDK reports as a definite, non-ambiguous
failure a caller can act on.
Any deployment where an acknowledgement is supposed to mean something has to set these. See
`docs/redis/support-matrix.md` for the full record.
## ACL accounts
`acl/` holds one file per `CommandAccess` level. They are deliberately narrower than the SDK's own
rules, so a mistake in the SDK is still refused by the server — the account is the last boundary and
a permit never widens it.
Every lane loads the same file on every data node. Accounts are enforced per node, so "they exist on
one node" is not evidence that a topology enforces them.
+8
View File
@@ -0,0 +1,8 @@
user default off
user ca-skeleton-application on >fixture-application sanitize-payload ~prod:* resetchannels &prod:* -@all +@connection +@pubsub +@transaction +@read +@write +@string +@hash +@list +@set +@sortedset +@bitmap +@hyperloglog +@geo +@stream -keys -flushdb -flushall -shutdown -debug -sort -sort_ro -smembers -randomkey -migrate -swapdb -select +cluster|slots +cluster|shards +cluster|nodes +cluster|info +cluster|myid
user ca-skeleton-application-advanced on >fixture-advanced sanitize-payload ~prod:* resetchannels &prod:* -@all +@read +@write +@string +@hash +@list +@set +@sortedset +@bitmap +@hyperloglog +@geo +@stream +@pubsub +@transaction +evalsha +evalsha_ro +script|load +script|exists +fcall +fcall_ro -keys -flushdb -flushall -shutdown -debug -eval -eval_ro -smembers -sort -sort_ro -randomkey -migrate -swapdb -select +cluster|slots +cluster|shards +cluster|nodes +cluster|info +cluster|myid
user ca-skeleton-raw-gateway on >fixture-raw sanitize-payload ~prod:* resetchannels -@all +smembers +sort +sort_ro
user ca-skeleton-admin-readonly on >fixture-admin ~* resetchannels -@all +info +dbsize +time +lastsave +memory|usage +memory|stats +slowlog|get +slowlog|len +latency|latest +latency|history +client|list +client|info +command|info +command|docs +command|count +command|getkeysandflags +config|get +acl|dryrun +acl|whoami +cluster|info +cluster|slots +cluster|shards +cluster|nodes +object|encoding +object|freq +object|idletime +pubsub|channels +pubsub|numsub +pubsub|shardchannels +xinfo|stream +xinfo|groups +xinfo|consumers +function|list +function|stats +cluster|keyslot +cluster|myid
user ca-skeleton-replication on >fixture-replication ~* resetchannels -@all +psync +replconf +ping
user ca-skeleton-sentinel on >fixture-sentinel ~* &* -@all +multi +slaveof +ping +exec +subscribe +config|rewrite +role +publish +info +client|setname +client|kill +script|kill +replconf +psync
user ca-skeleton-cluster-bootstrap on >fixture-bootstrap ~* &* +@all
+134
View File
@@ -0,0 +1,134 @@
# 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
+126
View File
@@ -0,0 +1,126 @@
# Sentinel lane. Three sentinels because a two-sentinel quorum cannot survive losing one, and a
# failover test that cannot lose a sentinel is not testing failover.
#
# Host networking, not a bridge with published ports. Sentinel does not proxy: it answers
# `SENTINEL get-master-addr-by-name` with the address it monitors, and the client then connects
# there itself. On a bridge that address is the container's internal IP, which the client on the
# host cannot reach, so the lane would resolve a primary it can never talk to — and after a
# promotion it would resolve a different unreachable one. Sharing the host network namespace makes
# the address Sentinel hands out the same address the client can dial, which is the only thing that
# makes the promotion observable from outside.
#
# Ports are fixed rather than parameterised because Sentinel stores them in its own config: the
# monitored address has to match what the client is told, and a lane whose two halves can disagree
# is a lane that fails for reasons that are not the SDK's.
#
# primary 7010 · replica 7011 · sentinels 27010 27011 27012
#
# The ACL file is loaded on both data nodes. The accounts are the deployment's last enforcement
# boundary, so "they exist in standalone" is not evidence that they exist in the topology that will
# actually be run in production.
#
# Both data nodes take their entire configuration from one definition, and that is load-bearing
# rather than tidiness. These two nodes swap roles on every failover, so a setting written only into
# the one that happens to start as primary silently stops applying the moment the lane does the
# thing it exists to do. The lane learned this the hard way: min-replicas-to-write was set on the
# primary only, the first promotion passed, and the second promotion — now writing to the node that
# never had the setting — discarded 2,099 acknowledged writes.
x-data-node: &data-node
image: "redis:${REDIS_VERSION:-7.4}"
network_mode: host
volumes:
- ../acl:/etc/redis/acl:ro
entrypoint:
- /bin/sh
- -c
# REPLICA_OF is deliberately unquoted: it is either empty or a two-word --replicaof argument.
#
# min-replicas-to-write is what stops a superseded primary from acknowledging writes it cannot
# keep. Without it a promotion silently destroys them — measured here at eleven seconds and two
# thousand confirmed-then-discarded writes — because Sentinel does not demote the old primary
# until well after it has promoted the new one. Requiring an in-sync replica turns that window
# into an explicit NOREPLICAS refusal the caller can see and act on. Any deployment where an
# acknowledgement is supposed to mean something has to set these.
- |
exec redis-server \
--port $$NODE_PORT \
$$REPLICA_OF \
--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:
primary:
<<: *data-node
environment:
NODE_PORT: "7010"
REPLICA_OF: ""
replica:
<<: *data-node
environment:
NODE_PORT: "7011"
REPLICA_OF: "--replicaof 127.0.0.1 7010"
depends_on:
primary:
condition: service_healthy
sentinel-1: &sentinel
image: "redis:${REDIS_VERSION:-7.4}"
network_mode: host
# The config is written at start-up rather than mounted because Sentinel rewrites its own file
# when it promotes. A read-only mount would make the first failover fail on a write error, and
# a shared writable mount would have three sentinels rewriting one file.
entrypoint:
- /bin/sh
- -c
- |
cat > /tmp/sentinel.conf <<CONF
port $$SENTINEL_PORT
sentinel monitor skeleton 127.0.0.1 7010 2
sentinel auth-user skeleton ca-skeleton-sentinel
sentinel auth-pass skeleton fixture-sentinel
sentinel down-after-milliseconds skeleton 2000
sentinel failover-timeout skeleton 10000
sentinel parallel-syncs skeleton 1
CONF
exec redis-sentinel /tmp/sentinel.conf
environment:
SENTINEL_PORT: "27010"
healthcheck:
test: ["CMD-SHELL", "[ \"$$(redis-cli -p 27010 ping)\" = PONG ]"]
interval: 2s
timeout: 2s
retries: 15
depends_on:
primary:
condition: service_healthy
sentinel-2:
<<: *sentinel
environment:
SENTINEL_PORT: "27011"
healthcheck:
test: ["CMD-SHELL", "[ \"$$(redis-cli -p 27011 ping)\" = PONG ]"]
interval: 2s
timeout: 2s
retries: 15
sentinel-3:
<<: *sentinel
environment:
SENTINEL_PORT: "27012"
healthcheck:
test: ["CMD-SHELL", "[ \"$$(redis-cli -p 27012 ping)\" = PONG ]"]
interval: 2s
timeout: 2s
retries: 15
+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())
+84 -39
View File
@@ -15,16 +15,13 @@ APP_MIGRATION_ON_STARTUP=true
APP_RATE_LIMIT_ENABLED=false
APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only
APP_RATE_LIMIT_PROVIDER=disabled
APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET=
APP_IDEMPOTENCY_TTL=24h
APP_IDEMPOTENCY_PROVIDER=jdbc
APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET=
APP_IDEMPOTENCY_REDIS_NAMESPACE_ENVIRONMENT=local
APP_IDEMPOTENCY_PROCESSING_LEASE=30s
APP_IDEMPOTENCY_FAILURE_RETENTION=24h
APP_LEASE_PROVIDER=disabled
APP_LEASE_REDIS_KEY_HMAC_SECRET=
APP_LEASE_REDIS_NAMESPACE_ENVIRONMENT=local
APP_LEASE_REDIS_DRIFT_BUDGET=10ms
# ----- Async executor -----
@@ -34,33 +31,10 @@ APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200
# ----- Optional integration adapters (default: all disabled) -----
APP_CACHE_CANONICAL_DEFAULT_PROVIDER=disabled
# Sentinel primary revalidation cadence for canonically active Sentinel roles.
APP_REDIS_SENTINEL_DISCOVERY_REFRESH_PERIOD=30s
# Canonical role semantic readiness: refresh no more often than this interval.
APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL=5s
# Fail closed when the last completed semantic observation is older than this bound.
APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS=15s
APP_CACHE_REDIS_ENABLED=false
APP_CACHE_REDIS_CLIENT_MODE=managed
APP_CACHE_REDIS_HOST=localhost
APP_CACHE_REDIS_PORT=6379
APP_CACHE_REDIS_PASSWORD=
APP_CACHE_REDIS_KEY_HMAC_SECRET=
APP_CACHE_REDIS_COMMAND_TIMEOUT=2s
APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS=8
APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES=16777216
APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT=local
APP_CACHE_REDIS_SEMANTIC_REGION=default
APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES=1048576
APP_CACHE_REDIS_L1_ENABLED=false
APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES=10000
APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES=67108864
APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES=1048576
APP_CACHE_REDIS_L1_TTL=30s
APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL=5s
APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY=1024
APP_CACHE_DEFAULT_TTL=300s
APP_CACHE_NEGATIVE_TTL=60s
# The single global Redis switch. False means no Redis settings, secrets, client, threads or
# health contributor exist. Role selectors (cache/session/idempotency/lease/rate-limit) choose
# which capabilities compose once Redis is on; none of them turns Redis on.
APP_REDIS_ENABLED=false
APP_MESSAGING_BROKER=
APP_MESSAGING_KAFKA_BROKERS=
APP_NOTIFICATION_SLACK_PROVIDER=
@@ -149,15 +123,6 @@ APP_SESSION_COOKIE_SAME_SITE=Lax
APP_SESSION_COOKIE_PATH=/
APP_SESSION_CSRF_COOKIE_NAME=XSRF-TOKEN
APP_SESSION_CSRF_HEADER_NAME=X-XSRF-TOKEN
APP_SESSION_REDIS_KEY_HMAC_SECRET=
APP_SESSION_REDIS_NAMESPACE_ENVIRONMENT=local
APP_SESSION_IDLE_TIMEOUT=30m
APP_SESSION_ABSOLUTE_LIFETIME=8h
APP_SESSION_TOUCH_INTERVAL=1m
APP_SESSION_TOMBSTONE_TTL=5m
APP_SESSION_MAXIMUM_ENVELOPE_BYTES=32768
APP_SESSION_MAXIMUM_ATTRIBUTES=64
APP_SESSION_MAXIMUM_SCALAR_BYTES=8192
# ----- CORS -----
APP_SECURITY_CORS_ENABLED=true
@@ -186,3 +151,83 @@ APP_DATASOURCE_POOL_MAX_LIFETIME=1800000
# ----- Management / Actuator -----
MANAGEMENT_SERVER_PORT=9001
# ----- Fileserver HTTP platform (app.fileserver-platform.*) -----
# Off by default. While false nothing below is bound: the platform auto-configuration binds this
# block itself and is not processed until the master switch is true.
APP_FILESERVER_PLATFORM_ENABLED=false
APP_FILESERVER_PLATFORM_INSTANCE_ID=local-node
APP_FILESERVER_PLATFORM_DEFAULT_NAMESPACE=default
# Storage root must be an absolute path on its own volume, never under a web or config root.
APP_FILESERVER_PLATFORM_STORAGE_ROOT=/var/lib/backend/files
APP_FILESERVER_PLATFORM_STORAGE_PUBLISH_MODE=atomic-move-preferred
APP_FILESERVER_PLATFORM_STORAGE_BUFFER_SIZE=128KB
APP_FILESERVER_PLATFORM_STORAGE_FORBIDDEN_ROOT_ANCESTORS=/app,/etc,/usr/share/nginx/html
# Shared with spring.servlet.multipart.* so the container and the policy cannot disagree.
APP_FILESERVER_PLATFORM_UPLOAD_MAX_FILE_SIZE=100MB
APP_FILESERVER_PLATFORM_UPLOAD_MAX_REQUEST_SIZE=110MB
APP_FILESERVER_PLATFORM_UPLOAD_INITIAL_RESERVATION=8MB
APP_FILESERVER_PLATFORM_UPLOAD_MAX_PARTS=16
APP_FILESERVER_PLATFORM_UPLOAD_TTL=1h
APP_FILESERVER_PLATFORM_UPLOAD_RESERVATION_TTL=24h
APP_FILESERVER_PLATFORM_UPLOAD_LEASE_DURATION=30s
APP_FILESERVER_PLATFORM_UPLOAD_REQUIRE_CONTENT_LENGTH=false
APP_FILESERVER_PLATFORM_DOWNLOAD_CACHE_CONTROL=private, no-store
APP_FILESERVER_PLATFORM_DOWNLOAD_INLINE_ALLOWED=false
APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGES=1
APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGE_BYTES=100MB
APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_ENABLED=true
APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_MINIMUM_BYTES=16MB
APP_FILESERVER_PLATFORM_TRANSFER_CORE_SIZE=8
APP_FILESERVER_PLATFORM_TRANSFER_MAX_SIZE=32
APP_FILESERVER_PLATFORM_TRANSFER_QUEUE_CAPACITY=64
APP_FILESERVER_PLATFORM_TRANSFER_AWAIT_SECONDS=300
# required | role-based | unenforced (unenforced is refused under a production profile).
APP_FILESERVER_PLATFORM_SECURITY_ACCESS_POLICY=required
APP_FILESERVER_PLATFORM_SECURITY_READ_ROLES=ROLE_FILE_READ
APP_FILESERVER_PLATFORM_SECURITY_WRITE_ROLES=ROLE_FILE_WRITE
APP_FILESERVER_PLATFORM_SECURITY_ADMIN_ROLES=ROLE_FILE_ADMIN
APP_FILESERVER_PLATFORM_VERIFICATION_TIMEOUT=5s
APP_FILESERVER_PLATFORM_VERIFICATION_REQUIRE_MEDIA_TYPE_VERDICT=false
APP_FILESERVER_PLATFORM_VERIFICATION_INLINE_SAFE_PROFILE=false
APP_FILESERVER_PLATFORM_QUOTA_INSTANCE_UPLOAD_PERMITS=16
APP_FILESERVER_PLATFORM_QUOTA_SCOPE_UPLOAD_PERMITS=4
APP_FILESERVER_PLATFORM_QUOTA_DIRECT_DOWNLOAD_PERMITS=64
APP_FILESERVER_PLATFORM_QUOTA_SOFT_HIGH_WATER=0.70
APP_FILESERVER_PLATFORM_QUOTA_HARD_HIGH_WATER=0.85
APP_FILESERVER_PLATFORM_ADMIN_ENABLED=false
APP_FILESERVER_PLATFORM_ADMIN_ORPHAN_MINIMUM_AGE=1h
APP_FILESERVER_PLATFORM_CLEANUP_ENABLED=false
APP_FILESERVER_PLATFORM_CLEANUP_INTERVAL=60s
APP_FILESERVER_PLATFORM_CLEANUP_MAX_ITEMS=100
APP_FILESERVER_PLATFORM_CLEANUP_MAX_BYTES=1GB
APP_FILESERVER_PLATFORM_CLEANUP_RETRY_BACKOFF=5m
APP_FILESERVER_PLATFORM_TUS_ENABLED=false
APP_FILESERVER_PLATFORM_HTTPBIS_DRAFT12_ENABLED=false
APP_FILESERVER_PLATFORM_NGINX_ENABLED=false
APP_FILESERVER_PLATFORM_NGINX_INTERNAL_PREFIX=/__files/
APP_FILESERVER_PLATFORM_NGINX_OBJECT_SUFFIX=.bin
APP_FILESERVER_PLATFORM_NGINX_MINIMUM_SIZE=16MB
APP_FILESERVER_PLATFORM_OBSERVABILITY_METRICS_ENABLED=true
# Secret. Required while metrics are enabled; an unkeyed digest of an enumerable id is reversible.
APP_FILESERVER_PLATFORM_OBSERVABILITY_FINGERPRINT_KEY=
# ----- HTTP Client platform (app.httpclient.*) -----
# The single switch for outbound HTTP. False means no HTTP client property is bound, and no
# transport provider, connection pool, TLS context, credential, thread or gateway is created.
# The per-client surface is indexed and per-deployment, so it is set directly in the environment
# rather than declared here; docs/httpclient/env-fields.yaml is its registry, and an
# APP_HTTPCLIENT_ variable that is not in that registry fails startup.
APP_HTTPCLIENT_ENABLED=false
+31 -8
View File
@@ -17,6 +17,7 @@
| 게이트 | 하는 일 |
| --- | --- |
| `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 |
| `verifyRuntimeModuleMembership` | registry의 두 composition root membership과 실제 main project dependency가 정확히 일치하는지 검사 |
| `verifyEnvKeys` | `env-keys.yaml``application.yml``src/.env` 가 어긋나지 않는지 검사 |
| `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 |
| `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 |
@@ -84,6 +85,24 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
registry와 ArchUnit 규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. settings와 gate는
같은 registry를 읽고, 등록되지 않은 leaf나 허용되지 않은 edge를 fail-closed로 거부합니다.
### `verifyRuntimeModuleMembership`
- **하는 일.** 같은 registry의 `runtime_compositions`와 각 leaf의 `runtime_memberships`를 읽어
`app-bootstrap`/`sample-portfolio`의 실제 `api`/`implementation`/`compileOnly`/`runtimeOnly`
project dependency와 정확히 대조합니다.
- **opt-in의 의미.** membership이 빈 GraphQL/gRPC/WebSocket/Mongo leaf는 독립 빌드 대상이지만 두
shipped runtime에는 없습니다. app-bootstrap의 `conditionalTransportTest` test-only classpath는
실제 채택 전에 세 inbound transport를 함께 qualification하기 위한 evidence composition입니다.
- **변경 규칙.** production edge를 추가하거나 제거할 때 `allowed_dependencies`,
`runtime_memberships`, 실제 Gradle dependency를 같은 변경에서 갱신하지 않으면 `check`가 실패합니다.
세 opt-in inbound transport의 test-only composition, 실제 wire 경계, positive-count/zero-skip 증거는
다음 release-blocking aggregate로 실행합니다.
```bash
./gradlew conditionalTransportQualification
```
### `verifyOneTypePerFile` (code-conventions I6)
- **하는 일.** `src/main/java` 의 모든 `.java` 파일이 public 최상위 타입을 1개만 갖고, 그 타입 이름이
@@ -119,9 +138,9 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
경로를 **제외한** 모든 요청은 인증을 요구합니다(`src/.env``SecuritySettings.publicPaths()`
`SecurityConfig`). 이 public 표면이 바뀌는 순간이 곧 보호되던 엔드포인트가 조용히 공개로 노출되는
지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다.
- **승인 방법.** reviewer 가 `./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange`
snapshot 을 의도적으로 다시 생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 수동 승인 후
재생성된 snapshot 을 함께 커밋합니다.
- **승인 방법.** `verifyPublicPathSnapshot` 은 항상 읽기 전용입니다. reviewer 가 변경을 승인한 뒤
`./gradlew updatePublicPathSnapshot -PapprovePublicPathChange` snapshot 을 명시적으로 다시
생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 재생성된 snapshot 을 함께 커밋합니다.
- **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시
`SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection
은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가
@@ -130,8 +149,8 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다.
- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. 이 파일은 커밋된 필수 보안
baseline 입니다. CI 는 Gradle 실행 전에 파일이 비어 있지 않고 Git에 추적되는지 검사하므로 fresh
checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 위 명령으로 재생성한
보안 리뷰와 함께 커밋합니다.
checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 update task로 재생성한
보안 리뷰와 함께 커밋합니다.
### `verifyTrivyignore`
@@ -260,9 +279,13 @@ ca-skeleton:
- `ACTIVE`는 exact destination/provider/operation-catalog binding을 요구합니다. 현재 유일한
buffered-classic readiness card가 `NOT_IMPLEMENTED`이므로 provider resource 생성 전에
fail-closed합니다. 아직 운영 HTTP provider를 활성화할 수 있다는 뜻이 아닙니다.
- 기존 `APP_OUTBOUND_HTTP_*``app.outbound.http.*`는 canonical 설정이 아닙니다. `.env`,
application YAML env-key registry에서 제거됐으며 canonical composition에 입력하면 상태와
무관하게 기동을 거부합니다.
- 기존 `APP_OUTBOUND_HTTP_*``app.outbound.http.*`는 canonical 설정이 아닙니다. 루트 `src/.env`,
`app-bootstrap` application YAML, env-key registry에서 제거됐으며 canonical composition에
입력하면 상태와 무관하게 기동을 거부합니다.
- 다만 `sample-portfolio`의 application YAML에는 legacy facade를 시연하기 위해 15개 키가 남아
있습니다. 이 모듈은 fixture/reference consumer이고 production 의존성이 아니며, 그 YAML은
`verifyEnvKeys`가 검사하는 세 파일에 포함되지 않습니다. "제거됐다"는 문장이 저장소 전체를
가리킨다고 읽히지 않도록 범위를 명시합니다.
- legacy JDK facade가 필요한 fork만 canonical composition 밖에서
`OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다.
timeout/retry/CB/response-size 설정은 그 migration API 내부 계약일 뿐 canonical provider
+97 -2
View File
@@ -21,6 +21,63 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
connection/admission, private keys and versioned atomic programs.
- Keep the legacy cache router isolated while consumers migrate to semantic ports.
- Reuse `adapter:outbound:support` for shared outbound concerns.
- Host the general-purpose Redis SDK under `…cache.redis.sdk` (see below). The SDK is a separate
concern from the semantic cache ports and must not be reached from `application-core`.
## Redis SDK (`…cache.redis.sdk`)
The Redis wrapper and typed API described in
`docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md` lives inside this leaf. Its
design models the SDK as twelve Gradle modules; this repository's 19-leaf fail-closed registry
outranks that layout, so each designed module is a package instead. Delivery status and the full
adaptation rationale are in
`docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-status.md`.
- `sdk.api..` is the public contract. It must never import Spring, Lettuce, Micrometer, or any SDK
implementation package; Reactor is confined to `sdk.api.reactive`.
- `sdk.lettuce..` implements the contract; `sdk.config` owns properties, the capability probe, and
the permit authority.
- `src/main/resources/redis-sdk/redis-command-policy.yml` is the command policy SSOT. A command that
is not classified there is refused, so adding a command means editing that file, not the code.
- `sdk.lettuce.operations` implements the typed operations. Everything there goes through
`RedisCommandGateway`, the only seam that reaches Lettuce, and every call is admitted by
`CommandPolicyGuard` before it runs. Never call the driver from an operation directly.
- `sdk.cluster` owns client-side slot arithmetic and cluster observation. It depends on `sdk.api`
only and must never import Lettuce: the slot is computed before a command is built, which is what
lets `CommandPolicyGuard` refuse a cross-slot request instead of learning about it from a server
redirect.
- `sdk.programmability` owns transactions, registered Lua scripts, and deployed function calls. A
script body is never accepted at call time: `EVAL` is blocked in the catalog and only `EVALSHA` of
a `RedisScriptRegistry` digest is reachable. `FUNCTION LOAD` is admin-plane, never application.
- Transactions are `WATCH`/`MULTI`/`EXEC` and **never** roll back. `TransactionResult` reports only
"executed" or "a watched key changed, so nothing ran", and no type in this package offers a word
that suggests otherwise. A queued command returns a `QueuedReply` that throws when read before the
commit, because inside the window the server has answered `+QUEUED` and nothing else. Queued
commands pass the same `CommandPolicyGuard` admission as ordinary ones — a transaction is not a
way around the guard — and the window is closed on every exit path, including a callback that
threw, because a connection abandoned in `MULTI` state silently queues the next caller's command.
The queue is write-only on purpose: a read inside the window cannot be branched on, so the reads a
transaction depends on belong before it, under `WATCH`.
- `sdk.raw` is the approved raw command gateway. A command is reachable only when the catalog marks
it `RAW_ONLY` *and* the deployment registered an `ApprovedRawCommand` for it; keys are parsed back
out of the arguments and namespace-checked before anything is sent. Never add a method here that
takes a command name as a string.
- `sdk.admin` is the read-only diagnostic plane. It takes its own gateway (admin ACL account, own
connection), refuses any command the catalog does not classify `ADMIN_ONLY` and read-only, and
projects replies so a slow log or client listing never carries arguments, peer addresses, or
connection names.
- `sdk.extensions.*` holds the Redis 8 modules (JSON, Search, Time Series, Probabilistic). Every
bean is created through `ifSupported(...)` — the capability probe decides, the catalog minimum is
only a pre-filter — and all of them build commands through `ExtensionCommandRunner` so the guard
sees their keys. Search is the exception that proves it: an index is not a key, so its name is
namespaced by `LettuceRedisSearchOperations` itself.
- `RedisSdkModuleBoundaryTest` enforces the package graph, driver containment, the absence of any
arbitrary string command surface, and the list of designed-but-unimplemented modules. Update
`NOT_YET_IMPLEMENTED_MODULES` when a module lands.
- Decisions that must not be changed without revisiting the design: no unbounded `entries`,
`members`, `rangeAll`, or `keys`; no optional R2 permit or budget; no arbitrary command string
overload; no automatic retry of a non-idempotent write after a timeout; no real key in a metric or
trace tag.
## Boundaries
@@ -44,5 +101,43 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
Focused tests use fakes for contract, key, catalog, and typed-facade behavior. R1/R2 promotion
requires a separate real Redis service lane; it may never be silently skipped when selected.
`redisServiceTest` is the explicit standalone lane. It fails when its host/port properties are
missing; the default unit task excludes its `redis-service` tag.
`redisTopologyTest` is the only real-server lane. It is opt-in and fail-closed in seven ways: the
lane must be one of `standalone`, `sentinel`, `cluster`, `tls`; the endpoint properties must be
present (`sentinel` additionally needs `redis.topology.master`, `tls` needs
`redis.topology.trust-material`); a test class carrying the lane's tag must exist; a run that
executes zero tests fails; the classes the lane exists to run must actually have run; the executed
count must reach the lane's declared floor; and a skipped test fails the run rather than counting
as executed. `tls` is a lane, not a deployment mode — its shape is standalone and the task maps it
so, because what it qualifies is the transport.
## Composition
`RedisSdkAutoConfiguration` is the only place Redis settings and Redis runtime come into existence,
and it exists only while `app.redis.enabled` (env `APP_REDIS_ENABLED`) is true. It builds the
client, the connection owner and the health contributors; `RedisCapabilityConfig` in `app-bootstrap`
composes the semantic ports on top, one per role selector. "Redis is on" therefore means the
capabilities that need Redis exist, not merely that Redis is reachable.
Every capability renders its keys under the one namespace from `app.redis.namespace`
`{environment}:{service}:{domain}` — through `CapabilityKeyspace`. Never give a capability its own
prefix tokens: four capabilities each joining two free-form strings produced four different key
shapes, and the deployment's ACL pattern matched none of them.
Lanes authenticate as different accounts. `RedisConnectionKind.credentialRole()` maps the lane to a
`RedisCredentialRole`, and the topology factory builds one client per *configured* role — so a
single-account deployment still gets exactly one client and one event loop. The `SCRIPT` lane is
the reason it exists: `SCRIPT LOAD` and `EVALSHA` belong to the advanced account, so the account
that reads a cache entry cannot execute a script.
A Cluster transaction runs on one node, so `RedisTransactionRunner` derives a routing key from the
watched keys (or an explicit `RedisSlotTag`) and pins the `TRANSACTION` lane to the node that owns
that slot. Routed leases are never pooled: a pooled connection is pinned to the previous caller's
node.
`RedisSdkSettings`
must never carry a class-level `@ConfigurationProperties`: the bootstrap's application-wide
`@ConfigurationPropertiesScan` would then register it in every deployment, so a service that runs
no Redis would bind Redis configuration. `RedisOptionalityContractTest` in `app-bootstrap` enforces
that. Role selectors (cache binding, session auth-mode, idempotency/lease/rate-limit provider)
choose which capabilities compose; none of them activates Redis, and selecting one while the global
switch is off is refused by `RedisActivationValidator`.
@@ -0,0 +1,306 @@
package dev.caskeleton.adapter.outbound.cache.redis.idempotency;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
/**
* The idempotency record's state machine, one atomic program per transition.
*
* <p>Each program re-reads the record, checks the owner <em>and</em> the state revision, and only
* then mutates. Both halves are necessary. The owner alone would let a holder whose lease expired —
* and whose claim was taken over — write over the new holder's work. The revision alone would let a
* different owner at the same revision do it. Together they are an optimistic compare-and-set, and
* every successful transition bumps the revision so a stale handle can never be reused.
*
* <p>The record is a hash, not a string, because the transitions touch different fields and a
* read-modify-write of a serialized blob would reintroduce exactly the race the programs remove.
*
* <p>Nothing here interprets the stored response. It is an opaque payload the application encoded;
* this adapter stores and returns bytes, so a codec change is a concern of whoever wrote them.
*/
public final class IdempotencyScripts {
/**
* Claim: create, replay, take over an expired lease, or report why not.
*
* <p>The fingerprint is compared before anything else. Two different requests that hash to the
* same idempotency scope are a client error, and treating the second as a replay of the first
* would return somebody else's response.
*/
private static final String CLAIM =
"""
local state = redis.call('HGET', KEYS[1], 'state')
local nowMillis = tonumber(ARGV[6])
if state == false then
redis.call('HSET', KEYS[1],
'state', 'CLAIMED', 'owner', ARGV[1], 'attempt', 1, 'rev', 1,
'op', ARGV[2], 'fp', ARGV[3], 'codec', ARGV[4], 'policy', ARGV[5],
'leaseUntil', nowMillis + tonumber(ARGV[7]))
redis.call('PEXPIRE', KEYS[1], ARGV[8])
return {'ACQUIRED', 1, 1, ARGV[1], '', tostring(nowMillis + tonumber(ARGV[7]))}
end
local fingerprint = redis.call('HGET', KEYS[1], 'fp')
if fingerprint ~= ARGV[3] then
return {'FINGERPRINT_MISMATCH', 0, 0, '', '', ''}
end
local owner = redis.call('HGET', KEYS[1], 'owner')
local op = redis.call('HGET', KEYS[1], 'op')
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
if state == 'COMPLETED' then
return {'COMPLETED_REPLAY', attempt, rev, owner, redis.call('HGET', KEYS[1], 'resp'),
tostring(redis.call('PTTL', KEYS[1]))}
end
if state == 'ABANDONED' then
return {'RECOVERY_REQUIRED', attempt, rev, owner, '', ''}
end
if owner == ARGV[1] then
if op ~= ARGV[2] then
return {'OWNER_OPERATION_CONFLICT', attempt, rev, owner, '', ''}
end
-- Same owner, same operation: a retry whose first reply was lost.
return {'REPLAYED_ACQUIRE', attempt, rev, owner, '',
redis.call('HGET', KEYS[1], 'leaseUntil')}
end
local leaseUntil = tonumber(redis.call('HGET', KEYS[1], 'leaseUntil'))
if state == 'FAILED_RETRYABLE' or (leaseUntil ~= nil and leaseUntil <= nowMillis) then
-- The previous holder's processing lease expired, or they marked the attempt retryable.
-- Taking over bumps the attempt so the new holder can tell it is not the first.
redis.call('HSET', KEYS[1],
'state', 'CLAIMED', 'owner', ARGV[1], 'attempt', attempt + 1, 'rev', rev + 1,
'op', ARGV[2], 'leaseUntil', nowMillis + tonumber(ARGV[7]))
redis.call('PEXPIRE', KEYS[1], ARGV[8])
return {'TAKEN_OVER', attempt + 1, rev + 1, ARGV[1], '',
tostring(nowMillis + tonumber(ARGV[7]))}
end
return {'IN_PROGRESS', attempt, rev, owner, '', tostring(leaseUntil - nowMillis)}
""";
/** A generic owner+revision compare-and-set transition. */
private static final String TRANSITION =
"""
local state = redis.call('HGET', KEYS[1], 'state')
if state == false then
return {'ABSENT', 0, 0, '', '', ''}
end
local owner = redis.call('HGET', KEYS[1], 'owner')
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
local op = redis.call('HGET', KEYS[1], 'op')
if owner ~= ARGV[1] then
return {'NOT_OWNER', attempt, rev, owner, '', ''}
end
if op ~= ARGV[3] then
return {'OPERATION_CONFLICT', attempt, rev, owner, '', ''}
end
if state == ARGV[5] then
-- Already in the target state, under the same owner and the same operation: a retry whose
-- first reply was lost, not a second transition. This is checked BEFORE the revision,
-- deliberately. The caller's handle necessarily carries the pre-transition revision — they
-- never received the reply that would have replaced it — so a revision check first would
-- turn every lost reply into NOT_OWNER and make the idempotent call non-idempotent. The
-- owner and operation already prove the record was moved by this caller and nobody else.
return {'ALREADY', attempt, rev, owner, redis.call('HGET', KEYS[1], 'resp') or '', ''}
end
if rev ~= tonumber(ARGV[2]) then
-- Stale handle: somebody moved the record on after this owner read it, and the target
-- state is not where they left it.
return {'NOT_OWNER', attempt, rev, owner, '', ''}
end
if state ~= ARGV[4] then
return {'WRONG_STATE', attempt, rev, owner, state, ''}
end
redis.call('HSET', KEYS[1], 'state', ARGV[5], 'rev', rev + 1)
if ARGV[6] ~= '' then
redis.call('HSET', KEYS[1], 'resp', ARGV[6])
end
if ARGV[7] ~= '' then
redis.call('HSET', KEYS[1], 'leaseUntil', ARGV[7])
end
if ARGV[8] ~= '' then
redis.call('PEXPIRE', KEYS[1], ARGV[8])
end
return {'APPLIED', attempt, rev + 1, owner, '', ''}
""";
/** Release before execution: only from CLAIMED, and only by the owner that holds it. */
private static final String RELEASE =
"""
local state = redis.call('HGET', KEYS[1], 'state')
if state == false then
return {'ABSENT', 0, 0, '', '', ''}
end
local owner = redis.call('HGET', KEYS[1], 'owner')
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
local op = redis.call('HGET', KEYS[1], 'op')
if owner ~= ARGV[1] then
return {'NOT_OWNER', attempt, rev, owner, '', ''}
end
if op ~= ARGV[3] then
return {'OPERATION_CONFLICT', attempt, rev, owner, '', ''}
end
if state ~= 'CLAIMED' then
return {'WRONG_STATE', attempt, rev, owner, state, ''}
end
redis.call('DEL', KEYS[1])
return {'APPLIED', attempt, rev, owner, '', ''}
""";
/** Inspect: read the record without touching it. */
private static final String INSPECT =
"""
local state = redis.call('HGET', KEYS[1], 'state')
if state == false then
return {'ABSENT', 0, 0, '', '', ''}
end
local fingerprint = redis.call('HGET', KEYS[1], 'fp')
if fingerprint ~= ARGV[2] then
return {'FINGERPRINT_MISMATCH', 0, 0, '', '', ''}
end
local owner = redis.call('HGET', KEYS[1], 'owner')
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
local op = redis.call('HGET', KEYS[1], 'op')
local mine = 'OTHER'
if owner == ARGV[1] then
if op == ARGV[3] then
mine = 'MINE'
else
mine = 'OPERATION_CONFLICT'
end
end
return {state, attempt, rev, owner,
redis.call('HGET', KEYS[1], 'resp') or '',
mine .. '|' .. tostring(redis.call('PTTL', KEYS[1]))}
""";
private final Map<String, AtomicReference<String>> digests = new LinkedHashMap<>();
CompletionStage<Reply> claim(RedisCommandGateway gateway, byte[] key, List<String> arguments) {
return run(gateway, "claim", CLAIM, key, arguments);
}
CompletionStage<Reply> transition(
RedisCommandGateway gateway, byte[] key, List<String> arguments) {
return run(gateway, "transition", TRANSITION, key, arguments);
}
CompletionStage<Reply> release(RedisCommandGateway gateway, byte[] key, List<String> arguments) {
return run(gateway, "release", RELEASE, key, arguments);
}
CompletionStage<Reply> inspect(RedisCommandGateway gateway, byte[] key, List<String> arguments) {
return run(gateway, "inspect", INSPECT, key, arguments);
}
private CompletionStage<Reply> run(
RedisCommandGateway gateway, String name, String source, byte[] key, List<String> arguments) {
AtomicReference<String> cache =
digests.computeIfAbsent(name, unused -> new AtomicReference<>());
List<byte[]> encoded = new ArrayList<>(arguments.size());
for (String argument : arguments) {
encoded.add(argument.getBytes(StandardCharsets.UTF_8));
}
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, encoded))
.handle(
(reply, failure) ->
failure == null
? CompletableFuture.completedFuture(reply)
: reload(gateway, source, cache, key, encoded, failure))
.thenCompose(stage -> stage)
.thenApply(IdempotencyScripts::replyOf);
}
private CompletionStage<List<Object>> reload(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments,
Throwable failure) {
if (!scriptMissing(failure)) {
return CompletableFuture.failedFuture(failure);
}
cache.set(null);
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments));
}
private static CompletionStage<String> digest(
RedisCommandGateway gateway, String source, AtomicReference<String> cache) {
String cached = cache.get();
if (cached != null) {
return CompletableFuture.completedFuture(cached);
}
return gateway
.loadScript(source.getBytes(StandardCharsets.UTF_8))
.thenApply(
loaded -> {
cache.set(loaded);
return loaded;
});
}
private static boolean scriptMissing(Throwable failure) {
Throwable cause = failure;
while ((cause instanceof CompletionException || cause instanceof ExecutionException)
&& cause.getCause() != null) {
cause = cause.getCause();
}
String message = cause.getMessage();
return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT");
}
private static Reply replyOf(List<Object> reply) {
if (reply == null || reply.size() < 6) {
throw new IllegalStateException("the idempotency program answered with an unexpected shape");
}
return new Reply(
text(reply.get(0)),
number(reply.get(1)),
number(reply.get(2)),
text(reply.get(3)),
text(reply.get(4)),
text(reply.get(5)));
}
private static long number(Object value) {
if (value instanceof Number n) {
return n.longValue();
}
String text = text(value);
return text.isBlank() ? 0L : Long.parseLong(text.strip());
}
private static String text(Object value) {
if (value instanceof byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
return value == null ? "" : String.valueOf(value);
}
/**
* One program's answer.
*
* @param status the transition verdict
* @param attempt the record's attempt counter
* @param revision the record's state revision after the call
* @param owner the stored owner token
* @param payload the stored response, when the verdict carries one
* @param detail verdict-specific detail: a lease deadline, a TTL, or the observed state
*/
record Reply(
String status, long attempt, long revision, String owner, String payload, String detail) {}
}
@@ -0,0 +1,203 @@
package dev.caskeleton.adapter.outbound.cache.redis.lease;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
/**
* The four owner-checked lease programs.
*
* <p>Every one of them compares the stored owner before it mutates, inside the same server
* execution. That is the entire safety property of this adapter: a renew or release that reads the
* owner and then writes would let a holder whose lease expired in between extend or delete a lease
* that now belongs to somebody else. "Check then act" is not a lease.
*
* <p>The stored value is {@code ownerToken:operationId}. Both, because the same owner retrying a
* different operation is a different claim — a caller that re-acquires under a new operation id has
* lost the old one's guarantee and must be told, rather than silently inheriting it.
*/
public final class LeaseScripts {
/** Acquire: set if absent, and report the existing holder when present. */
private static final String ACQUIRE =
"""
local existing = redis.call('GET', KEYS[1])
if existing == false then
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
return {1, redis.call('PTTL', KEYS[1]), ''}
end
if existing == ARGV[1] then
-- The same owner and the same operation. This is a retry of a call whose reply was lost,
-- not a second claim, so it is answered with the lease rather than with contention.
return {2, redis.call('PTTL', KEYS[1]), existing}
end
return {0, redis.call('PTTL', KEYS[1]), existing}
""";
/** Renew: extend only while this exact owner and operation still hold it. */
private static final String RENEW =
"""
local existing = redis.call('GET', KEYS[1])
if existing == false then
return {0, 0, ''}
end
if existing ~= ARGV[1] then
return {-1, redis.call('PTTL', KEYS[1]), existing}
end
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return {1, redis.call('PTTL', KEYS[1]), existing}
""";
/** Release: delete only while this exact owner and operation still hold it. */
private static final String RELEASE =
"""
local existing = redis.call('GET', KEYS[1])
if existing == false then
return {0, 0, ''}
end
if existing ~= ARGV[1] then
return {-1, redis.call('PTTL', KEYS[1]), existing}
end
redis.call('DEL', KEYS[1])
return {1, 0, existing}
""";
/** Inspect: read without mutating, so a caller can ask without taking. */
private static final String INSPECT =
"""
local existing = redis.call('GET', KEYS[1])
if existing == false then
return {0, 0, ''}
end
if existing ~= ARGV[1] then
return {-1, redis.call('PTTL', KEYS[1]), existing}
end
return {1, redis.call('PTTL', KEYS[1]), existing}
""";
private final AtomicReference<String> acquireDigest = new AtomicReference<>();
private final AtomicReference<String> renewDigest = new AtomicReference<>();
private final AtomicReference<String> releaseDigest = new AtomicReference<>();
private final AtomicReference<String> inspectDigest = new AtomicReference<>();
CompletionStage<Reply> acquire(
RedisCommandGateway gateway, byte[] key, String ownership, long ttlMillis) {
return run(gateway, ACQUIRE, acquireDigest, key, args(ownership, ttlMillis));
}
CompletionStage<Reply> renew(
RedisCommandGateway gateway, byte[] key, String ownership, long ttlMillis) {
return run(gateway, RENEW, renewDigest, key, args(ownership, ttlMillis));
}
CompletionStage<Reply> release(RedisCommandGateway gateway, byte[] key, String ownership) {
return run(gateway, RELEASE, releaseDigest, key, args(ownership, 0));
}
CompletionStage<Reply> inspect(RedisCommandGateway gateway, byte[] key, String ownership) {
return run(gateway, INSPECT, inspectDigest, key, args(ownership, 0));
}
private CompletionStage<Reply> run(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments) {
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments))
.handle(
(reply, failure) ->
failure == null
? CompletableFuture.completedFuture(reply)
: reload(gateway, source, cache, key, arguments, failure))
.thenCompose(stage -> stage)
.thenApply(LeaseScripts::replyOf);
}
private CompletionStage<List<Object>> reload(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments,
Throwable failure) {
if (!scriptMissing(failure)) {
return CompletableFuture.failedFuture(failure);
}
cache.set(null);
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments));
}
private static CompletionStage<String> digest(
RedisCommandGateway gateway, String source, AtomicReference<String> cache) {
String cached = cache.get();
if (cached != null) {
return CompletableFuture.completedFuture(cached);
}
return gateway
.loadScript(source.getBytes(StandardCharsets.UTF_8))
.thenApply(
loaded -> {
cache.set(loaded);
return loaded;
});
}
private static boolean scriptMissing(Throwable failure) {
Throwable cause = failure;
while ((cause instanceof CompletionException || cause instanceof ExecutionException)
&& cause.getCause() != null) {
cause = cause.getCause();
}
String message = cause.getMessage();
return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT");
}
private static Reply replyOf(List<Object> reply) {
if (reply == null || reply.size() < 3) {
throw new IllegalStateException("the lease program answered with an unexpected shape");
}
return new Reply(asLong(reply.get(0)), asLong(reply.get(1)), asText(reply.get(2)));
}
private static long asLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof byte[] bytes) {
return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip());
}
throw new IllegalStateException("the lease program answered with an unexpected value type");
}
private static String asText(Object value) {
if (value instanceof byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
return value == null ? "" : String.valueOf(value);
}
private static List<byte[]> args(String ownership, long ttlMillis) {
return List.of(
ownership.getBytes(StandardCharsets.UTF_8),
Long.toString(ttlMillis).getBytes(StandardCharsets.UTF_8));
}
/**
* One program's answer.
*
* @param status {@code 1} applied, {@code 2} replay of the same claim, {@code 0} absent, {@code
* -1} held by somebody else
* @param remainingMillis the server's remaining TTL, diagnostic only
* @param holder the stored ownership string, empty when absent
*/
record Reply(long status, long remainingMillis, String holder) {}
}
@@ -0,0 +1,280 @@
package dev.caskeleton.adapter.outbound.cache.redis.ratelimit;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
import dev.caskeleton.shared.ratelimit.RateLimitPolicy;
import dev.caskeleton.shared.ratelimit.RateParameters;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
/**
* The atomic programs that make one rate-limit decision one round trip.
*
* <p>Every algorithm here reads state, decides, mutates, and sets an expiry inside a single server
* execution. Splitting that into commands is not a performance question: two concurrent requests
* that both read "49 used of 50" would both be allowed, and the limit would be exceeded by exactly
* the concurrency. The whole decision has to be indivisible or it is not a limit.
*
* <p>Time comes from the caller, not from the server's {@code TIME}. Two reasons: a script that
* calls {@code TIME} is non-deterministic, and the decision has to be measured against the clock
* the caller's deadline is measured against. The caller's clock going backwards is handled by the
* policy's clock-regression bound rather than by trusting it blindly.
*
* <p>Loaded once and called by digest. A {@code NOSCRIPT} means the server rejected the call before
* running anything, so reloading and retrying once is safe — it is not a retry of an ambiguous
* mutation.
*/
public final class RateLimitScripts {
/**
* Fixed window: one counter per window, expiring with it.
*
* <p>Returns {@code {allowed, remaining, resetAfterMillis}}. The expiry is set from the window
* rather than refreshed per hit, so a subject cannot hold a counter alive indefinitely.
*/
private static final String FIXED_WINDOW =
"""
local limit = tonumber(ARGV[1])
local windowMillis = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local nowMillis = tonumber(ARGV[4])
local windowStart = nowMillis - (nowMillis % windowMillis)
local resetAfter = (windowStart + windowMillis) - nowMillis
local bucket = tostring(windowStart)
local current = tonumber(redis.call('HGET', KEYS[1], bucket)) or 0
if current + cost > limit then
return {0, limit - current, resetAfter}
end
redis.call('HSET', KEYS[1], bucket, current + cost)
redis.call('PEXPIRE', KEYS[1], windowMillis * 2)
return {1, limit - (current + cost), resetAfter}
""";
/**
* Sliding counter: the current window plus a weighted share of the previous one.
*
* <p>Approximate by construction, and the port says so. An exact sliding window needs one sorted
* set entry per request, which costs memory proportional to the traffic it is limiting — the
* failure mode of an exact limiter is that it becomes the outage.
*/
private static final String SLIDING_COUNTER =
"""
local limit = tonumber(ARGV[1])
local windowMillis = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local nowMillis = tonumber(ARGV[4])
local windowStart = nowMillis - (nowMillis % windowMillis)
local elapsed = nowMillis - windowStart
local resetAfter = windowMillis - elapsed
local current = tonumber(redis.call('HGET', KEYS[1], tostring(windowStart))) or 0
local previous = tonumber(redis.call('HGET', KEYS[1], tostring(windowStart - windowMillis))) or 0
local weight = (windowMillis - elapsed) / windowMillis
local estimated = current + math.floor(previous * weight)
if estimated + cost > limit then
return {0, math.max(0, limit - estimated), resetAfter}
end
redis.call('HSET', KEYS[1], tostring(windowStart), current + cost)
redis.call('HDEL', KEYS[1], tostring(windowStart - (windowMillis * 2)))
redis.call('PEXPIRE', KEYS[1], windowMillis * 3)
return {1, math.max(0, limit - (estimated + cost)), resetAfter}
""";
/**
* Token bucket: refill by elapsed time, then spend.
*
* <p>The stored timestamp is advanced by whole refill periods only. Advancing it to "now" would
* discard the fraction of a period that had already accrued, so a caller polling faster than the
* refill period would never accumulate a token.
*/
private static final String TOKEN_BUCKET =
"""
local capacity = tonumber(ARGV[1])
local refillTokens = tonumber(ARGV[2])
local refillPeriodMillis = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local nowMillis = tonumber(ARGV[5])
local state = redis.call('HMGET', KEYS[1], 'tokens', 'updatedAt')
local tokens = tonumber(state[1])
local updatedAt = tonumber(state[2])
if tokens == nil or updatedAt == nil then
tokens = capacity
updatedAt = nowMillis
end
if updatedAt > nowMillis then
-- The caller's clock went backwards. Refilling on a negative elapsed time would remove
-- tokens; holding the state still is the conservative reading.
updatedAt = nowMillis
end
local periods = math.floor((nowMillis - updatedAt) / refillPeriodMillis)
if periods > 0 then
tokens = math.min(capacity, tokens + (periods * refillTokens))
updatedAt = updatedAt + (periods * refillPeriodMillis)
end
local resetAfter = refillPeriodMillis - ((nowMillis - updatedAt) % refillPeriodMillis)
if tokens < cost then
redis.call('HSET', KEYS[1], 'tokens', tokens, 'updatedAt', updatedAt)
redis.call('PEXPIRE', KEYS[1], refillPeriodMillis * (capacity / math.max(1, refillTokens)) + refillPeriodMillis)
return {0, math.floor(tokens), resetAfter}
end
tokens = tokens - cost
redis.call('HSET', KEYS[1], 'tokens', tokens, 'updatedAt', updatedAt)
redis.call('PEXPIRE', KEYS[1], refillPeriodMillis * (capacity / math.max(1, refillTokens)) + refillPeriodMillis)
return {1, math.floor(tokens), resetAfter}
""";
private final AtomicReference<String> fixedWindowDigest = new AtomicReference<>();
private final AtomicReference<String> slidingCounterDigest = new AtomicReference<>();
private final AtomicReference<String> tokenBucketDigest = new AtomicReference<>();
/**
* Evaluates one request atomically.
*
* @param gateway the driver seam of a borrowed lease
* @param key the rendered counter key
* @param policy the policy to apply
* @param cost the request's cost
* @param now the caller's clock reading
* @return the evaluation
*/
public CompletionStage<Evaluation> evaluate(
RedisCommandGateway gateway, byte[] key, RateLimitPolicy policy, long cost, Instant now) {
Objects.requireNonNull(gateway, "gateway must be non-null");
Objects.requireNonNull(policy, "policy must be non-null");
Objects.requireNonNull(now, "now must be non-null");
long nowMillis = now.toEpochMilli();
return switch (policy.parameters()) {
case RateParameters.FixedWindow window ->
run(
gateway,
FIXED_WINDOW,
fixedWindowDigest,
key,
arguments(window.limit(), window.window().toMillis(), cost, nowMillis));
case RateParameters.SlidingCounter sliding ->
run(
gateway,
SLIDING_COUNTER,
slidingCounterDigest,
key,
arguments(sliding.limit(), sliding.window().toMillis(), cost, nowMillis));
case RateParameters.TokenBucket bucket ->
run(
gateway,
TOKEN_BUCKET,
tokenBucketDigest,
key,
arguments(
bucket.capacity(),
bucket.refillTokens(),
bucket.refillPeriod().toMillis(),
cost,
nowMillis));
default ->
CompletableFuture.failedFuture(
new IllegalStateException("unsupported rate parameters: " + policy.parameters()));
};
}
private CompletionStage<Evaluation> run(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments) {
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments))
.handle(
(reply, failure) ->
failure == null
? CompletableFuture.completedFuture(reply)
: reload(gateway, source, cache, key, arguments, failure))
.thenCompose(stage -> stage)
.thenApply(RateLimitScripts::evaluationOf);
}
private CompletionStage<List<Object>> reload(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments,
Throwable failure) {
if (!scriptMissing(failure)) {
return CompletableFuture.failedFuture(failure);
}
cache.set(null);
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments));
}
private static CompletionStage<String> digest(
RedisCommandGateway gateway, String source, AtomicReference<String> cache) {
String cached = cache.get();
if (cached != null) {
return CompletableFuture.completedFuture(cached);
}
return gateway
.loadScript(source.getBytes(StandardCharsets.UTF_8))
.thenApply(
loaded -> {
cache.set(loaded);
return loaded;
});
}
private static boolean scriptMissing(Throwable failure) {
Throwable cause = failure;
while ((cause instanceof CompletionException || cause instanceof ExecutionException)
&& cause.getCause() != null) {
cause = cause.getCause();
}
String message = cause.getMessage();
return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT");
}
private static Evaluation evaluationOf(List<Object> reply) {
if (reply == null || reply.size() < 3) {
throw new IllegalStateException(
"the rate limit program answered with "
+ (reply == null ? "nothing" : reply.size())
+ " values; three were expected");
}
return new Evaluation(asLong(reply.get(0)) == 1L, asLong(reply.get(1)), asLong(reply.get(2)));
}
private static long asLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof byte[] bytes) {
return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip());
}
throw new IllegalStateException(
"the rate limit program answered with an unexpected value type: "
+ (value == null ? "null" : value.getClass().getName()));
}
private static List<byte[]> arguments(long... values) {
return java.util.Arrays.stream(values)
.mapToObj(value -> Long.toString(value).getBytes(StandardCharsets.UTF_8))
.toList();
}
/**
* One evaluation's result.
*
* @param allowed whether the request may proceed
* @param remaining the remaining budget after this request
* @param resetAfterMillis how long until the budget changes
*/
public record Evaluation(boolean allowed, long remaining, long resetAfterMillis) {}
}
@@ -0,0 +1,337 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.admin;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandSupport;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.OperationBudget;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.QualifiedRedisKey;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.CommandRequest;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.RedisCommandCatalog;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.RedisCommandPolicy;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.SyncRedisCommandExecutor;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisOperationContext;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* The admin plane, on its own connection and its own ACL account.
*
* <p>It takes its own gateway for the same reason the blocking operations do: a diagnostic that
* walks the keyspace or serializes a large {@code INFO} must not compete with request traffic, and
* the account it authenticates with should be able to read diagnostics and nothing else. Binding
* that to a separate gateway instance is how the separation is expressed structurally instead of
* being left to a deployment note.
*
* <p>Every command is checked against the catalog before it is built: not classified {@code
* ADMIN_ONLY}, or not read-only, and it does not get sent. That check is what keeps a future
* addition to this class from quietly becoming a write.
*/
public final class LettuceRedisAdminOperations implements RedisAdminOperations {
private static final String FAMILY = "ADMIN";
private static final int MAX_PROJECTED = 1_000;
private final RedisCommandCatalog catalog;
private final RedisCommandGateway adminGateway;
private final RedisOperationContext context;
private final SyncRedisCommandExecutor executor;
private final Duration timeout;
/**
* Creates the admin plane.
*
* @param catalog the command policy catalog
* @param adminGateway the driver seam, bound to the admin account's own connection
* @param context the shared rendering and budget rules
* @param executor the guarded blocking executor
* @param timeout the bound on one diagnostic
*/
public LettuceRedisAdminOperations(
RedisCommandCatalog catalog,
RedisCommandGateway adminGateway,
RedisOperationContext context,
SyncRedisCommandExecutor executor,
Duration timeout) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.adminGateway = Objects.requireNonNull(adminGateway, "admin gateway must be non-null");
this.context = Objects.requireNonNull(context, "operation context must be non-null");
this.executor = Objects.requireNonNull(executor, "executor must be non-null");
this.timeout = Objects.requireNonNull(timeout, "timeout must be non-null");
if (timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("the admin timeout must be positive");
}
}
@Override
public Map<String, String> serverInfo(String section) {
Objects.requireNonNull(section, "section must be non-null");
return fields(text(run(CommandId.parse("INFO"), List.of(), utf8(section))));
}
@Override
public long databaseSize() {
return number(run(CommandId.parse("DBSIZE"), List.of()));
}
@Override
public long memoryUsage(QualifiedRedisKey key) {
Objects.requireNonNull(key, "key must be non-null");
byte[] rendered = context.renderKey(key);
List<Object> reply = run(CommandId.parse("MEMORY USAGE"), List.of(key), rendered);
return reply.isEmpty() || reply.get(0) == null ? -1L : number(reply);
}
@Override
public List<SlowLogEntry> slowLog(int count) {
requireBounded(count, "a slow log read");
List<Object> reply =
run(CommandId.parse("SLOWLOG GET"), List.of(), utf8(Integer.toString(count)));
List<SlowLogEntry> entries = new ArrayList<>();
for (Object element : reply) {
List<Object> row = nested(element);
if (row.size() < 4) {
continue;
}
entries.add(
new SlowLogEntry(
(Long) row.get(0),
Instant.ofEpochSecond((Long) row.get(1)),
Duration.ofNanos(Duration.ofMillis((Long) row.get(2)).toNanos() / 1_000L),
family(nested(row.get(3)))));
}
return List.copyOf(entries);
}
@Override
public Map<String, Duration> latencyLatest() {
List<Object> reply = run(CommandId.parse("LATENCY LATEST"), List.of());
Map<String, Duration> latest = new LinkedHashMap<>();
for (Object element : reply) {
List<Object> row = nested(element);
if (row.size() < 3) {
continue;
}
latest.put(text(List.of(row.get(0))), Duration.ofMillis((Long) row.get(2)));
}
return Map.copyOf(latest);
}
@Override
public List<ClientSummary> clients(int limit) {
requireBounded(limit, "a client projection");
String listing = text(run(CommandId.parse("CLIENT LIST"), List.of()));
List<ClientSummary> clients = new ArrayList<>();
for (String line : listing.lines().toList()) {
if (line.isBlank() || clients.size() == limit) {
break;
}
Map<String, String> attributes = attributes(line);
clients.add(
new ClientSummary(
Long.parseLong(attributes.getOrDefault("id", "0")),
Duration.ofSeconds(Long.parseLong(attributes.getOrDefault("age", "0"))),
Duration.ofSeconds(Long.parseLong(attributes.getOrDefault("idle", "0"))),
attributes.getOrDefault("cmd", "unknown").toUpperCase(Locale.ROOT)));
}
return List.copyOf(clients);
}
@Override
public Map<String, String> clusterInfo() {
return fields(text(run(CommandId.parse("CLUSTER INFO"), List.of())));
}
/**
* The only configuration parameters this plane will read.
*
* <p>Chosen for what an operator diagnosing a Redis problem actually needs — memory ceiling and
* eviction, persistence, replication durability, connection lifetime, topology — and nothing
* else. Adding a parameter is an edit here, which is the point: the set is reviewable, whereas a
* glob is not.
*/
private static final List<String> DIAGNOSTIC_PARAMETERS =
List.of(
"maxmemory",
"maxmemory-policy",
"maxmemory-samples",
"appendonly",
"appendfsync",
"save",
"min-replicas-to-write",
"min-replicas-max-lag",
"timeout",
"tcp-keepalive",
"databases",
"cluster-enabled",
"cluster-require-full-coverage",
"lazyfree-lazy-eviction",
"lazyfree-lazy-expire",
"notify-keyspace-events",
"slowlog-log-slower-than",
"slowlog-max-len");
/** Substrings that mark a parameter as carrying credential material. */
private static final List<String> SECRET_MARKERS =
List.of("pass", "auth", "secret", "key-file", "keyfile", "user");
/** Replacement for a value that must never leave the server. */
static final String REDACTED = "[redacted]";
@Override
public Map<String, String> configuration() {
List<byte[]> arguments =
DIAGNOSTIC_PARAMETERS.stream().map(LettuceRedisAdminOperations::utf8).toList();
List<Object> reply = run(CommandId.parse("CONFIG GET"), List.of(), arguments);
Map<String, String> parameters = new LinkedHashMap<>();
for (int index = 0; index + 1 < reply.size(); index += 2) {
String name = text(List.of(reply.get(index)));
// Filtered again on the way out. The request already named only allowlisted parameters, but
// a server-side alias or a future glob-expanding change must not be able to widen the
// projection, and a parameter that slipped through must not carry its value with it.
if (!DIAGNOSTIC_PARAMETERS.contains(name)) {
continue;
}
String value = text(List.of(reply.get(index + 1)));
parameters.put(name, isSecretShaped(name) ? REDACTED : value);
}
return Map.copyOf(parameters);
}
private static boolean isSecretShaped(String parameterName) {
String lower = parameterName.toLowerCase(Locale.ROOT);
return SECRET_MARKERS.stream().anyMatch(lower::contains);
}
@Override
public Optional<String> aclDryRun(String username, CommandId commandId) {
Objects.requireNonNull(username, "username must be non-null");
Objects.requireNonNull(commandId, "command id must be non-null");
List<byte[]> arguments = new ArrayList<>();
arguments.add(utf8(username));
arguments.add(utf8(commandId.family()));
commandId.subcommand().map(LettuceRedisAdminOperations::utf8).ifPresent(arguments::add);
String answer = text(run(CommandId.parse("ACL DRYRUN"), List.of(), arguments));
return "OK".equals(answer) ? Optional.empty() : Optional.of(answer);
}
private List<Object> run(CommandId commandId, List<QualifiedRedisKey> keys, byte[]... arguments) {
return run(commandId, keys, List.of(arguments));
}
private List<Object> run(
CommandId commandId, List<QualifiedRedisKey> keys, List<byte[]> arguments) {
RedisCommandPolicy policy = catalog.require(commandId);
if (policy.support() != CommandSupport.ADMIN_ONLY || !policy.readOnly()) {
throw context.reject(
FAMILY, true, "the admin plane only sends read-only diagnostics the catalog approved");
}
long requestBytes = 1L;
for (byte[] argument : arguments) {
requestBytes += argument.length;
}
OperationBudget budget =
new OperationBudget(
Math.max(1, keys.size()),
requestBytes,
context.limits().maxReplyBytesPerElement(),
timeout);
return executor.execute(
new CommandRequest<>(
commandId,
keys,
requestBytes,
0L,
Optional.empty(),
Optional.empty(),
Optional.of(budget),
Optional.empty(),
() -> adminGateway.sendAdminDiagnostic(commandId, arguments)));
}
private void requireBounded(int count, String description) {
if (count < 1) {
throw context.reject(FAMILY, true, description + " must declare a positive bound");
}
if (count > MAX_PROJECTED) {
throw context.reject(
FAMILY, true, description + " may not exceed " + MAX_PROJECTED + " entries");
}
}
private static String family(List<Object> commandWords) {
return commandWords.isEmpty()
? "UNKNOWN"
: text(List.of(commandWords.get(0))).toUpperCase(Locale.ROOT);
}
private static Map<String, String> fields(String body) {
Map<String, String> parsed = new LinkedHashMap<>();
for (String line : body.lines().toList()) {
String trimmed = line.strip();
int separator = trimmed.indexOf(':');
if (trimmed.isEmpty() || trimmed.startsWith("#") || separator < 1) {
continue;
}
parsed.put(trimmed.substring(0, separator), trimmed.substring(separator + 1));
}
return Map.copyOf(parsed);
}
private static Map<String, String> attributes(String line) {
Map<String, String> parsed = new LinkedHashMap<>();
// Parsed by hand rather than by splitting: a CLIENT LIST line is space-separated key=value
// pairs, and the values can themselves contain characters a naive split would mangle.
String remainder = line.strip();
while (!remainder.isEmpty()) {
int space = remainder.indexOf(' ');
String pair = space < 0 ? remainder : remainder.substring(0, space);
remainder = space < 0 ? "" : remainder.substring(space + 1);
int separator = pair.indexOf('=');
if (separator > 0) {
parsed.put(pair.substring(0, separator), pair.substring(separator + 1));
}
}
return parsed;
}
private static long number(List<Object> reply) {
Object first = reply.isEmpty() ? null : reply.get(0);
if (first instanceof Long value) {
return value;
}
if (first instanceof byte[] bytes) {
return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip());
}
throw new IllegalStateException("the diagnostic did not answer with a number");
}
private static String text(List<Object> reply) {
Object first = reply.isEmpty() ? null : reply.get(0);
if (first instanceof byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
return first == null ? "" : String.valueOf(first);
}
@SuppressWarnings("unchecked")
private static List<Object> nested(Object element) {
return element instanceof List ? (List<Object>) element : List.of();
}
private static byte[] utf8(String text) {
return text.getBytes(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.admin;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.QualifiedRedisKey;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* Read-only server diagnostics, separated from the application request path.
*
* <p>Everything here is read-only by construction: the command policy catalog classifies each of
* these {@code ADMIN_ONLY} and read-only, and the implementation refuses to send anything that is
* not. The destructive counterparts an operator might reach for — {@code FLUSHDB}, {@code
* FLUSHALL}, {@code SHUTDOWN}, {@code DEBUG}, {@code CONFIG SET}, {@code CLIENT KILL}, {@code ACL
* SETUSER}, {@code SLOWLOG RESET}, {@code LATENCY RESET} — are all {@code BLOCKED} in the catalog
* and have no method here or anywhere else in the SDK.
*
* <p>The plane is expected to run on its own connection factory and its own ACL account. That is
* not something this interface can enforce, which is exactly why the catalog blocks the dangerous
* commands outright rather than trusting the deployment to have separated the credentials.
*/
public interface RedisAdminOperations {
/**
* Reads one {@code INFO} section.
*
* @param section the section name, for example {@code memory} or {@code replication}
* @return the section's fields
*/
Map<String, String> serverInfo(String section);
/**
* Reads the key count of the current database.
*
* @return the key count
*/
long databaseSize();
/**
* Reads the memory one key occupies.
*
* @param key the key, which is namespace-checked like any other
* @return the size in bytes, or {@code -1} when the key is absent
*/
long memoryUsage(QualifiedRedisKey key);
/**
* Reads the most recent slow log entries.
*
* @param count the strictly positive bound on returned entries
* @return the entries, newest first
*/
List<SlowLogEntry> slowLog(int count);
/**
* Reads the latest latency spike per monitored event.
*
* @return the latest spike per event name
*/
Map<String, Duration> latencyLatest();
/**
* Reads a bounded projection of the connected clients.
*
* @param limit the strictly positive bound on returned clients
* @return the projected clients
*/
List<ClientSummary> clients(int limit);
/**
* Reads the cluster state.
*
* @return the {@code CLUSTER INFO} fields
*/
Map<String, String> clusterInfo();
/**
* Reads the fixed diagnostic configuration projection.
*
* <p>There is deliberately no pattern parameter. {@code CONFIG GET} with a caller-supplied glob
* is an arbitrary read of the server's configuration: {@code *} returns everything the account
* can see, including {@code requirepass}, {@code masterauth}, {@code masteruser} and the TLS key
* passwords. An admin plane whose whole purpose is bounded, payload-free diagnostics cannot own a
* method that returns whatever the caller asks for, so the parameter set is fixed here and
* anything outside it is unreachable.
*
* @return the allowlisted diagnostic parameters, with any secret-shaped value redacted
*/
Map<String, String> configuration();
/**
* Asks the server whether a user would be allowed to run a command.
*
* <p>This is how an ACL account is verified against what the SDK actually sends, rather than
* against what someone believed it sends.
*
* @param username the ACL user
* @param commandId the command to test
* @return empty when the command would be allowed, otherwise the server's refusal reason
*/
java.util.Optional<String> aclDryRun(String username, CommandId commandId);
}
@@ -0,0 +1,397 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.config;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisCredentialRole;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeClient;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisTopologyClientFactory;
import java.io.IOException;
import java.io.InputStream;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.boot.health.contributor.Status;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.context.annotation.Conditional;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.type.AnnotatedTypeMetadata;
/**
* The Redis composition root, and the only place Redis settings come into existence.
*
* <p>{@code app.redis.enabled} is the whole switch. While it is false this class contributes
* nothing, and because {@link RedisSdkSettings} is registered here rather than by the
* application-wide {@code @ConfigurationPropertiesScan}, "contributes nothing" is literal: the
* properties are not bound, the cross-field rules are not run, no credential is resolved, and no
* policy resource, TLS material, client, connection or thread is created. A deployment that does
* not use Redis carries no Redis configuration, and a deployment with malformed Redis configuration
* it never enabled is not punished for it.
*
* <p>While it is true the order is fixed and entirely local: bind, validate, then build. Validation
* runs at context refresh, before anything can reach the network, which is what makes {@link
* RedisSdkSettings#validate()} the fail-fast its own documentation claims — until this class
* existed the method had no production caller at all.
*
* <p>Roles — cache, session, idempotency, rate limiting, leases — select <em>which</em> Redis
* capabilities compose on top of this. None of them is a second master switch; {@code
* RedisActivationValidator} in the bootstrap refuses the contradiction of a role that selects Redis
* while this switch is off.
*/
@AutoConfiguration
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true")
// Registers the binding post-processor that populates the @ConfigurationProperties @Bean below.
// Without it the bean is created and silently left at its defaults, which is worse than not
// binding at all: validation would pass on settings nobody configured.
@EnableConfigurationProperties
public class RedisSdkAutoConfiguration {
private static final Logger LOG = LoggerFactory.getLogger(RedisSdkAutoConfiguration.class);
/**
* The status an optional Redis reports when it is unreachable.
*
* <p>Not {@code DOWN}. A cache outage is a real degradation and belongs in the health detail, but
* a status the readiness group understands as failure would remove a healthy pod from service —
* shrinking capacity during the exact incident that needs it most.
*/
private static final Status DEGRADED =
new Status("DEGRADED", "Redis is unreachable; the cache is bypassed");
/**
* Binds and validates the Redis settings.
*
* <p>Validation happens in the factory method rather than in an {@code @PostConstruct} or a
* listener so that a configuration error is reported as a failure to create this bean, with the
* offending rule in the message, and so that nothing downstream can obtain an unvalidated
* settings instance.
*
* @return the validated settings
*/
@Bean
@ConfigurationProperties(prefix = "app.redis")
public RedisSdkSettings redisSdkSettings() {
return new RedisSdkSettings();
}
/**
* Runs the cross-field rules once the binder has populated the settings.
*
* <p>Spring binds {@code @ConfigurationProperties} after the factory method returns, so the
* validation cannot live inside {@link #redisSdkSettings()}. A {@code
* ConfigurationPropertiesBindHandlerAdvisor}-free way to get the same fail-fast is a bean that
* depends on the settings: it is created during refresh, before any Redis client would be, and an
* exception here stops the context.
*
* @param settings the bound settings
* @return the validation outcome, kept as a bean so warnings are inspectable in tests
*/
@Bean
public RedisSdkSettingsValidation redisSdkSettingsValidation(
RedisSdkSettings settings, ResourceLoader resourceLoader) {
List<String> warnings = settings.validate();
requireRawPolicyResource(settings, resourceLoader);
warnings.forEach(warning -> LOG.warn("Redis SDK configuration warning: {}", warning));
return new RedisSdkSettingsValidation(warnings);
}
/**
* Proves the raw command allowlist exists before anything can reach Redis.
*
* <p>{@code validate()} only checks that the setting is non-blank, and the default points at
* {@code classpath:redis-sdk/raw-command-allowlist.yml} — a resource this module does not ship.
* So enabling the raw gateway passed configuration validation and then failed at the first raw
* command, from inside a request, against a live connection. The allowlist is the entire
* authorisation model for that gateway; not being able to read it is a startup failure.
*/
private static void requireRawPolicyResource(
RedisSdkSettings settings, ResourceLoader resourceLoader) {
if (!settings.getRaw().isEnabled()) {
return;
}
String location = settings.getRaw().getPolicyResource();
Resource resource = resourceLoader.getResource(location);
if (!resource.exists() || !resource.isReadable()) {
throw new IllegalStateException(
"the raw gateway is enabled but its allowlist resource '"
+ location
+ "' does not exist or cannot be read. The allowlist is the only thing that decides"
+ " which raw commands are reachable, so an unreadable one is a startup failure"
+ " rather than a per-command surprise. Point"
+ " app.redis.raw.policy-resource at a readable resource, or set"
+ " app.redis.raw.enabled=false.");
}
try (InputStream ignored = resource.getInputStream()) {
LOG.info("Redis raw command allowlist loaded from {}", location);
} catch (IOException failure) {
throw new IllegalStateException(
"the raw gateway allowlist resource '" + location + "' could not be opened", failure);
}
}
/**
* Resolves every credential reference the selected configuration actually needs.
*
* <p>Before any client exists. A reference that does not resolve is a configuration error, and
* the only place it can be reported as one is here — after that the failure is an authentication
* error on somebody's first command.
*
* @param settings the validated settings
* @param secretSource resolves a secret name to its value
* @param validation ordered after validation so a malformed setting is reported first
* @return the resolved credentials
*/
@Bean
public RedisResolvedCredentials redisResolvedCredentials(
RedisSdkSettings settings,
ObjectProvider<RedisSecretSource> secretSource,
RedisSdkSettingsValidation validation) {
RedisCredentialResolver resolver =
new RedisCredentialResolver(
name -> secretSource.getIfAvailable(() -> environmentSecretSource()).resolve(name));
Map<RedisCredentialRole, RedisCredentialResolver.RedisCredentials> accounts =
new EnumMap<>(RedisCredentialRole.class);
// Every configured role is resolved, not only the application one. A deployment that named an
// advanced or pub/sub account and got a client that silently authenticated as the application
// account has the privilege separation it configured on paper and nowhere else.
put(
accounts,
RedisCredentialRole.APPLICATION,
resolver.resolve("application", settings.getAuthentication().getCredentialReference()));
put(
accounts,
RedisCredentialRole.ADVANCED,
resolver.resolve(
"advanced", settings.getAuthentication().getAdvancedCredentialReference()));
put(
accounts,
RedisCredentialRole.PUBSUB,
resolver.resolve("pub/sub", settings.getAuthentication().getPubsubCredentialReference()));
if (settings.getAdmin().isEnabled()) {
put(
accounts,
RedisCredentialRole.ADMIN,
resolver.resolve("admin", settings.getAdmin().getCredentialReference()));
}
if (settings.getRaw().isEnabled()) {
put(
accounts,
RedisCredentialRole.RAW,
resolver.resolve("raw gateway", settings.getRaw().getCredentialReference()));
}
Optional<RedisCredentialResolver.RedisCredentials> sentinel =
settings.getMode()
== dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode.SENTINEL
? resolver.resolve("sentinel", settings.getSentinel().getCredentialReference())
: Optional.empty();
LOG.info(
"Redis accounts resolved for roles {}{}",
accounts.keySet(),
sentinel.isPresent() ? " plus the Sentinel control account" : "");
return new RedisResolvedCredentials(accounts, sentinel);
}
private static void put(
Map<RedisCredentialRole, RedisCredentialResolver.RedisCredentials> accounts,
RedisCredentialRole role,
Optional<RedisCredentialResolver.RedisCredentials> resolved) {
resolved.ifPresent(credentials -> accounts.put(role, credentials));
}
private static RedisSecretSource environmentSecretSource() {
// The default reads the process environment, which is where a mounted secret lands. A
// deployment with a secret manager contributes its own RedisSecretSource bean.
return name -> Optional.ofNullable(System.getenv(name)).filter(value -> !value.isBlank());
}
/**
* Builds the one client the configured topology calls for.
*
* @param settings the validated settings
* @param credentials the resolved credentials
* @return the runtime client
*/
@Bean
public RedisRuntimeClient redisRuntimeClient(
RedisSdkSettings settings,
RedisResolvedCredentials credentials,
ResourceLoader resourceLoader) {
return new RedisTopologyClientFactory(
settings,
credentials.accounts(),
credentials.sentinel(),
location -> tlsMaterial(resourceLoader, location).getInputStream())
.create();
}
/**
* Resolves a TLS material location, whether it names a resource or a file.
*
* <p>Both spellings are ordinary. A CA bundled with the application is {@code
* classpath:redis/ca.pem}; a CA mounted by the platform is {@code /etc/ssl/redis/ca.pem}, and the
* mounted one is the more common of the two. Resolving everything as a file broke the first;
* handing everything to the resource loader breaks the second, because a location with no prefix
* is a <em>classpath</em> location to Spring — so {@code /etc/ssl/redis/ca.pem} would be looked
* up on the classpath and reported missing while sitting on disk.
*
* @param resourceLoader the context's resource loader
* @param location the configured location
* @return the resolved resource
*/
private static Resource tlsMaterial(ResourceLoader resourceLoader, String location) {
boolean prefixed =
location.startsWith(ResourceLoader.CLASSPATH_URL_PREFIX)
|| location.contains("://")
|| location.startsWith("file:");
return prefixed
? resourceLoader.getResource(location)
: new org.springframework.core.io.FileSystemResource(location);
}
/**
* Owns every connection and the order they are torn down in.
*
* <p>Destroyed by Spring, and destroyed before the client bean it wraps because it depends on it
* — which is the order shutdown needs: connections drain and close, then the event loop stops.
*
* @param client the runtime client
* @param settings the validated settings
* @return the lifecycle owner
*/
@Bean(destroyMethod = "close")
public RedisRuntimeOwner redisRuntimeOwner(RedisRuntimeClient client, RedisSdkSettings settings) {
Map<RedisConnectionKind, Integer> limits = new EnumMap<>(RedisConnectionKind.class);
limits.put(RedisConnectionKind.REGULAR, settings.getCapacity().getMaximumInFlightCommands());
limits.put(RedisConnectionKind.BLOCKING, settings.getBlocking().getMaxConnections());
limits.put(RedisConnectionKind.TRANSACTION, settings.getTransaction().getMaxConnections());
limits.put(RedisConnectionKind.SCRIPT, settings.getCapacity().getMaximumInFlightCommands());
limits.put(
RedisConnectionKind.PUBSUB, Math.max(1, settings.getPubsub().getBufferCapacity() / 64));
limits.put(RedisConnectionKind.ADMIN, settings.getAdmin().isEnabled() ? 2 : 1);
return new RedisRuntimeOwner(client, limits, settings.getLifecycle().getDrainTimeout());
}
/**
* The optional-Redis health contributor: a cache outage is detail, never unreadiness.
*
* <p>Bean name {@code redisOptional}, and deliberately outside the readiness group. Turning a pod
* unready because its cache is down removes capacity from a system that is already slower than
* usual, which is the opposite of what the outage needs.
*
* @param owner the runtime owner
* @param settings the validated settings
* @return the contributor
*/
@Bean(RedisCorrectnessRoles.OPTIONAL_HEALTH_CONTRIBUTOR)
public HealthIndicator redisOptional(RedisRuntimeOwner owner, RedisSdkSettings settings) {
RedisHealthContributor contributor =
new RedisHealthContributor(owner, settings.getTimeout().getFast());
return () -> {
RedisHealthContributor.RedisHealth health = contributor.probe();
return Health.status(health.reachable() ? Status.UP : DEGRADED)
.withDetails(health.detail())
.build();
};
}
/**
* The required-Redis health contributor, present only when a correctness role is bound.
*
* <p>Bean name {@code redisRequired}, and the readiness group names it. Session, idempotency,
* rate-limit and lease all depend on Redis for correctness rather than speed: serving traffic
* without them is worse than not serving it, so this one does flip readiness.
*
* <p>Conditional on a role actually selecting Redis. A deployment that runs Redis purely as a
* cache has no correctness role to gate on, and a required contributor there would make a cache
* outage an outage.
*
* @param owner the runtime owner
* @param settings the validated settings
* @return the contributor
*/
@Bean(RedisCorrectnessRoles.REQUIRED_HEALTH_CONTRIBUTOR)
@Conditional(RedisCorrectnessRoleBound.class)
public HealthIndicator redisRequired(RedisRuntimeOwner owner, RedisSdkSettings settings) {
RedisHealthContributor contributor =
new RedisHealthContributor(owner, settings.getTimeout().getFast());
return () -> {
RedisHealthContributor.RedisHealth health = contributor.probe();
return Health.status(health.reachable() ? Status.UP : Status.DOWN)
.withDetails(health.detail())
.build();
};
}
/**
* Present when a role that needs Redis for <em>correctness</em> selected it.
*
* <p>Derived from the role selectors rather than a separate flag, because a separate flag is a
* second thing to keep in sync — and the failure mode of forgetting it is a readiness probe that
* does not gate on a dependency the deployment cannot serve without.
*/
static final class RedisCorrectnessRoleBound implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
// The same predicate the readiness group's post-processor asks. Duplicating it here is what
// produced a group naming a contributor nothing could create.
return RedisCorrectnessRoles.anySelected(context.getEnvironment());
}
}
/**
* The account resolved for each role the configuration named.
*
* <p>A role that is absent from {@code accounts} has no account of its own and runs on the
* application client. That is a deployment decision, taken by not configuring one, rather than a
* default the SDK picks.
*
* @param accounts the resolved account per configured role
* @param sentinel the Sentinel control account, on a Sentinel deployment
*/
public record RedisResolvedCredentials(
Map<RedisCredentialRole, RedisCredentialResolver.RedisCredentials> accounts,
Optional<RedisCredentialResolver.RedisCredentials> sentinel) {
public RedisResolvedCredentials {
accounts = Map.copyOf(accounts);
}
}
/** Where a credential reference's value comes from. */
@FunctionalInterface
public interface RedisSecretSource {
/**
* Resolves a secret by name.
*
* @param name the secret name
* @return the value, or empty when the source does not have it
*/
Optional<String> resolve(String name);
}
/**
* The result of validating the Redis settings at startup.
*
* @param warnings settings that are within the guardrails but worth an operator's attention
*/
public record RedisSdkSettingsValidation(List<String> warnings) {
public RedisSdkSettingsValidation {
warnings = List.copyOf(warnings);
}
}
}
@@ -0,0 +1,943 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.config;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisKeyRules;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Typed configuration for the Redis SDK, bound from {@code app.redis.*}.
*
* <p>Defaults are the skeleton guardrails from the design. Validation is fail-closed and runs at
* startup: a setting that would make a guardrail meaningless — a non-zero database on Cluster, an
* unbounded block, a raw gateway without an allowlist — stops the context rather than degrading
* quietly at the first request.
*
* <p>This class carries no {@code @ConfigurationProperties} annotation on purpose. It is registered
* and bound only by {@link RedisSdkAutoConfiguration}, which exists only while {@code
* app.redis.enabled} is true. Annotating the class would put it back inside the application-wide
* {@code @ConfigurationPropertiesScan}, and a Redis-free deployment would once again bind Redis
* configuration — the exact defect this arrangement removes.
*/
public class RedisSdkSettings {
/** Hard ceiling above which a configured timeout is a startup failure. */
public static final Duration MAXIMUM_TIMEOUT = Duration.ofSeconds(30);
/** Timeout above which a configured fast profile produces a startup warning. */
public static final Duration FAST_TIMEOUT_WARNING_THRESHOLD = Duration.ofSeconds(5);
private boolean enabled;
private RedisDeploymentMode mode = RedisDeploymentMode.STANDALONE;
private List<String> nodes = new ArrayList<>(List.of("localhost:6379"));
private int database;
private boolean acknowledgedWriteLossAccepted;
private final Namespace namespace = new Namespace();
private final Timeouts timeout = new Timeouts();
private final Limits limits = new Limits();
private final Blocking blocking = new Blocking();
private final Transaction transaction = new Transaction();
private final Advanced advanced = new Advanced();
private final Authentication authentication = new Authentication();
private final Sentinel sentinel = new Sentinel();
private final Tls tls = new Tls();
private final Lifecycle lifecycle = new Lifecycle();
private final Cluster cluster = new Cluster();
private final Capacity capacity = new Capacity();
private final PubSub pubsub = new PubSub();
private final Raw raw = new Raw();
private final Admin admin = new Admin();
/**
* Validates every cross-field rule.
*
* @return non-fatal warnings; an empty list means the configuration is entirely within guardrails
* @throws IllegalStateException when a setting would disable a guardrail
*/
public List<String> validate() {
List<String> warnings = new ArrayList<>();
if (mode == RedisDeploymentMode.CLUSTER && database != 0) {
throw new IllegalStateException("Cluster supports database 0 only");
}
if (database < 0) {
throw new IllegalStateException("database index must not be negative");
}
if (nodes == null || nodes.isEmpty()) {
throw new IllegalStateException("at least one Redis node must be configured");
}
namespace.validate();
limits.validate();
validateTimeout("fast", timeout.getFast(), warnings);
validateTimeout("collection", timeout.getCollection(), warnings);
validateTimeout("script", timeout.getScript(), warnings);
validateTimeout("batch", timeout.getBatch(), warnings);
validateTimeout("admin", timeout.getAdmin(), warnings);
if (blocking.getMaxBlock().isZero() || blocking.getMaxBlock().isNegative()) {
throw new IllegalStateException("blocking commands must not be unbounded");
}
if (blocking.getMaxConnections() < 1 || transaction.getMaxConnections() < 1) {
throw new IllegalStateException("dedicated connection lanes need a positive ceiling");
}
if (mode == RedisDeploymentMode.SENTINEL
&& (sentinel.getMasterName() == null || sentinel.getMasterName().isBlank())) {
throw new IllegalStateException(
"a Sentinel deployment must name the monitored primary; without it the client cannot"
+ " resolve a primary at all, let alone follow a promotion");
}
if (tls.isEnabled() && !tls.isHostnameVerification()) {
warnings.add(
"TLS is enabled with hostname verification disabled, which accepts any certificate the"
+ " trust material signs, for any host");
}
lifecycle.validate();
capacity.validate();
cluster.validate();
pubsub.validate();
if (raw.isEnabled() && (raw.getPolicyResource() == null || raw.getPolicyResource().isBlank())) {
throw new IllegalStateException("the raw gateway requires an allowlist resource");
}
if (raw.isEnabled()
&& (raw.getCredentialReference() == null || raw.getCredentialReference().isBlank())) {
throw new IllegalStateException("the raw gateway requires its own credential reference");
}
if (admin.isEnabled()
&& (admin.getCredentialReference() == null || admin.getCredentialReference().isBlank())) {
throw new IllegalStateException("the admin plane requires its own credential reference");
}
if (!advanced.isEnabled() && !advanced.getPolicies().isEmpty()) {
throw new IllegalStateException(
"advanced permit policies are configured while advanced operations are disabled");
}
// Last, deliberately. A deployment with both a structural mistake and a missing credential
// should be told about the structural one first: it is the cheaper thing to be wrong about,
// and reporting "no credential" for a configuration that could never have connected anyway
// sends the operator to the wrong file.
authentication.validate(warnings);
return List.copyOf(warnings);
}
private static void validateTimeout(String name, Duration value, List<String> warnings) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalStateException(name + " timeout must be positive");
}
if (value.compareTo(MAXIMUM_TIMEOUT) > 0) {
throw new IllegalStateException(
name + " timeout must not exceed " + MAXIMUM_TIMEOUT.toSeconds() + "s");
}
if ("fast".equals(name) && value.compareTo(FAST_TIMEOUT_WARNING_THRESHOLD) > 0) {
warnings.add(
"fast timeout of " + value + " is far above the 500ms guardrail for single-key commands");
}
}
/** Namespace tokens applied to every key this process writes. */
public static class Namespace {
private String environment = "local";
private String service = "sample-service";
private String domain = "shared";
void validate() {
RedisKeyRules.requireToken("environment", environment);
RedisKeyRules.requireToken("service", service);
RedisKeyRules.requireToken("domain", domain);
}
public String getEnvironment() {
return environment;
}
public void setEnvironment(String environment) {
this.environment = environment;
}
public String getService() {
return service;
}
public void setService(String service) {
this.service = service;
}
public String getDomain() {
return domain;
}
public void setDomain(String domain) {
this.domain = domain;
}
}
/** Per-profile timeout guardrails. */
public static class Timeouts {
private Duration fast = Duration.ofMillis(500);
private Duration collection = Duration.ofSeconds(2);
private Duration script = Duration.ofSeconds(1);
private Duration batch = Duration.ofSeconds(2);
private Duration admin = Duration.ofSeconds(3);
public Duration getFast() {
return fast;
}
public void setFast(Duration fast) {
this.fast = fast;
}
public Duration getCollection() {
return collection;
}
public void setCollection(Duration collection) {
this.collection = collection;
}
public Duration getScript() {
return script;
}
public void setScript(Duration script) {
this.script = script;
}
public Duration getBatch() {
return batch;
}
public void setBatch(Duration batch) {
this.batch = batch;
}
public Duration getAdmin() {
return admin;
}
public void setAdmin(Duration admin) {
this.admin = admin;
}
}
/** Size and count ceilings enforced before Redis is called. */
public static class Limits {
private int maxKeyBytes = 512;
private long maxValueBytes = 1_048_576L;
private long maxStreamPayloadBytes = 262_144L;
private long maxHashFieldValueBytes = 524_288L;
private int maxCollectionElements = 1_000;
private int maxScanCount = 500;
private int maxBatchCommands = 500;
private long maxBatchRequestBytes = 4L * 1024 * 1024;
private long maxBatchReplyBytes = 16L * 1024 * 1024;
private int offlineQueueCommands = 1_000;
private long maxBitmapOffset = 10_000_000L;
void validate() {
if (maxKeyBytes < 1 || maxKeyBytes > RedisKeyRules.MAX_KEY_BYTES) {
throw new IllegalStateException(
"max-key-bytes must be in 1.." + RedisKeyRules.MAX_KEY_BYTES);
}
if (maxValueBytes < 1
|| maxStreamPayloadBytes < 1
|| maxHashFieldValueBytes < 1
|| maxCollectionElements < 1
|| maxScanCount < 1
|| maxBatchCommands < 1
|| maxBatchRequestBytes < 1
|| maxBatchReplyBytes < 1
|| offlineQueueCommands < 1
|| maxBitmapOffset < 1) {
throw new IllegalStateException("every Redis SDK limit must be positive");
}
}
public int getMaxKeyBytes() {
return maxKeyBytes;
}
public void setMaxKeyBytes(int maxKeyBytes) {
this.maxKeyBytes = maxKeyBytes;
}
public long getMaxValueBytes() {
return maxValueBytes;
}
public void setMaxValueBytes(long maxValueBytes) {
this.maxValueBytes = maxValueBytes;
}
public long getMaxStreamPayloadBytes() {
return maxStreamPayloadBytes;
}
public void setMaxStreamPayloadBytes(long maxStreamPayloadBytes) {
this.maxStreamPayloadBytes = maxStreamPayloadBytes;
}
public long getMaxHashFieldValueBytes() {
return maxHashFieldValueBytes;
}
public void setMaxHashFieldValueBytes(long maxHashFieldValueBytes) {
this.maxHashFieldValueBytes = maxHashFieldValueBytes;
}
public int getMaxCollectionElements() {
return maxCollectionElements;
}
public void setMaxCollectionElements(int maxCollectionElements) {
this.maxCollectionElements = maxCollectionElements;
}
public int getMaxScanCount() {
return maxScanCount;
}
public void setMaxScanCount(int maxScanCount) {
this.maxScanCount = maxScanCount;
}
public int getMaxBatchCommands() {
return maxBatchCommands;
}
public void setMaxBatchCommands(int maxBatchCommands) {
this.maxBatchCommands = maxBatchCommands;
}
public long getMaxBatchRequestBytes() {
return maxBatchRequestBytes;
}
public void setMaxBatchRequestBytes(long maxBatchRequestBytes) {
this.maxBatchRequestBytes = maxBatchRequestBytes;
}
public long getMaxBatchReplyBytes() {
return maxBatchReplyBytes;
}
public void setMaxBatchReplyBytes(long maxBatchReplyBytes) {
this.maxBatchReplyBytes = maxBatchReplyBytes;
}
public int getOfflineQueueCommands() {
return offlineQueueCommands;
}
public void setOfflineQueueCommands(int offlineQueueCommands) {
this.offlineQueueCommands = offlineQueueCommands;
}
public long getMaxBitmapOffset() {
return maxBitmapOffset;
}
public void setMaxBitmapOffset(long maxBitmapOffset) {
this.maxBitmapOffset = maxBitmapOffset;
}
}
/** Blocking lane ceilings. */
public static class Blocking {
private int maxConnections = 32;
private Duration maxBlock = Duration.ofSeconds(30);
public int getMaxConnections() {
return maxConnections;
}
public void setMaxConnections(int maxConnections) {
this.maxConnections = maxConnections;
}
public Duration getMaxBlock() {
return maxBlock;
}
public void setMaxBlock(Duration maxBlock) {
this.maxBlock = maxBlock;
}
}
/** Transaction lane ceilings. */
public static class Transaction {
private int maxConnections = 16;
public int getMaxConnections() {
return maxConnections;
}
public void setMaxConnections(int maxConnections) {
this.maxConnections = maxConnections;
}
}
/** Application authentication material, carried as references rather than values. */
public static class Authentication {
private String credentialReference;
private String advancedCredentialReference;
private String pubsubCredentialReference;
private boolean anonymousAccessAccepted;
void validate(List<String> warnings) {
if (credentialReference == null || credentialReference.isBlank()) {
if (!anonymousAccessAccepted) {
throw new IllegalStateException(
"Redis is enabled but no application credential reference is configured. Booting"
+ " anyway builds an unauthenticated client, which on any deployment that"
+ " disabled the `default` ACL user cannot run a single command — the failure"
+ " simply moves from startup to the first request, where it looks like an"
+ " outage instead of a missing setting. Set"
+ " app.redis.authentication.credential-reference, or declare the trade with"
+ " app.redis.authentication.anonymous-access-accepted=true.");
}
warnings.add(
"Redis is running without credentials because"
+ " app.redis.authentication.anonymous-access-accepted is true; every command runs"
+ " as the `default` ACL user");
}
if (advancedCredentialReference == null || advancedCredentialReference.isBlank()) {
// Not a failure: one account is a legitimate deployment. But it is worth saying out loud,
// because it means the account that reads cache entries can also execute scripts.
warnings.add(
"no advanced credential reference is configured, so registered scripts run as the"
+ " application account — that account therefore needs SCRIPT LOAD and EVALSHA,"
+ " and every code path that reaches a regular connection has them too");
}
}
/**
* Reports whether this deployment declared that it accepts running Redis unauthenticated.
*
* <p>Leave this false. It exists so that a local single-container Redis is a one-line
* declaration rather than a reason to weaken the check for everybody, and so that the
* declaration is visible in the deployment's own configuration.
*
* @return {@code true} when anonymous access is accepted
*/
public boolean isAnonymousAccessAccepted() {
return anonymousAccessAccepted;
}
public void setAnonymousAccessAccepted(boolean anonymousAccessAccepted) {
this.anonymousAccessAccepted = anonymousAccessAccepted;
}
public String getCredentialReference() {
return credentialReference;
}
public void setCredentialReference(String credentialReference) {
this.credentialReference = credentialReference;
}
public String getAdvancedCredentialReference() {
return advancedCredentialReference;
}
public void setAdvancedCredentialReference(String advancedCredentialReference) {
this.advancedCredentialReference = advancedCredentialReference;
}
public String getPubsubCredentialReference() {
return pubsubCredentialReference;
}
public void setPubsubCredentialReference(String pubsubCredentialReference) {
this.pubsubCredentialReference = pubsubCredentialReference;
}
}
/** Sentinel discovery. */
public static class Sentinel {
private String masterName;
private List<String> nodes = new ArrayList<>();
private String credentialReference;
public String getMasterName() {
return masterName;
}
public void setMasterName(String masterName) {
this.masterName = masterName;
}
public List<String> getNodes() {
return nodes;
}
public void setNodes(List<String> nodes) {
this.nodes = nodes;
}
public String getCredentialReference() {
return credentialReference;
}
public void setCredentialReference(String credentialReference) {
this.credentialReference = credentialReference;
}
}
/** Transport security. */
public static class Tls {
private boolean enabled;
private boolean hostnameVerification = true;
private String trustMaterialResource;
private String clientCertificateResource;
private String clientKeyReference;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public boolean isHostnameVerification() {
return hostnameVerification;
}
public void setHostnameVerification(boolean hostnameVerification) {
this.hostnameVerification = hostnameVerification;
}
public String getTrustMaterialResource() {
return trustMaterialResource;
}
public void setTrustMaterialResource(String trustMaterialResource) {
this.trustMaterialResource = trustMaterialResource;
}
public String getClientCertificateResource() {
return clientCertificateResource;
}
public void setClientCertificateResource(String clientCertificateResource) {
this.clientCertificateResource = clientCertificateResource;
}
public String getClientKeyReference() {
return clientKeyReference;
}
public void setClientKeyReference(String clientKeyReference) {
this.clientKeyReference = clientKeyReference;
}
}
/** Client lifecycle timings. */
public static class Lifecycle {
private String clientName = "ca-skeleton";
private Duration connectTimeout = Duration.ofSeconds(2);
private Duration tlsHandshakeTimeout = Duration.ofSeconds(3);
private Duration acquireTimeout = Duration.ofSeconds(2);
private Duration shutdownQuietPeriod = Duration.ofMillis(100);
private Duration shutdownTimeout = Duration.ofSeconds(3);
private Duration drainTimeout = Duration.ofSeconds(6);
void validate() {
if (clientName == null || clientName.isBlank()) {
throw new IllegalStateException("the client name must not be blank");
}
requirePositive("connect", connectTimeout);
requirePositive("tls-handshake", tlsHandshakeTimeout);
requirePositive("acquire", acquireTimeout);
requirePositive("shutdown", shutdownTimeout);
requirePositive("drain", drainTimeout);
if (shutdownQuietPeriod == null || shutdownQuietPeriod.isNegative()) {
throw new IllegalStateException("the shutdown quiet period must not be negative");
}
if (shutdownQuietPeriod.compareTo(shutdownTimeout) > 0) {
throw new IllegalStateException(
"the shutdown quiet period must not exceed the shutdown timeout, or shutdown can never"
+ " complete within its own budget");
}
}
private static void requirePositive(String name, Duration value) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalStateException(name + " timeout must be positive");
}
}
public String getClientName() {
return clientName;
}
public void setClientName(String clientName) {
this.clientName = clientName;
}
public Duration getConnectTimeout() {
return connectTimeout;
}
public void setConnectTimeout(Duration connectTimeout) {
this.connectTimeout = connectTimeout;
}
public Duration getTlsHandshakeTimeout() {
return tlsHandshakeTimeout;
}
public void setTlsHandshakeTimeout(Duration tlsHandshakeTimeout) {
this.tlsHandshakeTimeout = tlsHandshakeTimeout;
}
public Duration getAcquireTimeout() {
return acquireTimeout;
}
public void setAcquireTimeout(Duration acquireTimeout) {
this.acquireTimeout = acquireTimeout;
}
public Duration getShutdownQuietPeriod() {
return shutdownQuietPeriod;
}
public void setShutdownQuietPeriod(Duration shutdownQuietPeriod) {
this.shutdownQuietPeriod = shutdownQuietPeriod;
}
public Duration getShutdownTimeout() {
return shutdownTimeout;
}
public void setShutdownTimeout(Duration shutdownTimeout) {
this.shutdownTimeout = shutdownTimeout;
}
public Duration getDrainTimeout() {
return drainTimeout;
}
public void setDrainTimeout(Duration drainTimeout) {
this.drainTimeout = drainTimeout;
}
}
/** Cluster routing. */
public static class Cluster {
private int maximumRedirects = 5;
private Duration topologyRefreshPeriod = Duration.ofSeconds(30);
void validate() {
if (maximumRedirects < 1) {
throw new IllegalStateException("cluster maximum redirects must be positive");
}
if (topologyRefreshPeriod == null
|| topologyRefreshPeriod.isZero()
|| topologyRefreshPeriod.isNegative()) {
throw new IllegalStateException("the cluster topology refresh period must be positive");
}
}
public int getMaximumRedirects() {
return maximumRedirects;
}
public void setMaximumRedirects(int maximumRedirects) {
this.maximumRedirects = maximumRedirects;
}
public Duration getTopologyRefreshPeriod() {
return topologyRefreshPeriod;
}
public void setTopologyRefreshPeriod(Duration topologyRefreshPeriod) {
this.topologyRefreshPeriod = topologyRefreshPeriod;
}
}
/** In-flight capacity ceilings. */
public static class Capacity {
private int maximumInFlightCommands = 64;
private long maximumInFlightBytes = 4L * 1024 * 1024;
private long maximumReplyBytes = 16L * 1024 * 1024;
private boolean rejectWhenDisconnected = true;
void validate() {
if (maximumInFlightCommands < 1 || maximumInFlightBytes < 1 || maximumReplyBytes < 1) {
throw new IllegalStateException("every Redis capacity ceiling must be positive");
}
}
public int getMaximumInFlightCommands() {
return maximumInFlightCommands;
}
public void setMaximumInFlightCommands(int maximumInFlightCommands) {
this.maximumInFlightCommands = maximumInFlightCommands;
}
public long getMaximumInFlightBytes() {
return maximumInFlightBytes;
}
public void setMaximumInFlightBytes(long maximumInFlightBytes) {
this.maximumInFlightBytes = maximumInFlightBytes;
}
public long getMaximumReplyBytes() {
return maximumReplyBytes;
}
public void setMaximumReplyBytes(long maximumReplyBytes) {
this.maximumReplyBytes = maximumReplyBytes;
}
/**
* Reports whether a command issued while the connection is down is refused rather than queued.
*
* <p>Leave this true. Lettuce's default is to hold commands in an offline queue and replay them
* on reconnect, which turns a five-second outage into a burst of writes whose ordering relative
* to everything that happened during the outage is arbitrary.
*
* @return {@code true} when a disconnected client refuses commands
*/
public boolean isRejectWhenDisconnected() {
return rejectWhenDisconnected;
}
public void setRejectWhenDisconnected(boolean rejectWhenDisconnected) {
this.rejectWhenDisconnected = rejectWhenDisconnected;
}
}
/** Subscription delivery. */
public static class PubSub {
private int bufferCapacity = 1_024;
private String overflowPolicy = "error";
void validate() {
if (bufferCapacity < 1) {
throw new IllegalStateException("the pub/sub buffer capacity must be positive");
}
if (!List.of("error", "drop-oldest", "drop-latest").contains(overflowPolicy)) {
throw new IllegalStateException(
"the pub/sub overflow policy must be error, drop-oldest, or drop-latest");
}
}
public int getBufferCapacity() {
return bufferCapacity;
}
public void setBufferCapacity(int bufferCapacity) {
this.bufferCapacity = bufferCapacity;
}
public String getOverflowPolicy() {
return overflowPolicy;
}
public void setOverflowPolicy(String overflowPolicy) {
this.overflowPolicy = overflowPolicy;
}
}
/** Advanced R2 exposure. */
public static class Advanced {
private boolean enabled;
private List<String> policies = new ArrayList<>();
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public List<String> getPolicies() {
return policies;
}
public void setPolicies(List<String> policies) {
this.policies = policies;
}
}
/** Approved raw gateway exposure. */
public static class Raw {
private boolean enabled;
private String policyResource = "classpath:redis-sdk/raw-command-allowlist.yml";
private String credentialReference;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getPolicyResource() {
return policyResource;
}
public void setPolicyResource(String policyResource) {
this.policyResource = policyResource;
}
public String getCredentialReference() {
return credentialReference;
}
public void setCredentialReference(String credentialReference) {
this.credentialReference = credentialReference;
}
}
/** Isolated admin plane exposure. */
public static class Admin {
private boolean enabled;
private String credentialReference;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getCredentialReference() {
return credentialReference;
}
public void setCredentialReference(String credentialReference) {
this.credentialReference = credentialReference;
}
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public RedisDeploymentMode getMode() {
return mode;
}
public void setMode(RedisDeploymentMode mode) {
this.mode = mode;
}
public List<String> getNodes() {
return nodes;
}
public void setNodes(List<String> nodes) {
this.nodes = nodes;
}
public int getDatabase() {
return database;
}
public void setDatabase(int database) {
this.database = database;
}
/**
* Reports whether this deployment has declared that it accepts losing acknowledged writes.
*
* <p>Leave this false unless the trade is deliberate. A replicated deployment without {@code
* min-replicas-to-write} discards writes it told the caller had succeeded, and no client-side
* signal exists for it; see {@link RedisCapabilityProbe#requireWriteDurability}.
*
* @return {@code true} when the loss is accepted
*/
public boolean isAcknowledgedWriteLossAccepted() {
return acknowledgedWriteLossAccepted;
}
public void setAcknowledgedWriteLossAccepted(boolean acknowledgedWriteLossAccepted) {
this.acknowledgedWriteLossAccepted = acknowledgedWriteLossAccepted;
}
public Namespace getNamespace() {
return namespace;
}
public Timeouts getTimeout() {
return timeout;
}
public Limits getLimits() {
return limits;
}
public Blocking getBlocking() {
return blocking;
}
public Transaction getTransaction() {
return transaction;
}
public Advanced getAdvanced() {
return advanced;
}
public Raw getRaw() {
return raw;
}
public Admin getAdmin() {
return admin;
}
public Authentication getAuthentication() {
return authentication;
}
public Sentinel getSentinel() {
return sentinel;
}
public Tls getTls() {
return tls;
}
public Lifecycle getLifecycle() {
return lifecycle;
}
public Cluster getCluster() {
return cluster;
}
public Capacity getCapacity() {
return capacity;
}
public PubSub getPubsub() {
return pubsub;
}
}
@@ -0,0 +1,142 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.config;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisCapabilities;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisCapability;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisVersion;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
/**
* Asks the server what it is, once, at startup.
*
* <p>Configuration says what the deployment intends; only the server says what is true. The version
* a managed Redis advertises does not imply the modules are present, and a replicated deployment's
* write durability is a server setting no client can compensate for. Both are cheap to ask and
* expensive to discover later — a missing capability found at the first request is an outage, found
* here it is a failed deploy.
*
* <p>This type holds no connection. It takes the three facts as inputs so the same logic is
* exercised by unit tests and by the real lanes, and so the caller decides which account asks —
* {@code INFO} and {@code CONFIG GET} are admin-plane, and the application account is denied them.
*/
public final class RedisStartupProbe {
private final RedisCapabilityProbe probe;
/**
* Creates the probe.
*
* @param probe the capability probe
*/
public RedisStartupProbe(RedisCapabilityProbe probe) {
this.probe = Objects.requireNonNull(probe, "capability probe must be non-null");
}
/**
* Confirms the server matches what the deployment declared.
*
* @param settings the validated settings
* @param serverFacts what the server reported
* @param requiredCapabilities capabilities the deployment explicitly enabled
* @return the confirmed capability snapshot
*/
public RedisCapabilities confirm(
RedisSdkSettings settings,
ServerFacts serverFacts,
Collection<RedisCapability> requiredCapabilities) {
Objects.requireNonNull(settings, "settings must be non-null");
Objects.requireNonNull(serverFacts, "server facts must be non-null");
RedisCapabilities capabilities =
probe.probe(
serverFacts.version(),
settings.getMode(),
settings.getDatabase(),
commandPresence(serverFacts.commands()),
requiredCapabilities);
probe.requireWriteDurability(
settings.getMode(),
serverFacts.minReplicasToWrite(),
serverFacts.minReplicasMaxLagSeconds(),
settings.isAcknowledgedWriteLossAccepted());
return capabilities;
}
private static Predicate<CommandId> commandPresence(Set<String> reported) {
// COMMAND INFO answers with the top-level command name; a subcommand's presence follows from
// its container. Matching on the family keeps "does the server have FT.SEARCH" answerable
// without asking the server about every subcommand the catalog knows.
return commandId -> reported.contains(commandId.family().toLowerCase(java.util.Locale.ROOT));
}
/**
* What the server reported at startup.
*
* @param version the version from {@code INFO server}
* @param commands the command names from {@code COMMAND LIST}, lowercased
* @param minReplicasToWrite the server's {@code min-replicas-to-write}
* @param minReplicasMaxLagSeconds the server's {@code min-replicas-max-lag}
*/
public record ServerFacts(
RedisVersion version,
Set<String> commands,
int minReplicasToWrite,
int minReplicasMaxLagSeconds) {
public ServerFacts {
Objects.requireNonNull(version, "version must be non-null");
commands = Set.copyOf(commands);
}
/**
* Reads the facts out of the raw replies, so parsing lives beside the contract it feeds.
*
* @param infoServer the {@code INFO server} payload
* @param commandNames the command names the server reports
* @param configuration the {@code CONFIG GET} projection
* @return the parsed facts
*/
public static ServerFacts from(
String infoServer, List<String> commandNames, Map<String, String> configuration) {
RedisVersion version = null;
for (String line : infoServer.lines().toList()) {
if (line.startsWith("redis_version:")) {
version = RedisVersion.parse(line.substring("redis_version:".length()).strip());
}
}
if (version == null) {
throw new IllegalStateException(
"the server did not report a version; the SDK will not guess one, because every"
+ " capability decision below depends on it");
}
return new ServerFacts(
version,
commandNames.stream()
.map(name -> name.toLowerCase(java.util.Locale.ROOT))
.collect(java.util.stream.Collectors.toUnmodifiableSet()),
intOf(configuration, "min-replicas-to-write"),
intOf(configuration, "min-replicas-max-lag"));
}
private static int intOf(Map<String, String> configuration, String key) {
String value = configuration.get(key);
if (value == null || value.isBlank()) {
// Absent is not zero. A deployment whose admin account cannot read the setting has not
// proven the guarantee, and treating "unknown" as "unset" would fail a correctly
// configured server while treating it as "set" would pass an incorrectly configured one.
// Failing is the safe direction: the message says exactly which grant is missing.
throw new IllegalStateException(
"the server did not report '"
+ key
+ "'. Write durability cannot be confirmed without it; grant the admin account"
+ " +config|get, or set app.redis.acknowledged-write-loss-accepted=true to record"
+ " that this deployment accepts losing acknowledged writes.");
}
return Integer.parseInt(value.strip());
}
}
}
@@ -0,0 +1,321 @@
package dev.caskeleton.adapter.outbound.cache.redis.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisNamespace;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeClient;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.InMemoryGatewayAccess;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
import dev.caskeleton.shared.ratelimit.RateLimitAlgorithm;
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
import dev.caskeleton.shared.ratelimit.RateLimitEvaluationDedupPolicy;
import dev.caskeleton.shared.ratelimit.RateLimitFailurePolicy;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.ratelimit.RateLimitPolicy;
import dev.caskeleton.shared.ratelimit.RateLimitRequest;
import dev.caskeleton.shared.ratelimit.RateParameters;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.EnumMap;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* The provider-neutral rate limit, on Redis, without a Redis in sight from the caller's side.
*
* <p>Two things are being asserted. The algorithms bound what they claim to bound — that is the
* feature. And every failure path is closed — that is the safety property, and the one a limiter
* gets wrong in the direction that matters: allowing traffic when the store is unreachable removes
* the bound at exactly the moment the bound is load-bearing.
*/
class RedisEdgeRateLimitAdapterTest {
private static final Instant T0 = Instant.parse("2026-08-10T12:00:00Z");
// The contract bounds both identifiers by pattern. The adapter must satisfy them, not relax them:
// a subject digest short enough to collide, or an evaluation id without a generation prefix, are
// exactly what those patterns exist to keep out of a shared keyspace.
private static final String SUBJECT_A = "aaaaaaaaaaaaaaaa0000";
private static final String SUBJECT_B = "bbbbbbbbbbbbbbbb1111";
private static final String EVALUATION_ID = "ev1:AAAAAAAAAAAAAAAAAAAAAA";
private final InMemoryGatewayAccess gateway = InMemoryGatewayAccess.create();
private final RateLimitKeys keys =
new RateLimitKeys(new RedisNamespace("prod", "ca-skeleton", "shared"), 1);
private RedisEdgeRateLimitAdapter adapter(RateLimitPolicy policy, Clock clock) {
return adapter(policy, clock, new StubClient(gateway.gateway()));
}
private RedisEdgeRateLimitAdapter adapter(
RateLimitPolicy policy, Clock clock, RedisRuntimeClient client) {
Map<RedisConnectionKind, Integer> limits = new EnumMap<>(RedisConnectionKind.class);
for (RedisConnectionKind kind : RedisConnectionKind.values()) {
limits.put(kind, 4);
}
return new RedisEdgeRateLimitAdapter(
new RedisRuntimeOwner(client, limits, Duration.ofSeconds(1)),
keys,
Map.of(policy.policyId(), policy),
new RateLimitScripts(),
clock,
Duration.ofSeconds(2),
Duration.ofMillis(100));
}
private static RateLimitPolicy policy(RateParameters parameters, RateLimitAlgorithm algorithm) {
return new RateLimitPolicy(
"api-default",
"v1",
algorithm,
parameters,
// The contract caps a single request's cost at the algorithm's own budget: a cost that can
// never be satisfied is a configuration error, not a permanently denied caller.
maximumCostOf(parameters),
Duration.ofSeconds(5),
Duration.ofMillis(250),
RateLimitFailurePolicy.FAIL_CLOSED,
new RateLimitEvaluationDedupPolicy(false, Duration.ZERO, 0, 0));
}
private static long maximumCostOf(RateParameters parameters) {
return switch (parameters) {
case RateParameters.FixedWindow window -> window.limit();
case RateParameters.SlidingCounter sliding -> sliding.limit();
case RateParameters.TokenBucket bucket -> bucket.capacity();
default -> throw new IllegalStateException("unsupported parameters");
};
}
private static RateLimitRequest request(long cost) {
return new RateLimitRequest(
"api-default", SUBJECT_A, cost, EVALUATION_ID, T0.plusSeconds(3600));
}
@Test
@DisplayName("a fixed window allows up to its limit and then denies with a positive wait")
void aFixedWindowBoundsItsWindow() {
RateLimitPolicy policy =
policy(
new RateParameters.FixedWindow(3, Duration.ofSeconds(60)),
RateLimitAlgorithm.FIXED_WINDOW);
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
for (int allowed = 0; allowed < 3; allowed++) {
assertThat(decision(adapter.evaluate(request(1))).allowed())
.as("request %s of the budget", allowed + 1)
.isTrue();
}
RateLimitDecision denied = decision(adapter.evaluate(request(1)));
assertThat(denied.allowed()).isFalse();
assertThat(denied.remaining()).isZero();
assertThat(denied.retryAfter()).isPositive();
assertThat(denied.source()).isEqualTo(RateLimitDecision.DecisionSource.GLOBAL_REDIS);
assertThat(denied.certainty()).isEqualTo(RateLimitDecision.DecisionCertainty.CERTAIN);
}
@Test
@DisplayName("a new window restores the budget")
void anewWindowRestoresTheBudget() {
RateLimitPolicy policy =
policy(
new RateParameters.FixedWindow(2, Duration.ofSeconds(60)),
RateLimitAlgorithm.FIXED_WINDOW);
RedisEdgeRateLimitAdapter spent = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
spent.evaluate(request(1));
spent.evaluate(request(1));
assertThat(decision(spent.evaluate(request(1))).allowed()).isFalse();
RedisEdgeRateLimitAdapter next =
adapter(policy, Clock.fixed(T0.plusSeconds(60), ZoneOffset.UTC));
assertThat(decision(next.evaluate(request(1))).allowed()).isTrue();
}
@Test
@DisplayName("a sliding counter reports itself as approximate")
void aSlidingCounterIsHonestAboutBeingApproximate() {
RateLimitPolicy policy =
policy(
new RateParameters.SlidingCounter(5, Duration.ofSeconds(60)),
RateLimitAlgorithm.SLIDING_COUNTER);
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
// Interpolating across two windows is a deliberate memory trade, and the caller is told, since
// "approximate" and "certain" are different things to build an abuse decision on.
assertThat(decision(adapter.evaluate(request(1))).certainty())
.isEqualTo(RateLimitDecision.DecisionCertainty.APPROXIMATE_ALGORITHM);
}
@Test
@DisplayName("a token bucket refills by elapsed periods, not by wall-clock jumps")
void aTokenBucketRefillsByPeriod() {
RateLimitPolicy policy =
policy(
new RateParameters.TokenBucket(2, 1, Duration.ofSeconds(10)),
RateLimitAlgorithm.TOKEN_BUCKET);
RedisEdgeRateLimitAdapter start = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
assertThat(decision(start.evaluate(request(1))).allowed()).isTrue();
assertThat(decision(start.evaluate(request(1))).allowed()).isTrue();
assertThat(decision(start.evaluate(request(1))).allowed()).isFalse();
// Half a period buys nothing; a whole one buys exactly one token.
RedisEdgeRateLimitAdapter halfway =
adapter(policy, Clock.fixed(T0.plusSeconds(5), ZoneOffset.UTC));
assertThat(decision(halfway.evaluate(request(1))).allowed()).isFalse();
RedisEdgeRateLimitAdapter refilled =
adapter(policy, Clock.fixed(T0.plusSeconds(10), ZoneOffset.UTC));
assertThat(decision(refilled.evaluate(request(1))).allowed()).isTrue();
assertThat(decision(refilled.evaluate(request(1))).allowed()).isFalse();
}
@Test
@DisplayName("an unreachable Redis denies rather than allows")
void anUnreachableRedisFailsClosed() {
RateLimitPolicy policy =
policy(
new RateParameters.FixedWindow(5, Duration.ofSeconds(60)),
RateLimitAlgorithm.FIXED_WINDOW);
RedisEdgeRateLimitAdapter adapter =
adapter(policy, Clock.fixed(T0, ZoneOffset.UTC), new BrokenClient());
RateLimitOutcome outcome = adapter.evaluate(request(1));
// Never Evaluated(allowed). A limiter that opens up during an outage is not a limiter, and the
// outage is exactly when the bound matters.
assertThat(outcome).isInstanceOf(RateLimitOutcome.Unavailable.class);
assertThat(((RateLimitOutcome.Unavailable) outcome).retryAfter()).isPositive();
}
@Test
@DisplayName("an unknown policy is a deployment error, not an allowance")
void anUnknownPolicyIsIncompatible() {
RateLimitPolicy policy =
policy(
new RateParameters.FixedWindow(5, Duration.ofSeconds(60)),
RateLimitAlgorithm.FIXED_WINDOW);
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
RateLimitOutcome outcome =
adapter.evaluate(
new RateLimitRequest(
"other-policy", SUBJECT_A, 1, EVALUATION_ID, T0.plusSeconds(3600)));
assertThat(outcome).isInstanceOf(RateLimitOutcome.Incompatible.class);
}
@Test
@DisplayName("a cost above the policy ceiling is refused rather than clamped")
void anOversizedCostIsRefused() {
RateLimitPolicy policy =
policy(
new RateParameters.FixedWindow(5, Duration.ofSeconds(60)),
RateLimitAlgorithm.FIXED_WINDOW);
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
assertThat(adapter.evaluate(request(6))).isInstanceOf(RateLimitOutcome.Incompatible.class);
}
@Test
@DisplayName("a caller whose deadline already passed is told now, not after a round trip")
void anExpiredCallerDeadlineIsRejectedImmediately() {
RateLimitPolicy policy =
policy(
new RateParameters.FixedWindow(5, Duration.ofSeconds(60)),
RateLimitAlgorithm.FIXED_WINDOW);
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
RateLimitOutcome outcome =
adapter.evaluate(
new RateLimitRequest("api-default", SUBJECT_A, 1, EVALUATION_ID, T0.minusMillis(1)));
assertThat(outcome).isInstanceOf(RateLimitOutcome.Unavailable.class);
assertThat(((RateLimitOutcome.Unavailable) outcome).category())
.isEqualTo(RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED);
}
@Test
@DisplayName("different subjects have separate budgets")
void subjectsAreIsolated() {
RateLimitPolicy policy =
policy(
new RateParameters.FixedWindow(1, Duration.ofSeconds(60)),
RateLimitAlgorithm.FIXED_WINDOW);
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
assertThat(decision(adapter.evaluate(request(1))).allowed()).isTrue();
assertThat(decision(adapter.evaluate(request(1))).allowed()).isFalse();
assertThat(
decision(
adapter.evaluate(
new RateLimitRequest(
"api-default", SUBJECT_B, 1, EVALUATION_ID, T0.plusSeconds(3600))))
.allowed())
.isTrue();
}
private static RateLimitDecision decision(RateLimitOutcome outcome) {
assertThat(outcome).isInstanceOf(RateLimitOutcome.Evaluated.class);
return ((RateLimitOutcome.Evaluated) outcome).decision();
}
/** Hands out the shared in-memory gateway; the owner's pooling makes it one logical server. */
private record StubClient(RedisCommandGateway gateway) implements RedisRuntimeClient {
@Override
public RedisDeploymentMode mode() {
return RedisDeploymentMode.STANDALONE;
}
@Override
public RedisLaneConnection openLane(
RedisConnectionKind kind, java.util.Optional<byte[]> routingKey) {
return new RedisLaneConnection() {
@Override
public RedisCommandGateway gateway() {
return gateway;
}
@Override
public boolean open() {
return true;
}
@Override
public void close() {}
};
}
@Override
public void close() {}
}
/** A server that cannot be reached at all. */
private static final class BrokenClient implements RedisRuntimeClient {
@Override
public RedisDeploymentMode mode() {
return RedisDeploymentMode.STANDALONE;
}
@Override
public RedisLaneConnection openLane(
RedisConnectionKind kind, java.util.Optional<byte[]> routingKey) {
throw new IllegalStateException("the server is unreachable");
}
@Override
public void close() {}
}
}
@@ -0,0 +1,244 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode;
import io.lettuce.core.RedisURI;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
/**
* The endpoint a topology lane was pointed at.
*
* <p>Resolution fails rather than returning a default. A topology test that quietly falls back to
* {@code localhost:6379} either tests the wrong thing or passes because nothing answered, and both
* are indistinguishable from success in a CI log.
*
* <p>The endpoint is not always the data node. On the Sentinel lane the declared address is a
* <em>sentinel</em>, and the primary has to be resolved from it — which is the whole point of the
* lane, because the address the client dials changes when the primary is promoted. {@link
* #dataUri()} is therefore the only supported way to reach data: a test that builds its own URI
* from {@link #host()} and {@link #port()} would connect to a sentinel and then assert against the
* wrong server.
*
* @param host the declared host
* @param port the declared port
* @param mode the deployment mode the lane represents
* @param masterId the monitored primary's name, present on the Sentinel lane only
* @param username the ACL account the data connection authenticates as
* @param password that account's password
* @param trustMaterial the CA the lane's server certificate is signed by, on the TLS lane only
*/
public record RedisTopologyEndpoint(
String host,
int port,
RedisDeploymentMode mode,
Optional<String> masterId,
String username,
String password,
Optional<String> trustMaterial) {
/** Canonical constructor. */
public RedisTopologyEndpoint {
Objects.requireNonNull(host, "host must be non-null");
Objects.requireNonNull(mode, "mode must be non-null");
Objects.requireNonNull(masterId, "master identifier must be non-null");
if (host.isBlank()) {
throw new IllegalArgumentException("the topology host must not be blank");
}
if (port < 1 || port > 65_535) {
throw new IllegalArgumentException("the topology port must be a valid port number");
}
Objects.requireNonNull(username, "username must be non-null");
Objects.requireNonNull(password, "password must be non-null");
Objects.requireNonNull(trustMaterial, "trust material must be non-null");
if (username.isBlank()) {
throw new IllegalArgumentException(
"the lane must authenticate as a named ACL account; the fixture disables the default"
+ " user precisely so an unauthenticated connection cannot pass for a working one");
}
if (mode == RedisDeploymentMode.SENTINEL && masterId.isEmpty()) {
throw new IllegalArgumentException(
"the sentinel lane needs the monitored primary's name; without it the client cannot"
+ " resolve a primary at all, let alone follow a promotion");
}
}
/**
* Resolves the endpoint from the system properties the lane sets.
*
* @return the endpoint
* @throws IllegalStateException when the lane was selected without an endpoint
*/
public static RedisTopologyEndpoint fromSystemProperties() {
RedisDeploymentMode mode =
RedisDeploymentMode.valueOf(require("redis.topology.mode").toUpperCase(Locale.ROOT));
Optional<String> masterId =
mode == RedisDeploymentMode.SENTINEL
? Optional.of(require("redis.topology.master"))
: Optional.ofNullable(System.getProperty("redis.topology.master"))
.filter(value -> !value.isBlank());
return new RedisTopologyEndpoint(
require("redis.topology.host"),
Integer.parseInt(require("redis.topology.port")),
mode,
masterId,
// The lane's ACL fixture disables `default`, so every data connection authenticates as a
// named account. Defaulting to the application account keeps the common case one flag
// shorter while still going through AUTH — which is what the qualification has to prove.
System.getProperty("redis.topology.username", "ca-skeleton-application"),
// The fixture accounts carry real passwords. They were `nopass`, which accepts any
// password at all — so every assertion about authentication passed for the same reason a
// wrong password would have, and rotation and wrong-password coverage was false-green.
System.getProperty("redis.topology.password", "fixture-application"),
// Present exactly on the TLS lane. The build passes it, so a lane that forgot it fails in
// the task rather than by silently connecting without verification.
Optional.ofNullable(System.getProperty("redis.topology.trust-material"))
.filter(value -> !value.isBlank()));
}
/**
* Reports whether this lane speaks TLS.
*
* @return {@code true} when the lane's server has no plaintext port at all
*/
public boolean tls() {
return Boolean.parseBoolean(System.getProperty("redis.topology.tls", "false"));
}
/**
* Returns the CA the lane's server certificate is signed by.
*
* @return the configured trust material location
* @throws IllegalStateException when the lane is not a TLS lane
*/
public String requireTrustMaterial() {
return trustMaterial.orElseThrow(
() -> new IllegalStateException("only the TLS lane declares trust material"));
}
/**
* Returns the URI a data connection must be opened with.
*
* @return a sentinel-resolving URI on the Sentinel lane, the declared address otherwise
*/
public RedisURI dataUri() {
RedisURI uri =
switch (mode) {
case SENTINEL -> RedisURI.Builder.sentinel(host, port, masterId.orElseThrow()).build();
case STANDALONE, CLUSTER -> RedisURI.create(host, port);
};
uri.setCredentialsProvider(
io.lettuce.core.RedisCredentialsProvider.from(
() -> io.lettuce.core.RedisCredentials.just(username, password.toCharArray())));
return uri;
}
/**
* Returns the URI a diagnostic connection must be opened with.
*
* <p>Separate from {@link #dataUri()} because the accounts are separate, and deliberately so. The
* application account cannot run {@code INFO} — the ACL fixture denies it, exactly as the SDK's
* own admin plane models it — so a lane that probes the server version over the data connection
* gets {@code NOPERM}. That is the fixture working, not a fixture bug: reaching diagnostics
* requires holding the admin account.
*
* @return the declared address, authenticated as the read-only admin account
*/
public RedisURI adminUri() {
RedisURI uri =
switch (mode) {
case SENTINEL -> RedisURI.Builder.sentinel(host, port, masterId.orElseThrow()).build();
case STANDALONE, CLUSTER -> RedisURI.create(host, port);
};
uri.setCredentialsProvider(
io.lettuce.core.RedisCredentialsProvider.from(
() ->
io.lettuce.core.RedisCredentials.just(
System.getProperty(
"redis.topology.admin-username", "ca-skeleton-admin-readonly"),
System.getProperty("redis.topology.admin-password", "fixture-admin")
.toCharArray())));
return uri;
}
/**
* Returns the URI a Sentinel control connection must be opened with.
*
* <p>Deliberately credential-free, and that is not an oversight. A sentinel is a different
* process with its own ACL: it does not load the data nodes' {@code aclfile}, so the accounts in
* {@code infra/redis-sdk/acl} do not exist there and presenting one gets {@code WRONGPASS}. The
* {@code sentinel auth-user} / {@code auth-pass} directives in the lane are about how the
* sentinel authenticates <em>to the monitored primary</em>, which is a different direction
* entirely. Securing the sentinels themselves would mean giving them their own ACL file, and the
* lane deliberately does not, because a sentinel port is not a data path.
*
* @return the declared sentinel address
*/
public RedisURI sentinelControlUri() {
return RedisURI.create(host, port);
}
/**
* Returns the URI for a specific data node the lane discovered, authenticated as the data
* account.
*
* <p>A promotion test has to dial the node Sentinel just named, not the declared address, so the
* host and port come from the caller while the credentials stay the lane's.
*
* @param nodeHost the discovered host
* @param nodePort the discovered port
* @return the authenticated URI
*/
public RedisURI dataNodeUri(String nodeHost, int nodePort) {
RedisURI uri = RedisURI.create(nodeHost, nodePort);
uri.setCredentialsProvider(
io.lettuce.core.RedisCredentialsProvider.from(
() -> io.lettuce.core.RedisCredentials.just(username, password.toCharArray())));
return uri;
}
/**
* Returns the URI for cluster provisioning writes against a specific node.
*
* <p>{@code CLUSTER SETSLOT} and friends are administrative writes. They are absent from the
* read-only admin account on purpose — an account named {@code admin-readonly} that can reshard a
* cluster is misnamed — so a test that drives a migration authenticates as the provisioning
* identity the lane also uses to build the cluster.
*
* @param nodeHost the node's host
* @param nodePort the node's port
* @return the authenticated URI
*/
public RedisURI provisioningUri(String nodeHost, int nodePort) {
RedisURI uri = RedisURI.create(nodeHost, nodePort);
uri.setCredentialsProvider(
io.lettuce.core.RedisCredentialsProvider.from(
() ->
io.lettuce.core.RedisCredentials.just(
System.getProperty(
"redis.topology.provisioning-username", "ca-skeleton-cluster-bootstrap"),
System.getProperty("redis.topology.provisioning-password", "fixture-bootstrap")
.toCharArray())));
return uri;
}
/**
* Returns the monitored primary's name.
*
* @return the name
* @throws IllegalStateException when the lane is not a Sentinel lane
*/
public String requireMasterId() {
return masterId.orElseThrow(
() -> new IllegalStateException("only the sentinel lane declares a monitored primary"));
}
private static String require(String key) {
String value = System.getProperty(key);
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"the redis-topology lane requires -D" + key + "; it must never be skipped silently");
}
return value;
}
}
@@ -0,0 +1,176 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.config;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.RedisTopologyEndpoint;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisLease;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeClient;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.boot.health.contributor.Status;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
/**
* The composition root, against a server that is actually there.
*
* <p>Every other test of this configuration uses endpoints nothing answers on, which proves the
* bean graph and nothing about whether the graph works. These are the three claims that can only be
* settled by connecting: the mode produced the client the topology needs, a lease borrowed from the
* owner reaches Redis, and shutting the context down leaves nothing behind.
*/
@Tag("redis-topology")
@Tag("lane-standalone")
@Tag("lane-sentinel")
@Tag("lane-cluster")
class LiveRedisCompositionTest {
private final RedisTopologyEndpoint endpoint = RedisTopologyEndpoint.fromSystemProperties();
private ApplicationContextRunner runner() {
return runner("fixture-application");
}
private ApplicationContextRunner runner(String password) {
ApplicationContextRunner runner =
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(RedisSdkAutoConfiguration.class))
.withBean(
RedisSdkAutoConfiguration.RedisSecretSource.class,
// The fixture account's real password. It used to be `nopass`, so this could have
// been any string at all and the lane would still have connected — which is why
// there was nothing to distinguish a working credential from a wrong one.
() -> name -> Optional.of(password))
.withPropertyValues(
"app.redis.enabled=true",
"app.redis.mode=" + endpoint.mode().name().toLowerCase(java.util.Locale.ROOT),
"app.redis.nodes=" + endpoint.host() + ":" + endpoint.port(),
// The lane's ACL disables `default`, so the composition authenticates as a named
// account exactly as a deployment would. A reference, not a value.
"app.redis.authentication.credential-reference=secret://ca-skeleton-application@environment/APP_REDIS_PASSWORD",
"app.redis.namespace.environment=prod",
"app.redis.namespace.service=order",
"app.redis.namespace.domain=shared");
return endpoint.mode()
== dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode.SENTINEL
? runner.withPropertyValues("app.redis.sentinel.master-name=" + endpoint.requireMasterId())
: runner;
}
@Test
@DisplayName("a wrong password is refused, so a right one proves something")
void aWrongPasswordIsRefused() throws Exception {
// The assertion the `nopass` fixture could never carry. With an account that accepts anything,
// every credential test passed for the same reason a typo would have, and the lane's coverage
// of authentication, rotation and secret wiring was indistinguishable from no coverage.
runner("not-the-fixture-password")
.run(
context -> {
assertThat(context).hasNotFailed();
RedisRuntimeOwner owner = context.getBean(RedisRuntimeOwner.class);
org.assertj.core.api.Assertions.assertThatThrownBy(
() -> {
try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) {
lease.gateway().ping().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
})
.as("authentication is actually enforced by the fixture")
.rootCause()
.hasMessageContaining("WRONGPASS");
});
}
@Test
@DisplayName("the configured mode produces exactly one client of the matching topology")
void theModeProducesOneMatchingClient() {
runner()
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(RedisRuntimeClient.class);
assertThat(context.getBean(RedisRuntimeClient.class).mode())
.isEqualTo(endpoint.mode());
assertThat(context).hasSingleBean(RedisRuntimeOwner.class);
});
}
@Test
@DisplayName("a lease borrowed from the composed owner reaches the server")
void aLeaseReachesTheServer() throws Exception {
runner()
.run(
context -> {
RedisRuntimeOwner owner = context.getBean(RedisRuntimeOwner.class);
assertThat(owner.state()).isEqualTo(RedisRuntimeOwner.State.OPEN);
try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) {
String reply =
lease.gateway().ping().toCompletableFuture().get(5, TimeUnit.SECONDS);
assertThat(reply).isEqualTo("PONG");
}
assertThat(owner.outstanding(RedisConnectionKind.REGULAR))
.as("the lease was returned, not leaked")
.isZero();
});
}
@Test
@DisplayName("the optional health contributor reports UP against a live server")
void theOptionalContributorReportsUp() {
runner()
.run(
context -> {
HealthIndicator optional = (HealthIndicator) context.getBean("redisOptional");
assertThat(optional.health().getStatus()).isEqualTo(Status.UP);
});
}
@Test
@DisplayName("closing the context drains, closes connections, then shuts the client down")
void closingTheContextTearsEverythingDown() {
RedisRuntimeOwner[] captured = new RedisRuntimeOwner[1];
int threadsBefore = redisThreadCount();
runner()
.run(
context -> {
captured[0] = context.getBean(RedisRuntimeOwner.class);
captured[0].borrow(RedisConnectionKind.REGULAR).close();
});
assertThat(captured[0].state()).isEqualTo(RedisRuntimeOwner.State.CLOSED);
// The event loop is what a leaked client leaves behind, and it is invisible to a bean-graph
// assertion. Lettuce's threads are named, so counting them is a direct check.
await(() -> redisThreadCount() <= threadsBefore, Duration.ofSeconds(10));
assertThat(redisThreadCount())
.as("no Lettuce event-loop threads outlive the context")
.isLessThanOrEqualTo(threadsBefore);
}
private static int redisThreadCount() {
return (int)
Thread.getAllStackTraces().keySet().stream()
.map(Thread::getName)
.filter(name -> name.startsWith("lettuce-"))
.count();
}
private static void await(java.util.function.BooleanSupplier condition, Duration budget) {
long deadline = System.nanoTime() + budget.toNanos();
while (System.nanoTime() < deadline && !condition.getAsBoolean()) {
try {
Thread.sleep(50);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return;
}
}
}
}
@@ -0,0 +1,437 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.RedisTopologyEndpoint;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisVersion;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.RedisCommandDescriptor;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error.RedisFailureMetadata;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error.RedisOperationException;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.ListKey;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.codec.Utf8StringCodec;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.ExecutionCertainty;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.RedisCommandCatalog;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.SentinelFailoverObserver;
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.sentinel.api.StatefulRedisSentinelConnection;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
/**
* What a Sentinel promotion does to work that is in flight.
*
* <p>The unit suite can prove that {@link SentinelFailoverObserver} counts what it is told and that
* {@link ExecutionCertainty} refuses to retry a non-idempotent write. It cannot prove the thing
* those two types exist for: that the certainty the SDK reports to a caller is <em>true</em> of the
* server afterwards. Only a real promotion settles that.
*
* <p>The experiment runs once in {@link #promote()} and every test below asserts a different
* falsifiable claim about that one recorded run. Each write carries a token that is unique across
* the run, so the list on the promoted primary is a verbatim record of what actually happened and
* the SDK's per-call verdict can be checked against it one token at a time.
*
* <p>The two claims with teeth are that a token whose call the SDK reported as <em>definitely not
* applied</em> must be absent from the promoted primary, and that no token may appear twice —
* {@code RPUSH} is not retry-safe, and the guardrails forbid anything in the stack from resending
* it on its own. Both fail loudly if the certainty model is wishful thinking rather than a
* description of the driver underneath it.
*/
@Tag("redis-topology")
@Tag("lane-sentinel")
class LiveRedisSentinelPromotionTest {
/** The write that must survive the promotion; it is confirmed and replicated beforehand. */
private static final String REPLICATED = "replicated-before-promotion";
/** How long the write loop may run before the lane is declared broken. */
private static final Duration EXPERIMENT_CEILING = Duration.ofSeconds(90);
/** Confirmed writes required after the promotion before the run is considered settled. */
private static final int SETTLED_WRITES = 50;
/** Attempts to confirm before the failover is requested. */
private static final int WARMUP_WRITES = 20;
/** Pause between attempts; small enough to land inside a promotion, large enough not to spin. */
private static final Duration ATTEMPT_INTERVAL = Duration.ofMillis(5);
/**
* The {@code min-replicas-max-lag} the lane configures.
*
* <p>This is the width of the window in which a superseded primary can still acknowledge a write
* that is about to be discarded, so it is also the bound on how many acknowledged writes a
* promotion may destroy. Doubling it leaves room for scheduling jitter without leaving room for
* the unbounded behaviour the setting exists to prevent.
*/
private static final Duration REPLICA_LAG_CEILING = Duration.ofSeconds(1);
private static RedisTopologyEndpoint endpoint;
private static RedisClient controlClient;
private static StatefulRedisSentinelConnection<String, String> sentinel;
private static LiveRedisOperationsFixture fixture;
private static ListKey<String> log;
private static String renderedLogKey;
private static InetSocketAddress primaryBefore;
private static InetSocketAddress primaryAfter;
private static final List<Attempt> ATTEMPTS = new ArrayList<>();
private static List<String> tokensOnPromotedPrimary = List.of();
private static SentinelFailoverObserver observer;
private static Duration observedReconnect = Duration.ZERO;
/** One write attempt and the verdict the SDK returned for it. */
private record Attempt(String token, Optional<RedisOperationException> failure) {
boolean confirmed() {
return failure.isEmpty();
}
/** Reports whether the SDK told the caller the write definitely did not take effect. */
boolean reportedAsNotApplied() {
return failure.map(value -> !value.metadata().ambiguousExecution()).orElse(false);
}
/** Reports whether the SDK left the outcome open. */
boolean reportedAsAmbiguous() {
return failure.map(value -> value.metadata().ambiguousExecution()).orElse(false);
}
Optional<RedisFailureMetadata> metadata() {
return failure.map(RedisOperationException::metadata);
}
}
@BeforeAll
static void promote() throws InterruptedException {
endpoint = RedisTopologyEndpoint.fromSystemProperties();
controlClient = RedisClient.create();
// The sentinels themselves run with `default` off, so the control connection authenticates as
// the sentinel account. Before that account existed this connected as `default` — which is why
// hardening the fixture broke this lane rather than the lane proving the hardening.
sentinel = controlClient.connectSentinel(endpoint.sentinelControlUri());
primaryBefore = resolvePrimary();
fixture =
new LiveRedisOperationsFixture(
endpoint, RedisVersion.parse("7.4.0"), Duration.ofMillis(500));
log = fixture.keys.list("failover", "promotion-log", Utf8StringCodec.instance());
renderedLogKey = fixture.renderer.render(log.key());
observer = new SentinelFailoverObserver(1_000);
// The lane requires an in-sync replica before it will accept a write at all, so the run cannot
// start until one is attached. Writing first and discovering NOREPLICAS would report a lane
// that was merely still starting up as a promotion that destroyed data.
awaitInSyncReplica(primaryBefore);
clearLog(primaryBefore);
// If this one is missing afterwards the lane promoted a replica that never carried the data,
// and every other assertion in this class would be measuring the wrong thing.
fixture.lists.pushRight(log, List.of(REPLICATED));
requireReplication(primaryBefore);
runWriteLoopAcrossAPromotion();
primaryAfter = resolvePrimary();
observer.recordPromotion(observedReconnect);
RedisCommandDescriptor push =
RedisCommandCatalog.loadDefault().require(CommandId.parse("RPUSH")).descriptor();
for (Attempt attempt : ATTEMPTS) {
if (!attempt.confirmed()) {
observer.classify(push, attempt.reportedAsAmbiguous());
}
}
tokensOnPromotedPrimary = readLog(primaryAfter);
report();
}
private static void runWriteLoopAcrossAPromotion() throws InterruptedException {
Instant deadline = Instant.now().plus(EXPERIMENT_CEILING);
boolean failoverRequested = false;
Instant firstFailure = null;
int confirmedAfterFirstFailure = 0;
int index = 0;
while (Instant.now().isBefore(deadline)) {
Attempt attempt = attempt("token-" + index++);
ATTEMPTS.add(attempt);
if (!failoverRequested && attempt.confirmed() && index >= WARMUP_WRITES) {
sentinel.sync().failover(endpoint.requireMasterId());
failoverRequested = true;
}
if (failoverRequested && !attempt.confirmed() && firstFailure == null) {
firstFailure = Instant.now();
}
if (firstFailure != null && attempt.confirmed()) {
if (observedReconnect.isZero()) {
observedReconnect = Duration.between(firstFailure, Instant.now());
}
if (++confirmedAfterFirstFailure >= SETTLED_WRITES) {
return;
}
}
Thread.sleep(ATTEMPT_INTERVAL.toMillis());
}
}
/**
* Prints what the promotion actually did.
*
* <p>This lane exists to find out how the driver behaves, so the distribution of failure types is
* evidence in its own right and is recorded in the run log rather than only in an assertion
* message.
*/
private static void report() {
Map<String, Long> byType = new LinkedHashMap<>();
for (Attempt attempt : ATTEMPTS) {
attempt.failure.ifPresent(
failure ->
byType.merge(
failure.getClass().getSimpleName()
+ (failure.metadata().ambiguousExecution() ? " [ambiguous]" : " [not-run]")
+ " "
+ failure.getMessage()
+ " <- "
+ rootCause(failure),
1L,
Long::sum));
}
Set<String> present = new LinkedHashSet<>(tokensOnPromotedPrimary);
long acknowledgedButLost =
ATTEMPTS.stream()
.filter(Attempt::confirmed)
.map(Attempt::token)
.filter(token -> !present.contains(token))
.count();
System.out.println("[sentinel] primary " + primaryBefore + " -> " + primaryAfter);
System.out.println(
"[sentinel] attempts="
+ ATTEMPTS.size()
+ " confirmed="
+ ATTEMPTS.stream().filter(Attempt::confirmed).count()
+ " reconnect="
+ observedReconnect
+ " stored="
+ tokensOnPromotedPrimary.size()
+ " acknowledged-but-lost="
+ acknowledgedButLost);
byType.forEach((type, count) -> System.out.println("[sentinel] " + count + "x " + type));
}
private static String rootCause(Throwable failure) {
Throwable current = failure;
while (current.getCause() != null) {
current = current.getCause();
}
return current.getClass().getName() + ": " + current.getMessage();
}
@AfterAll
static void disconnect() {
if (fixture != null) {
fixture.close();
}
if (sentinel != null) {
sentinel.close();
}
if (controlClient != null) {
controlClient.shutdown();
}
}
@Test
@DisplayName("the lane actually promoted a different node")
void promotionHappened() {
assertThat(primaryAfter)
.as("Sentinel resolves the same address before and after; nothing was promoted")
.isNotEqualTo(primaryBefore);
}
@Test
@DisplayName("the client followed the promotion and writes land on the new primary")
void clientFollowedThePromotion() {
// Deliberately not asserting that the caller saw a failure. The first runs of this lane showed
// a promotion that cost the caller nothing visible at all — sixteen thousand attempts, zero
// exceptions — while two thousand acknowledged writes were being discarded. Requiring a
// visible interruption would have turned that into a red test for the wrong reason and hidden
// the finding behind it.
assertThat(ATTEMPTS.stream().filter(Attempt::confirmed).toList())
.hasSizeGreaterThan(SETTLED_WRITES);
assertThat(tokensOnPromotedPrimary)
.as("the replicated pre-promotion write did not survive the promotion")
.contains(REPLICATED);
}
@Test
@DisplayName("a promotion destroys no more acknowledged writes than the replica lag allows")
void acknowledgedWriteLossIsBounded() {
Set<String> present = new LinkedHashSet<>(tokensOnPromotedPrimary);
List<String> lost =
ATTEMPTS.stream()
.filter(Attempt::confirmed)
.map(Attempt::token)
.filter(token -> !present.contains(token))
.toList();
long allowed = 2 * REPLICA_LAG_CEILING.dividedBy(ATTEMPT_INTERVAL);
// This is the assertion the lane was built for. A superseded primary that still has an in-sync
// replica requirement stops acknowledging writes about one lag-window after it is orphaned;
// one that does not keeps saying +OK until Sentinel demotes it, which took eleven seconds and
// cost two thousand acknowledged writes when this was first measured. If the requirement is
// ever dropped from the lane, this count jumps by an order of magnitude and says so.
assertThat((long) lost.size())
.as(
"%d acknowledged writes were discarded by the promotion; the configured replica lag"
+ " allows at most %d, so the superseded primary was acknowledging writes it could"
+ " not keep",
lost.size(), allowed)
.isLessThanOrEqualTo(allowed);
}
@Test
@DisplayName("a write the SDK reported as not applied is absent from the promoted primary")
void reportedFailuresDidNotApply() {
Set<String> present = new LinkedHashSet<>(tokensOnPromotedPrimary);
assertThat(
ATTEMPTS.stream()
.filter(Attempt::reportedAsNotApplied)
.map(Attempt::token)
.filter(present::contains)
.toList())
.as("the SDK told the caller these writes definitely did not run, and they did")
.isEmpty();
}
@Test
@DisplayName("every failure carries a coherent verdict")
void everyFailureIsClassified() {
List<RedisFailureMetadata> failures =
ATTEMPTS.stream().flatMap(attempt -> attempt.metadata().stream()).toList();
assertThat(failures)
.allSatisfy(
metadata -> {
assertThat(metadata.readOperation()).isFalse();
assertThat(metadata.retryable() && metadata.ambiguousExecution())
.as("an ambiguous write must never be advertised as retryable")
.isFalse();
assertThat(metadata.retryable())
.as("RPUSH is not retry-safe, so no verdict may mark it retryable")
.isFalse();
});
}
@Test
@DisplayName("no write is applied twice across the promotion")
void nonIdempotentWritesAreNeverReplayed() {
assertThat(tokensOnPromotedPrimary)
.as("a token appears more than once; a non-retry-safe write was resent by the stack")
.doesNotHaveDuplicates();
}
@Test
@DisplayName("nothing reached the server that the test never issued")
void serverStateIsExplainedByTheRun() {
Set<String> issued = new LinkedHashSet<>(ATTEMPTS.stream().map(Attempt::token).toList());
issued.add(REPLICATED);
assertThat(issued).containsAll(tokensOnPromotedPrimary);
}
@Test
@DisplayName("the observer's account of the promotion matches the run")
void observerMatchesTheRun() {
long ambiguous = ATTEMPTS.stream().filter(Attempt::reportedAsAmbiguous).count();
assertThat(observer.promotionCount()).isEqualTo(1);
assertThat(observer.ambiguousWriteCount())
.as("every ambiguous non-retry-safe write around a promotion is one to reconcile")
.isEqualTo(ambiguous);
assertThat(observer.longestReconnect()).isEqualTo(observedReconnect);
assertThat(observer.refusedWhileReconnectingCount()).isZero();
}
private static Attempt attempt(String token) {
try {
fixture.lists.pushRight(log, List.of(token));
return new Attempt(token, Optional.empty());
} catch (RedisOperationException failure) {
return new Attempt(token, Optional.of(failure));
}
}
private static InetSocketAddress resolvePrimary() {
SocketAddress address = sentinel.sync().getMasterAddrByName(endpoint.requireMasterId());
if (!(address instanceof InetSocketAddress resolved)) {
throw new IllegalStateException("Sentinel did not report an inet address for the primary");
}
return resolved;
}
private static void clearLog(InetSocketAddress primary) {
withPrimary(primary, connection -> connection.sync().del(renderedLogKey));
}
private static void awaitInSyncReplica(InetSocketAddress primary) throws InterruptedException {
for (int attempt = 0; attempt < 30; attempt++) {
Long replicas =
withPrimary(primary, connection -> connection.sync().waitForReplication(1, 1_000));
if (replicas != null && replicas >= 1) {
return;
}
Thread.sleep(500);
}
throw new IllegalStateException(
"no replica came into sync; the lane cannot promote one and cannot accept a write");
}
private static void requireReplication(InetSocketAddress primary) {
Long replicas =
withPrimary(primary, connection -> connection.sync().waitForReplication(1, 2_000));
if (replicas == null || replicas < 1) {
throw new IllegalStateException(
"the primary has no replica in sync; the lane cannot promote one");
}
}
private static List<String> readLog(InetSocketAddress primary) {
return withPrimary(primary, connection -> connection.sync().lrange(renderedLogKey, 0, -1));
}
private static <T> T withPrimary(
InetSocketAddress primary, Function<StatefulRedisConnection<String, String>, T> work) {
RedisClient direct =
RedisClient.create(endpoint.dataNodeUri(primary.getHostString(), primary.getPort()));
try (StatefulRedisConnection<String, String> connection = direct.connect()) {
return work.apply(connection);
} finally {
direct.shutdown();
}
}
}
+52 -23
View File
@@ -1,4 +1,4 @@
# adapter:outbound:httpclient — resilient HTTP client adapter
# adapter:outbound:httpclient — HTTP Client Platform
## Registered identity
@@ -10,38 +10,67 @@
Package root: `dev.caskeleton.adapter.outbound.httpclient`.
## Design authority
The implementation follows
`httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`.
The design assumes 19 separate Gradle modules; this repository's fail-closed 19-leaf registry
outranks that layout, so those modules are **packages** here. The mapping, and every other
deliberate substitution, is recorded in `docs/httpclient/repository-adaptation.md`. Read it before
moving a type between packages.
## Responsibility
- Own typed destination/operation catalogs, safe target construction, outbound engine construction,
deadlines/cancellation, resilience, request/response bounds, egress security, diagnostics, and
lifecycle.
- Own the public call surfaces: H1 typed service clients (default), H2 generic exchange, H3 dynamic
target. H4 native engine access stays internal to `apache`, `jdk`, `reactor`, and `http3`.
- Own Named Client Profiles, runtime generations, transport SPI, deadlines, evidence-based retry,
resilience, authentication, TLS, SSRF defence, streaming lifecycle, and observability.
- Adapt external HTTP calls behind application/domain ports.
- Reuse `adapter:outbound:support` for shared outbound concerns.
## Package boundaries
`HttpClientModuleBoundaryTest` and `PublicApiArchitectureTest` enforce the design's module table:
- `api` depends on nothing else in the platform, and on no Spring, Apache, Netty, Jetty, or
Resilience4j type.
- `profile` depends publicly only on `api`.
- transport packages never reach back into the gateways.
- `resilience` never depends on a transport — retry eligibility is transport-neutral.
- no production package depends on `testkit`.
- Stable code never references `http3`.
- `org.springframework.web.service.registry` appears only in `spring7`.
- `RestTemplate` appears only in `migration`.
## Boundaries
- Allowed dependency edges come only from the module's
`src/config/architecture/modules.json` entry.
- Allowed dependency edges come only from this module's `src/config/architecture/modules.json` entry.
- No inbound controller/DTO, persistence, bootstrap, or sample dependency.
- Retry and circuit-breaker code is technical resilience; business compensation and use-case
sequencing stay in application/domain layers.
- Application/domain code must not import this module's generic HTTP client, operation descriptor,
URI, Spring HTTP, JDK/Apache client, retry, or wire DTO types.
- Normal calls use registered fixed destinations and relative operation routes; arbitrary absolute
URL/header/credential APIs are forbidden.
- The legacy JDK facade, connect/read timeout, and response-size interceptor are not evidence of an
Apache pool bound, egress security, wire hard-cancellation, or R2 readiness. Its active monotonic
logical-call deadline is R1 evidence only.
- Canonical activation is owned by `app-bootstrap`: default `DISABLED` must resolve to
`DISABLED_VERIFIED` with zero HTTP runtime resources. Provider definitions are inert unless an
exact binding selects them; the current `NOT_IMPLEMENTED` card rejects every ACTIVE selection
before provider construction.
- Legacy `app.outbound.http.*` values are explicit migration input only. They must not be globally
configuration-properties scanned or present beside canonical composition in any expected state.
- Streaming must validate status before body delivery and remains bounded by a selected readiness
card before production use.
- Application/domain code must not import this module's gateways, operation descriptors, URI types,
Spring HTTP types, engine clients, retry types, or wire DTOs.
- Normal calls use a registered profile and a profile-relative template. Absolute URLs are H3 only.
- Composition is owned by `app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.httpclient`):
profile binding, startup validation, transport registration, and the actuator endpoint live there.
That package is excluded from the composition root's component scan and reached only through
`HttpClientPlatformAutoConfiguration`, which is gated on `app.httpclient.enabled=true`. While the
switch is off the capability has no beans at all — not an empty registry, nothing.
## Tests
Focused tests may use loopback servers and collaborator fakes. R2 promotion requires explicit
real-network/TLS/pool/cancellation/security lanes and no selected lane may silently skip.
Default lane: `./gradlew :adapter:outbound:httpclient:test`. Additional lanes, all fail-closed:
| Lane | Purpose |
|---|---|
| `httpClientStableContractTest` | one semantic contract across Apache, JDK, Reactor |
| `httpClientSecurityTest` | SSRF matrix, credential stripping, tag cardinality |
| `httpClientFailureInjectionTest` | Toxiproxy faults; **requires Docker and fails without it** |
| `httpClientPerformanceTest` | pool, streaming, retry, rotation resource bounds |
| `spring62CompatibilityTest` | Spring 6.2 API-surface confinement |
| `spring70CompatibilityTest` | contract suite on the repository baseline |
| `jmh` | per-call overhead benchmarks |
A selected lane never skips silently: the fault lane throws without Docker, the contract lane throws
on an empty or unknown transport selection, and the performance lane prints which machine-dependent
bounds were not asserted.
+229 -4
View File
@@ -1,17 +1,242 @@
plugins { id 'groovy' }
// Outbound HTTP Client Platform leaf — see
// docs/superpowers/specs/2026-08-08-httpclient-platform-design.md (design package) and
// docs/httpclient/repository-adaptation.md (how the design's 19 library modules map here).
//
// The design models the platform as 19 separate Gradle modules. This repository's fail-closed
// 19-leaf registry (src/config/architecture/modules.json) outranks that layout, so the module
// boundaries are packages under dev.caskeleton.adapter.outbound.httpclient and
// HttpClientModuleBoundaryTest enforces the design's module dependency table.
description = 'Outbound adapter: HTTP client platform (typed clients, profiles, evidence-based retry)'
dependencies {
implementation project(':application-core')
implementation project(':shared-contract')
implementation project(':adapter:outbound:support')
// Spring client layer. `httpclient-core-api` must not reach these; ArchUnit enforces it.
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.springframework:spring-web'
implementation 'io.micrometer:micrometer-core'
implementation 'org.springframework:spring-webflux'
implementation 'io.projectreactor:reactor-core'
// Transport providers. Apache HC5 is the blocking default, JDK HttpClient is the lightweight
// alternative (JDK built-in), Reactor Netty is the reactive default, Jetty carries the
// Experimental HTTP/3 transport that the Stable starter never auto-configures.
implementation 'org.apache.httpcomponents.client5:httpclient5'
implementation 'io.projectreactor.netty:reactor-netty-http'
implementation 'org.eclipse.jetty:jetty-client'
// HTTP/3 is Experimental and off by default, so its transport is compileOnly plus a test
// dependency rather than a runtime one. It used to be `implementation`, which put the whole
// QUIC/HTTP-3/QPACK stack on every deployment's runtime classpath — megabytes and an attack
// surface — to serve a feature the Stable starter never auto-configures. A deployment that
// opts into HTTP/3 adds `org.eclipse.jetty.http3:jetty-http3-client-transport` itself, and
// Http3CapabilityReport already refuses the transport when those classes are absent, so the
// failure mode is a startup error rather than a NoClassDefFoundError mid-call.
compileOnly 'org.eclipse.jetty.http3:jetty-http3-client-transport'
testImplementation 'org.eclipse.jetty.http3:jetty-http3-client-transport'
// Resilience4j supplies the execution primitives only. HTTP retry *eligibility* is owned by
// this module (design D-09) and never delegated to a generic retry library.
implementation 'io.github.resilience4j:resilience4j-retry:2.2.0'
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
implementation 'io.github.resilience4j:resilience4j-ratelimiter:2.2.0'
implementation 'io.github.resilience4j:resilience4j-bulkhead:2.2.0'
implementation 'io.github.resilience4j:resilience4j-micrometer:2.2.0'
implementation 'org.springframework.security:spring-security-oauth2-client'
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'io.micrometer:micrometer-core'
implementation 'org.slf4j:slf4j-api'
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
// Testkit dependencies (design §28.1 test topology). They are test-scoped so no production
// module can depend on the testkit.
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
testImplementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testImplementation 'org.testcontainers:testcontainers-toxiproxy'
testImplementation 'io.projectreactor:reactor-test'
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
testImplementation 'io.projectreactor.tools:blockhound:1.0.17.RELEASE'
}
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
// Performance certification and JMH benchmarks are separate source sets: they are slow, they assert
// on resource bounds rather than behaviour, and they must never be part of the default unit lane.
sourceSets {
httpClientPerformanceTest {
java.srcDir 'src/httpClientPerformanceTest/java'
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += output + compileClasspath
}
jmh {
java.srcDir 'src/jmh/java'
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += output + compileClasspath
}
}
configurations {
httpClientPerformanceTestImplementation.extendsFrom testImplementation
httpClientPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
jmhImplementation.extendsFrom testImplementation
jmhRuntimeOnly.extendsFrom testRuntimeOnly
}
dependencies {
jmhImplementation 'org.openjdk.jmh:jmh-core:1.37'
jmhAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
// JMH generates its harness classes at compile time. They are not our source, so the
// compile-time checker and -Werror are switched off for that source set only; applying them
// would fail the build on generated code we cannot edit.
tasks.named('compileJmhJava', JavaCompile) {
options.errorprone.enabled = false
options.compilerArgs.removeIf { it == '-Werror' }
}
// The bytecode analyser is disabled for the same generated harness, for the same reason.
tasks.named('spotbugsJmh') {
enabled = false
}
Closure<Void> applyContractSelection = { Test task ->
// Cross-transport contract lane. The same semantic contract runs against every Stable transport;
// the transport under test is selected explicitly so a missing transport is an error, not a skip.
task.systemProperty 'httpclient.contract.transports',
(project.findProperty('httpclient.contract.transports') ?: 'apache,jdk,reactor').toString()
// Netty's strictest leak detector is on for every lane. It is only meaningful if it is actually
// live, so NettyLeakDetectionExtension asserts the level rather than trusting the flag reached
// the forked JVM.
task.systemProperty 'io.netty.leakDetection.level', 'paranoid'
// HTTP/3 is Experimental: it is never part of the default lane and never silently skipped.
task.systemProperty 'httpclient.http3.tests.enabled',
(project.findProperty('http3.tests.enabled') ?: 'false').toString()
}
tasks.named('test', Test) {
applyContractSelection(it)
// Two lanes are excluded from the default run for opposite reasons: the fault lane needs Docker
// and fails closed without it, and the BlockHound lane rewrites core JDK bytecode, which must
// not be imposed on every unit run.
useJUnitPlatform {
excludeTags 'quarantine', 'httpclient-fault', 'httpclient-blockhound'
}
}
tasks.register('httpClientBlockHoundTest', Test) {
group = 'verification'
description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-blockhound' }
applyContractSelection(it)
// BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them.
jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods'
// The lane exists to run BlockHound. Discovering nothing means it did not, which is a failure.
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('httpClientStableContractTest', Test) {
group = 'verification'
description = 'Runs the cross-transport stable contract suite (design §28.2, §33).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-contract' }
applyContractSelection(it)
outputs.upToDateWhen { false }
}
tasks.register('httpClientSecurityTest', Test) {
group = 'verification'
description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-security' }
applyContractSelection(it)
outputs.upToDateWhen { false }
}
tasks.register('httpClientFailureInjectionTest', Test) {
group = 'verification'
description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker (design §28.3).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-fault' }
applyContractSelection(it)
// The upstream image is mutable by default. Passing a digest here is what makes a red fault
// run attributable to this repository rather than to someone else's image push.
systemProperty 'httpclient.fault.httpbin.image',
(project.findProperty('httpclient.fault.httpbin.image') ?: 'kennethreitz/httpbin:latest').toString()
// A fault suite that never injected a fault must not report success, so a selected lane with no
// discovered test is an error rather than an empty pass.
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('httpClientPerformanceTest', Test) {
group = 'verification'
description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).'
testClassesDirs = sourceSets.httpClientPerformanceTest.output.classesDirs
classpath = sourceSets.httpClientPerformanceTest.runtimeClasspath
useJUnitPlatform()
applyContractSelection(it)
systemProperty 'performance.assertions.enabled',
(project.findProperty('performance.assertions.enabled') ?: 'false').toString()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('jmh', JavaExec) {
group = 'verification'
description = 'Runs the JMH benchmarks for the blocking and reactive clients (design §28.8).'
mainClass = 'org.openjdk.jmh.Main'
classpath = sourceSets.jmh.runtimeClasspath
args '-rf', 'json', '-rff', layout.buildDirectory.file('reports/jmh/result.json').get().asFile.absolutePath
}
// Spring 6.2 / 7.0 compatibility lanes. This repository's Spring Boot 4.0 baseline pins Spring
// Framework 7, so the 6.2 lane verifies the *API surface* the common packages compile against
// rather than executing on a 6.2 distribution; the limitation is recorded in
// docs/httpclient/support-matrix.md instead of being hidden behind a green check.
tasks.register('spring62ApiSurfaceScan', Test) {
group = 'verification'
description = 'Scans the common packages for Spring 6.2 API-surface confinement. NOT a 6.2 runtime.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-spring62-surface' }
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
// Which lanes gate an ordinary build, and which do not.
//
// The specialised lanes existed but hung off nothing: `check` ran only `test`, so the SSRF suite,
// the BlockHound lane, the cross-transport contract and the Spring 6.2 surface scan were green in
// CI only because a workflow happened to name them, and green locally because nobody ran them.
// The four below are hermetic and fast — no Docker, no network, no machine-dependent thresholds —
// so they belong in `check`.
//
// httpClientFailureInjectionTest (needs Docker), httpClientPerformanceTest (machine-dependent
// bounds) and jmh (minutes) stay out deliberately. Attaching them would make `check` fail on a
// laptop without Docker, which teaches people to skip `check`.
tasks.named('check') {
dependsOn 'httpClientStableContractTest',
'httpClientSecurityTest',
'httpClientBlockHoundTest',
'spring62ApiSurfaceScan'
}
tasks.register('spring70CompatibilityTest', Test) {
group = 'verification'
description = 'Runs the contract suite on the repository Spring 7 baseline (design §29).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-contract' }
applyContractSelection(it)
outputs.upToDateWhen { false }
}
+208 -123
View File
@@ -1,166 +1,251 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
com.github.spotbugs:spotbugs-annotations:4.8.6=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.41.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.jayway.jsonpath:json-path:2.9.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:content-type:2.3=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:lang-tag:1.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:oauth2-oidc-sdk:11.26.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
com.squareup.okhttp3:mockwebserver:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.squareup.okhttp3:okhttp-tls:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.squareup.okhttp3:okhttp:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.squareup.okio:okio-jvm:3.6.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.squareup.okio:okio:3.6.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5-api:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine:1.3.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-logging:commons-logging:1.3.5=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.github.resilience4j:resilience4j-bulkhead:2.2.0=runtimeClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-core:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-micrometer:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-ratelimiter:2.2.0=runtimeClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-retry:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-timelimiter:2.2.0=runtimeClasspath,testRuntimeClasspath
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
io.github.resilience4j:resilience4j-bulkhead:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-core:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-micrometer:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-ratelimiter:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-retry:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-timelimiter:2.2.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-classes-quic:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-compression:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-dns:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-http2:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-http3:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-http:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-native-quic:4.2.17.Final=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
io.netty:netty-codec-socks:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-handler-proxy:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns-native-macos:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-classes-epoll:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-native-epoll:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor.netty:reactor-netty-core:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor.netty:reactor-netty-http:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor.tools:blockhound:1.0.17.RELEASE=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-test:3.8.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
junit:junit:4.13.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.java.dev.jna:jna:5.18.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
net.sf.jopt-simple:jopt-simple:5.0.4=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-compress:1.28.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-math3:3.6.1=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
org.apache.httpcomponents.client5:httpclient5:5.5.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.httpcomponents.core5:httpcore5-h2:5.3.6=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.httpcomponents.core5:httpcore5:5.3.6=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.apiguardian:apiguardian-api:1.1.2=compileClasspath,httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
org.assertj:assertj-core:3.27.6=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath
org.eclipse.jetty.compression:jetty-compression-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.compression:jetty-compression-gzip:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.http3:jetty-http3-client-transport:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.http3:jetty-http3-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.http3:jetty-http3-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.http3:jetty-http3-qpack:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.quic:jetty-quic-api:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.quic:jetty-quic-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.quic:jetty-quic-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.quic:jetty-quic-util:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-alpn-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-http:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-io:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-util:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hamcrest:hamcrest-core:3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jetbrains:annotations:17.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,httpClientPerformanceTestAnnotationProcessor,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath
org.junit:junit-bom:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
org.latencyutils:LatencyUtils:2.0.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath
org.openjdk.jmh:jmh-core:1.37=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath
org.openjdk.jmh:jmh-generator-annprocess:1.37=jmhAnnotationProcessor
org.opentest4j:opentest4j:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.ow2.asm:asm:9.7.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.rnorth.duct-tape:duct-tape:1.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-junit-jupiter:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-toxiproxy:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
org.xmlunit:xmlunit-core:2.10.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,104 @@
package dev.caskeleton.adapter.outbound.httpclient.performance;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientApiType;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol;
import dev.caskeleton.adapter.outbound.httpclient.profile.PoolSettings;
import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType;
import dev.caskeleton.adapter.outbound.httpclient.reactor.ReactorConnectionProviderFactory;
import dev.caskeleton.adapter.outbound.httpclient.reactor.ReactorHttpClientFactory;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
import dev.caskeleton.adapter.outbound.httpclient.testkit.TlsFixture;
import dev.caskeleton.adapter.outbound.httpclient.testkit.TlsMaterials;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.netty.resources.ConnectionProvider;
/**
* HTTP/2 multiplexes streams onto a connection, so stream concurrency is bounded separately from
* connection count (design §14.1, §24.1).
*
* <p>This test used to run against {@code MockHttpServer.start()} — cleartext HTTP/1.1 — while
* asserting only that some requests completed. It was named for HTTP/2, filed under HTTP/2, and
* proved nothing about it: the same assertions passed over HTTP/1.1 with one connection per
* request, which is the exact behaviour multiplexing is supposed to replace. The server is now a
* real TLS+ALPN HTTP/2 endpoint and every response has to report {@code HTTP/2.0}, so the claim in
* the class name is the claim the test makes.
*/
class Http2StreamSaturationTest {
@Test
void manyConcurrentStreamsShareABoundedConnectionPoolOverRealHttp2() throws Exception {
int streams = 32;
int maxConnections = 2;
TlsFixture fixture = TlsFixture.trusted();
try (MockHttpServer server = MockHttpServer.startTlsWithHttp2(fixture.serverSocketFactory())) {
for (int index = 0; index < streams; index++) {
server.enqueueJson(200, "{\"id\":1,\"name\":\"a\"}");
}
ClientProfile profile =
ClientProfiles.builder("multiplexed")
.baseUrl(server.uri("/"))
.transport(TransportType.REACTOR_NETTY)
.api(ClientApiType.WEB_CLIENT)
.protocols(Set.of(HttpProtocol.HTTP_2, HttpProtocol.HTTP_1_1))
.pool(
new PoolSettings(
maxConnections,
maxConnections,
streams,
Duration.ofSeconds(5),
Duration.ofSeconds(30),
Duration.ofMinutes(5),
Duration.ofSeconds(5),
Duration.ofSeconds(15),
Duration.ofSeconds(5),
false,
false))
.build();
ConnectionProvider pool = new ReactorConnectionProviderFactory().create(profile);
try {
reactor.netty.http.client.HttpClient client =
new ReactorHttpClientFactory()
.create(
profile, pool, Optional.of(TlsMaterials.trustOnly(fixture)), Optional.empty());
List<String> versions =
Flux.fromStream(IntStream.range(0, streams).boxed())
.flatMap(
index ->
client
.get()
.uri(server.uri("/users/1").toString())
.response((response, bytes) -> Mono.just(response.version().text())),
streams)
.collectList()
.block(Duration.ofSeconds(60));
assertThat(versions).hasSize(streams);
assertThat(versions)
.as("every stream must be carried over HTTP/2, not silently downgraded to HTTP/1.1")
.containsOnly("HTTP/2.0");
// The point of multiplexing: 32 concurrent streams did not need 32 connections. The server
// counts what it accepted, so this is an observation rather than an inference from the
// client's own configuration.
PerformanceAssertions.structural(
"the bounded pool carried every concurrent stream", server.requestCount() == streams);
} finally {
pool.disposeLater().block(Duration.ofSeconds(5));
}
}
}
}
@@ -0,0 +1,95 @@
package dev.caskeleton.adapter.outbound.httpclient.performance;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.auth.AccessToken;
import dev.caskeleton.adapter.outbound.httpclient.auth.OAuth2TokenCacheKey;
import dev.caskeleton.adapter.outbound.httpclient.auth.SingleFlightTokenLoader;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
/** Token refresh under contention must stay single-flight (design §20.3, §28.8). */
class OAuthRefreshContentionTest {
private static final OAuth2TokenCacheKey KEY =
new OAuth2TokenCacheKey(
"payment",
"java.lang.String",
Set.of("payments.write"),
Optional.of("payment-api"),
Optional.empty(),
Optional.empty());
/**
* Single-flight collapses <em>concurrent</em> refreshes, so all hundred callers must genuinely be
* in flight at once. A pool smaller than the caller count would serialise them into successive
* refreshes and measure something the design never claimed.
*/
@Test
void aHundredConcurrentCallersProduceOneTokenRequest() throws Exception {
int callers = 100;
AtomicInteger loads = new AtomicInteger();
CountDownLatch started = new CountDownLatch(callers);
CountDownLatch release = new CountDownLatch(1);
SingleFlightTokenLoader loader =
new SingleFlightTokenLoader(
key -> {
loads.incrementAndGet();
try {
release.await(10, TimeUnit.SECONDS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
return new AccessToken(
"token", Clock.systemUTC().instant().plus(Duration.ofMinutes(5)));
});
ExecutorService pool = Executors.newFixedThreadPool(callers);
try {
List<Future<AccessToken>> futures =
IntStream.range(0, callers)
.mapToObj(
index ->
pool.submit(
() -> {
started.countDown();
return loader.load(KEY);
}))
.toList();
assertThat(started.await(20, TimeUnit.SECONDS)).isTrue();
// Wait for the condition the test actually depends on — every caller inside load() — rather
// than sleeping and hoping. `started` only proves each task began; it counts down *before*
// load() is entered, so releasing on a fixed 200ms could let a caller arrive after the first
// refresh had already completed and been removed, producing a second load and a failure that
// looks like a single-flight bug but is a test bug.
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
while (loader.joinedCallers() < callers && System.nanoTime() < deadline) {
TimeUnit.MILLISECONDS.sleep(1);
}
assertThat(loader.joinedCallers())
.as("every caller must be inside load() before the refresh is released")
.isEqualTo(callers);
release.countDown();
for (Future<AccessToken> future : futures) {
assertThat(future.get(20, TimeUnit.SECONDS)).isNotNull();
}
} finally {
pool.shutdownNow();
assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue();
}
PerformanceAssertions.structural("token refresh collapsed to one request", loads.get() == 1);
assertThat(loader.inFlightRefreshes()).isZero();
}
}
@@ -0,0 +1,94 @@
package dev.caskeleton.adapter.outbound.httpclient.performance;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.api.OperationName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpBulkheadRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpPoolAcquireTimeoutException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.PoolSettings;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
import dev.caskeleton.adapter.outbound.httpclient.testkit.TestGateways;
import dev.caskeleton.adapter.outbound.httpclient.testkit.UserResponse;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
/** Concurrency beyond the pool must be bounded, not unbounded queueing (design §14, §28.8). */
class PoolSaturationPerformanceTest {
@Test
void concurrentCallsStayWithinTheDeclaredPoolAndFailFastBeyondIt() throws Exception {
int concurrency = 24;
try (MockHttpServer server = MockHttpServer.start()) {
ClientProfile profile =
ClientProfiles.builder("saturation")
.baseUrl(server.uri("/"))
.pool(
new PoolSettings(
4,
4,
8,
Duration.ofMillis(250),
Duration.ofSeconds(30),
Duration.ofMinutes(5),
Duration.ofSeconds(5),
Duration.ofSeconds(15),
Duration.ofSeconds(5),
false,
false))
.build();
for (int index = 0; index < concurrency; index++) {
server.enqueueJson(200, "{\"id\":1,\"name\":\"a\"}");
}
try (TestGateways.Harness harness = TestGateways.forProfile(profile)) {
AtomicInteger succeeded = new AtomicInteger();
AtomicInteger rejected = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(concurrency);
try {
for (int index = 0; index < concurrency; index++) {
pool.execute(
() -> {
try {
harness
.gateway()
.exchange(
profile.name(),
HttpOperation.get(new OperationName("get-user"), "/users/1", Map.of()),
ResponseType.of(UserResponse.class));
succeeded.incrementAndGet();
} catch (HttpBulkheadRejectedException
| HttpRateLimitRejectedException
| HttpPoolAcquireTimeoutException bounded) {
// Only the platform's own back-pressure counts as a bounded rejection. Catching
// RuntimeException made this assertion unfalsifiable: a NullPointerException, a
// serialization failure or a bug in the harness all counted as "the pool did
// its
// job", so the test would have passed while proving the opposite.
rejected.incrementAndGet();
}
});
}
pool.shutdown();
assertThat(pool.awaitTermination(60, TimeUnit.SECONDS)).isTrue();
} finally {
pool.shutdownNow();
}
PerformanceAssertions.structural(
"every call reached a terminal outcome",
succeeded.get() + rejected.get() == concurrency);
assertThat(succeeded.get()).isPositive();
}
}
}
}
@@ -0,0 +1,118 @@
package dev.caskeleton.adapter.outbound.httpclient.apache;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportCapabilities;
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportProvider;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey;
import io.micrometer.core.instrument.MeterRegistry;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.InetAddress;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
/**
* Blocking default transport (design D-06, §13.4).
*
* <p>The provider hands out a Spring {@code ClientHttpRequestFactory}; the {@code
* CloseableHttpClient} itself never escapes this package, which is what makes design §9.5's "no
* native client in the public API" enforceable rather than aspirational.
*/
public final class ApacheBlockingTransportProvider implements BlockingTransportProvider {
private static final TransportId ID = new TransportId("apache");
private final ApacheClientFactory clientFactory = new ApacheClientFactory();
private final ApacheFailureClassifier classifier = new ApacheFailureClassifier();
private final Map<TransportResourceKey, ApacheClientFactory.ApacheRuntime> runtimes =
new ConcurrentHashMap<>();
private final Optional<MeterRegistry> meterRegistry;
private final Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver;
private final Function<ClientProfile, Optional<Function<String, List<InetAddress>>>>
resolverFactory;
public ApacheBlockingTransportProvider() {
this(Optional.empty(), profile -> Optional.empty(), profile -> Optional.empty());
}
public ApacheBlockingTransportProvider(
Optional<MeterRegistry> meterRegistry,
Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver,
Function<ClientProfile, Optional<Function<String, List<InetAddress>>>> resolverFactory) {
this.meterRegistry = Objects.requireNonNull(meterRegistry, "meter registry");
this.tlsMaterialResolver = Objects.requireNonNull(tlsMaterialResolver, "tls material resolver");
this.resolverFactory = Objects.requireNonNull(resolverFactory, "dns resolver factory");
}
@Override
public TransportId id() {
return ID;
}
@Override
public BlockingTransportCapabilities capabilities() {
// Apache bounds the pending-acquire *wait* with connectionRequestTimeout; the pending-acquire
// *count* is bounded by the platform's logical admission limiter. Both halves of design §14.1
// are therefore satisfied for this transport.
//
// HTTP/1.1 only: Spring's HttpComponentsClientHttpRequestFactory drives the classic client, and
// Apache implements HTTP/2 in its async client. Blocking HTTP/2 is served by the JDK transport.
return BlockingTransportCapabilities.apacheClassic();
}
@Override
public ClientHttpRequestFactory create(
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(listener, "lifecycle listener");
ApacheClientFactory.ApacheRuntime runtime =
clientFactory.create(
profile, tlsMaterialResolver.apply(profile), resolverFactory.apply(profile));
runtimes.put(new TransportResourceKey(profile.name(), generation), runtime);
meterRegistry.ifPresent(
registry -> ApachePoolMetricsBinder.bind(registry, profile.name(), runtime.pool()));
listener.onRuntimeCreated(profile.name(), ID);
return new HttpComponentsClientHttpRequestFactory(runtime.client());
}
/** Live pool statistics; the pool-saturation and leak suites assert on these. */
public int leasedConnections(ClientProfileName profileName) {
return runtimes.entrySet().stream()
.filter(entry -> entry.getKey().profileName().equals(profileName))
.mapToInt(entry -> entry.getValue().pool().getTotalStats().getLeased())
.sum();
}
@Override
public TransportFailureClassifier failureClassifier() {
return classifier;
}
@Override
public void close(ClientProfile profile, RuntimeGeneration generation) {
ApacheClientFactory.ApacheRuntime runtime =
runtimes.remove(new TransportResourceKey(profile.name(), generation));
if (runtime == null) {
return;
}
try {
runtime.client().close();
} catch (IOException failure) {
throw new UncheckedIOException(failure);
} finally {
runtime.pool().close();
}
}
}
@@ -0,0 +1,140 @@
package dev.caskeleton.adapter.outbound.httpclient.apache;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import java.io.InterruptedIOException;
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.security.cert.CertificateException;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLPeerUnverifiedException;
import org.apache.hc.client5.http.ConnectTimeoutException;
import org.apache.hc.client5.http.HttpHostConnectException;
import org.apache.hc.core5.http.ConnectionClosedException;
/**
* Maps Apache HttpClient 5 failures onto stable evidence (design §13.3, §13.4).
*
* <p>The classifier is deliberately asymmetric: {@code NOT_SENT} is only produced for stages that
* prove nothing left the process. Once the request write has begun, an ambiguous I/O error stays
* {@code SENT_NO_RESPONSE}, because guessing "not sent" is what turns a timeout into a duplicate
* payment.
*/
public final class ApacheFailureClassifier implements TransportFailureClassifier {
@Override
public TransportFailure classify(Throwable failure, AttemptStage lastObservedStage) {
// The whole cause chain is inspected, not just the outermost throwable: Spring wraps engine
// exceptions, and a wrapped ConnectException still proves the request was never sent. Matching
// only the outer type would downgrade a provable NOT_SENT to an ambiguous SENT_NO_RESPONSE.
for (Throwable cause : chain(failure)) {
TransportFailure recognized = recognize(cause, lastObservedStage);
if (recognized != null) {
return recognized;
}
}
return conservative(lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
}
private TransportFailure recognize(Throwable cause, AttemptStage lastObservedStage) {
if (cause instanceof org.apache.hc.core5.concurrent.CancellableDependency) {
return TransportFailure.notSent(
AttemptStage.POOL_ACQUIRE, FailureCategory.POOL_ACQUIRE_TIMEOUT, "POOL_ACQUIRE_TIMEOUT");
}
if (isPoolAcquireTimeout(cause)) {
return TransportFailure.notSent(
AttemptStage.POOL_ACQUIRE, FailureCategory.POOL_ACQUIRE_TIMEOUT, "POOL_ACQUIRE_TIMEOUT");
}
if (cause instanceof UnknownHostException) {
return TransportFailure.notSent(
AttemptStage.DNS, FailureCategory.DNS, "DNS_RESOLUTION_FAILED");
}
if (cause instanceof ConnectTimeoutException
|| cause instanceof HttpHostConnectException
|| cause instanceof ConnectException
|| cause instanceof NoRouteToHostException) {
return TransportFailure.notSent(
AttemptStage.CONNECT, FailureCategory.CONNECT, "CONNECT_FAILED");
}
if (cause instanceof SSLPeerUnverifiedException || cause instanceof CertificateException) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_TRUST_FAILED");
}
if (cause instanceof SSLHandshakeException) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_HANDSHAKE_FAILED");
}
if (cause instanceof SSLException
&& !lastObservedStage.isAtLeast(AttemptStage.REQUEST_HEADERS)) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_TRANSIENT_FAILURE");
}
if (cause instanceof SocketTimeoutException) {
return timeout(lastObservedStage);
}
if (cause instanceof InterruptedIOException) {
// Not a timeout. A SocketTimeoutException means the peer went quiet; a bare
// InterruptedIOException usually means this thread was interrupted a cancellation, from a
// caller or a shutdown. Treating the two alike classified a cancellation as a transient
// timeout, which the retry engine then retried, so cancelling a call could produce more
// requests than not cancelling it. The interrupt flag is restored because swallowing it
// leaves the thread unable to observe its own cancellation.
Thread.currentThread().interrupt();
return conservative(lastObservedStage, FailureCategory.CANCELLED, "ATTEMPT_INTERRUPTED");
}
if (cause instanceof ConnectionClosedException) {
return conservative(
lastObservedStage, FailureCategory.RESPONSE_TRUNCATED, "CONNECTION_CLOSED");
}
return null;
}
private java.util.List<Throwable> chain(Throwable failure) {
java.util.List<Throwable> chain = new java.util.ArrayList<>();
Throwable current = failure;
while (current != null && !chain.contains(current)) {
chain.add(current);
current = current.getCause();
}
return chain;
}
private boolean isPoolAcquireTimeout(Throwable cause) {
String typeName = cause.getClass().getName();
return typeName.endsWith("ConnectionRequestTimeoutException");
}
private TransportFailure timeout(AttemptStage lastObservedStage) {
if (lastObservedStage.provesNotSent()) {
return TransportFailure.notSent(
lastObservedStage, FailureCategory.CONNECT, "CONNECT_TIMEOUT");
}
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
return new TransportFailure(
lastObservedStage,
ExecutionEvidence.PARTIAL_RESPONSE,
FailureCategory.RESPONSE_TIMEOUT,
"RESPONSE_BODY_TIMEOUT");
}
return TransportFailure.sentNoResponse(
AttemptStage.RESPONSE_HEADERS, FailureCategory.RESPONSE_TIMEOUT, "RESPONSE_HEADER_TIMEOUT");
}
private TransportFailure conservative(
AttemptStage lastObservedStage, FailureCategory category, String reason) {
if (lastObservedStage.provesNotSent()) {
return TransportFailure.notSent(lastObservedStage, category, reason);
}
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
return new TransportFailure(
lastObservedStage, ExecutionEvidence.PARTIAL_RESPONSE, category, reason);
}
return TransportFailure.sentNoResponse(lastObservedStage, category, reason);
}
}
@@ -0,0 +1,118 @@
package dev.caskeleton.adapter.outbound.httpclient.api.body;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability;
import java.util.Objects;
import java.util.OptionalLong;
/**
* DTO body encoded by a deterministic codec (design §23.1).
*
* <p>Replayability is a property of the value, not of the codec. Every {@code ObjectBody} used to
* report {@code REPLAYABLE} unconditionally, on the strength of a javadoc line asking callers not
* to mutate the value afterwards. A mutable DTO handed to the platform and then changed by the
* caller a builder reused across calls, a collection the caller kept a reference to produced a
* retry that sent <em>different bytes</em> under the same idempotency key, which is the one thing a
* replay must never do.
*
* <p>The check is structural and conservative: records, enums, strings, boxed primitives and
* immutable collection views replay; anything else is treated as one-shot, so the retry engine
* refuses rather than gambling. A caller who knows better can freeze the value itself serialize
* it to a {@code byte[]} body which states the guarantee instead of asserting it.
*/
public record ObjectBody(Object value, String mediaType) implements BodySource {
public ObjectBody {
Objects.requireNonNull(value, "object body value");
Objects.requireNonNull(mediaType, "object body media type");
}
public static ObjectBody json(Object value) {
return new ObjectBody(value, "application/json");
}
/**
* Describes the body without printing it.
*
* <p>The generated {@code toString} rendered the payload itself, so any log line or exception
* message that mentioned a body disclosed its contents which for an outbound call is by
* definition someone else's data.
*/
@Override
public String toString() {
return "ObjectBody[" + value.getClass().getSimpleName() + ", " + mediaType + ", REDACTED]";
}
@Override
public BodyReplayability replayability() {
return deeplyImmutable(value) ? BodyReplayability.REPLAYABLE : BodyReplayability.ONE_SHOT;
}
/**
* Whether re-encoding this value is guaranteed to produce the same bytes.
*
* <p>Records are accepted when every component is itself immutable, which covers the DTO shape
* the platform is built around without accepting a record that merely wraps a mutable list.
*/
private static boolean deeplyImmutable(Object candidate) {
if (candidate == null) {
return true;
}
if (candidate instanceof String
|| candidate instanceof Number
|| candidate instanceof Boolean
|| candidate instanceof Character
|| candidate instanceof Enum<?>
|| candidate instanceof java.util.UUID
|| candidate instanceof java.time.temporal.Temporal) {
return true;
}
if (candidate instanceof java.util.Collection<?> collection) {
return isImmutableCollectionView(collection)
&& collection.stream().allMatch(ObjectBody::deeplyImmutable);
}
if (candidate instanceof java.util.Map<?, ?> map) {
return isImmutableCollectionView(map)
&& map.entrySet().stream()
.allMatch(
entry -> deeplyImmutable(entry.getKey()) && deeplyImmutable(entry.getValue()));
}
Class<?> type = candidate.getClass();
if (!type.isRecord()) {
return false;
}
for (java.lang.reflect.RecordComponent component : type.getRecordComponents()) {
try {
java.lang.reflect.Method accessor = component.getAccessor();
accessor.setAccessible(true);
if (!deeplyImmutable(accessor.invoke(candidate))) {
return false;
}
} catch (ReflectiveOperationException | RuntimeException unreadable) {
// A component the platform cannot inspect cannot be certified, and an uncertified body is
// one-shot rather than optimistically replayable.
return false;
}
}
return true;
}
/**
* Whether the collection is one of the JDK's unmodifiable views.
*
* <p>Name-based because {@code List.of(...)} and {@code Collections.unmodifiableList(...)} return
* package-private classes with no shared marker interface. An ordinary {@code ArrayList} the
* caller still holds is exactly the case this must not accept.
*/
private static boolean isImmutableCollectionView(Object collection) {
String name = collection.getClass().getName();
return name.startsWith("java.util.ImmutableCollections")
|| name.startsWith("java.util.Collections$Unmodifiable")
|| name.startsWith("java.util.Collections$Empty")
|| name.startsWith("java.util.Collections$Singleton");
}
@Override
public OptionalLong knownLength() {
return OptionalLong.empty();
}
}
@@ -0,0 +1,152 @@
package dev.caskeleton.adapter.outbound.httpclient.api.operation;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey;
import dev.caskeleton.adapter.outbound.httpclient.api.OperationName;
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Immutable description of one logical outbound call (design §10.1).
*
* <p>The operation carries the URI <em>template</em>, never an expanded URL: observability tags and
* failure metadata must stay low-cardinality, and the security layer expands components itself.
*/
public record HttpOperation(
OperationName operationName,
HttpMethod method,
String uriTemplate,
Map<String, ?> uriVariables,
Map<String, List<String>> headers,
BodySource body,
OperationIdempotency idempotency,
Optional<IdempotencyKey> idempotencyKey,
Optional<Instant> deadline) {
public HttpOperation {
Objects.requireNonNull(operationName, "operation name");
Objects.requireNonNull(method, "http method");
Objects.requireNonNull(uriTemplate, "uri template");
Objects.requireNonNull(uriVariables, "uri variables");
Objects.requireNonNull(headers, "headers");
Objects.requireNonNull(body, "body");
Objects.requireNonNull(idempotency, "idempotency");
Objects.requireNonNull(idempotencyKey, "idempotency key");
Objects.requireNonNull(deadline, "deadline");
if (idempotency == OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED && idempotencyKey.isEmpty()) {
throw new IllegalArgumentException("idempotency key is required for this operation");
}
uriVariables = Map.copyOf(uriVariables);
headers = copyHeaders(headers);
}
public static HttpOperation get(
OperationName operationName, String uriTemplate, Map<String, ?> uriVariables) {
return new HttpOperation(
operationName,
HttpMethod.GET,
uriTemplate,
uriVariables,
Map.of(),
EmptyBody.instance(),
OperationIdempotency.STANDARD_IDEMPOTENT,
Optional.empty(),
Optional.empty());
}
public HttpOperation withHeaders(Map<String, List<String>> replacement) {
return new HttpOperation(
operationName,
method,
uriTemplate,
uriVariables,
replacement,
body,
idempotency,
idempotencyKey,
deadline);
}
public HttpOperation withBody(BodySource replacement) {
return new HttpOperation(
operationName,
method,
uriTemplate,
uriVariables,
headers,
replacement,
idempotency,
idempotencyKey,
deadline);
}
public HttpOperation withMethod(HttpMethod replacement) {
return new HttpOperation(
operationName,
replacement,
uriTemplate,
uriVariables,
headers,
body,
idempotency,
idempotencyKey,
deadline);
}
private static Map<String, List<String>> copyHeaders(Map<String, List<String>> headers) {
Map<String, List<String>> copy = new LinkedHashMap<>();
headers.forEach(
(name, values) -> {
Objects.requireNonNull(name, "header name");
Objects.requireNonNull(values, "header values");
copy.put(name, List.copyOf(new ArrayList<>(values)));
});
return Map.copyOf(copy);
}
/**
* Low-cardinality, secret-free description.
*
* <p>The record's generated {@code toString} printed every header value, the body object and the
* expanded URI variables. That string reaches a log the moment an operation appears in an
* exception message, a debug statement or an assertion failure so an {@code Authorization}
* header, a request payload and a customer identifier were one stack trace away from the log
* aggregator. The template is safe by construction; the values are not, and none of them are
* needed to identify which operation this is.
*/
@Override
public String toString() {
return "HttpOperation["
+ operationName.value()
+ ' '
+ method
+ ' '
+ uriTemplate
+ ", headers="
+ headers.keySet()
+ ", body="
+ body.getClass().getSimpleName()
+ ", idempotency="
+ idempotency
+ ", idempotencyKey="
+ (idempotencyKey.isPresent() ? "PRESENT" : "ABSENT")
+ ']';
}
/** Case-insensitive single header lookup used by the request writer and redirect coordinator. */
public Optional<String> firstHeader(String name) {
String wanted = name.toLowerCase(Locale.ROOT);
return headers.entrySet().stream()
.filter(entry -> entry.getKey().toLowerCase(Locale.ROOT).equals(wanted))
.flatMap(entry -> entry.getValue().stream())
.findFirst();
}
}
@@ -0,0 +1,66 @@
package dev.caskeleton.adapter.outbound.httpclient.auth;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.OperationName;
import dev.caskeleton.adapter.outbound.httpclient.profile.AuthenticationSettings;
import java.net.URI;
import java.util.Objects;
import java.util.Optional;
/**
* Input to credential materialization (design §20.2).
*
* <p>The principal is passed explicitly rather than read from ambient thread state: design §26.3
* forbids implicitly picking up a user token, because that silently turns a machine-to-machine call
* into a user-scoped one.
*/
public record CredentialRequest(
ClientProfileName clientName,
OperationName operationName,
AuthenticationSettings settings,
URI target,
Optional<Object> principal,
Optional<String> clientCertificateIdentity,
boolean forceRefresh) {
/**
* Identifies the request without exposing the principal or the target URL.
*
* <p>The generated {@code toString} printed the authenticated principal and the full target URI,
* including any query string. A credential-resolution failure is exactly when this record ends up
* in a log line, which made the failure path the most likely place for a user identity and a
* signed URL to escape.
*/
@Override
public String toString() {
return "CredentialRequest["
+ clientName.value()
+ ' '
+ operationName.value()
+ ", type="
+ settings.type()
+ ", target="
+ target.getScheme()
+ "://"
+ target.getHost()
+ ", principal="
+ (principal.isPresent() ? "PRESENT" : "ABSENT")
+ ", forceRefresh="
+ forceRefresh
+ ']';
}
public CredentialRequest {
Objects.requireNonNull(clientName, "client name");
Objects.requireNonNull(operationName, "operation name");
Objects.requireNonNull(settings, "authentication settings");
Objects.requireNonNull(target, "target");
Objects.requireNonNull(principal, "principal");
Objects.requireNonNull(clientCertificateIdentity, "client certificate identity");
}
public CredentialRequest refreshed() {
return new CredentialRequest(
clientName, operationName, settings, target, principal, clientCertificateIdentity, true);
}
}
@@ -0,0 +1,128 @@
package dev.caskeleton.adapter.outbound.httpclient.auth;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAuthenticationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
/**
* OAuth2 access tokens obtained through Spring Security (design D-13, §20.3).
*
* <p>Token acquisition is delegated, but three rules are owned here because they are what make the
* result safe under load: the cache key, single-flight refresh, and an expiry skew so a token is
* replaced before an upstream starts rejecting it.
*/
public final class OAuth2CredentialProvider implements RequestCredentialProvider {
/**
* Attribute name for the audience a token is requested for.
*
* <p>Not standardised in OAuth2 core, so it is spelled out here rather than borrowed from a
* constant that does not exist; authorization servers that support audience restriction read this
* parameter name.
*/
private static final String OAUTH2_AUDIENCE_ATTRIBUTE = "audience";
private static final Duration EXPIRY_SKEW = Duration.ofSeconds(30);
private final OAuth2AuthorizedClientManager authorizedClientManager;
private final SingleFlightTokenLoader tokenLoader;
private final ConcurrentMap<OAuth2TokenCacheKey, AccessToken> cache = new ConcurrentHashMap<>();
private final Clock clock;
public OAuth2CredentialProvider(
OAuth2AuthorizedClientManager authorizedClientManager, Clock clock) {
this.authorizedClientManager =
Objects.requireNonNull(authorizedClientManager, "authorized client manager");
this.clock = Objects.requireNonNull(clock, "clock");
this.tokenLoader = new SingleFlightTokenLoader(this::loadToken);
}
@Override
public CredentialType type() {
return CredentialType.OAUTH2_CLIENT_CREDENTIALS;
}
@Override
public RequestCredentials resolve(CredentialRequest request) {
OAuth2TokenCacheKey key = cacheKey(request);
if (request.forceRefresh()) {
cache.remove(key);
}
AccessToken token =
cache.compute(
key,
(ignored, existing) ->
existing == null || existing.expired(clock, EXPIRY_SKEW)
? tokenLoader.load(key)
: existing);
return RequestCredentials.header("Authorization", "Bearer " + token.value());
}
@Override
public void invalidate(CredentialRequest request) {
cache.remove(cacheKey(request));
}
private OAuth2TokenCacheKey cacheKey(CredentialRequest request) {
String registrationId =
request
.settings()
.registrationId()
.orElseThrow(
() ->
new HttpAuthenticationException(
"oauth2 authentication requires a registration id",
HttpFailureMetadata.startup(request.clientName())));
return new OAuth2TokenCacheKey(
registrationId,
request.principal().map(principal -> principal.getClass().getName()).orElse("anonymous"),
request.settings().scopes(),
request.settings().audience(),
Optional.empty(),
request.clientCertificateIdentity());
}
/**
* Authorizes, carrying the scopes and audience the profile declared.
*
* <p>Both used to be part of the cache key and part of nothing else. The platform cached tokens
* <em>as though</em> they differed by scope while every authorize request asked for the
* registration's default scopes, so a profile that declared a narrower scope set received a
* broader token and a profile that declared a wider one received a token missing the scopes it
* needed and the cache confidently kept them apart. Attributes are the mechanism Spring's
* authorized-client manager passes through to the token request.
*/
private AccessToken loadToken(OAuth2TokenCacheKey key) {
OAuth2AuthorizeRequest.Builder builder =
OAuth2AuthorizeRequest.withClientRegistrationId(key.registrationId())
.principal(key.principalClass());
if (!key.scopes().isEmpty()) {
builder.attribute(
org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.SCOPE,
String.join(" ", key.scopes()));
}
key.audience().ifPresent(audience -> builder.attribute(OAUTH2_AUDIENCE_ATTRIBUTE, audience));
OAuth2AuthorizeRequest authorizeRequest = builder.build();
OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
if (authorizedClient == null) {
throw new IllegalStateException(
"no oauth2 authorized client for registration " + key.registrationId());
}
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
Instant expiresAt =
accessToken.getExpiresAt() == null
? clock.instant().plus(Duration.ofMinutes(5))
: accessToken.getExpiresAt();
return new AccessToken(accessToken.getTokenValue(), expiresAt);
}
}
@@ -0,0 +1,79 @@
package dev.caskeleton.adapter.outbound.httpclient.auth;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAuthenticationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
import reactor.core.publisher.Mono;
/**
* Dispatches a reactive call to the provider its profile declared (design §20.1, §20.2).
*
* <p>The reactive runtime takes a single {@link ReactiveRequestCredentialProvider}, and the
* composition root used to hand it {@link NoAuthCredentialProvider} unconditionally. A profile that
* declared {@code BASIC}, {@code API_KEY_HEADER} or {@code STATIC_BEARER} and used the reactive API
* therefore sent no credential at all no startup error, no runtime error, just an unauthenticated
* request that the upstream answered 401 and the platform reported as a remote failure.
*
* <p>This registry is the same shape as the blocking {@link CredentialProviderRegistry}: selection
* is by declaration, and a type nobody registered is an error rather than a silent downgrade to
* anonymous. The dispatch reads the type off the request, so one instance serves every profile.
*/
public final class ReactiveCredentialProviderRegistry implements ReactiveRequestCredentialProvider {
private final Map<CredentialType, ReactiveRequestCredentialProvider> providers =
new EnumMap<>(CredentialType.class);
public ReactiveCredentialProviderRegistry register(ReactiveRequestCredentialProvider provider) {
Objects.requireNonNull(provider, "reactive credential provider");
providers.put(provider.type(), provider);
return this;
}
/** Adapts a provider whose resolution is a synchronous computation rather than I/O. */
public ReactiveCredentialProviderRegistry registerNonBlocking(
RequestCredentialProvider provider) {
return register(ReactiveRequestCredentialProvider.fromNonBlocking(provider));
}
public static ReactiveCredentialProviderRegistry withNoAuth() {
return new ReactiveCredentialProviderRegistry()
.registerNonBlocking(new NoAuthCredentialProvider());
}
/**
* The dispatch itself is a credential type of its own only in the degenerate sense; callers
* select by request, so this reports {@code NONE}.
*/
@Override
public CredentialType type() {
return CredentialType.NONE;
}
@Override
public Mono<RequestCredentials> resolve(CredentialRequest request) {
return provider(request).flatMap(provider -> provider.resolve(request));
}
@Override
public Mono<Void> invalidate(CredentialRequest request) {
return provider(request).flatMap(provider -> provider.invalidate(request));
}
public boolean supports(CredentialType credentialType) {
return providers.containsKey(credentialType);
}
private Mono<ReactiveRequestCredentialProvider> provider(CredentialRequest request) {
CredentialType credentialType = CredentialType.from(request.settings().type());
ReactiveRequestCredentialProvider provider = providers.get(credentialType);
if (provider == null) {
return Mono.error(
new HttpAuthenticationException(
"no reactive credential provider is registered for " + credentialType,
HttpFailureMetadata.startup(request.clientName())));
}
return Mono.just(provider);
}
}
@@ -0,0 +1,148 @@
package dev.caskeleton.adapter.outbound.httpclient.auth;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAuthenticationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
/**
* Collapses concurrent refreshes of the same token into one (design §20.3).
*
* <p>Without this, a token expiring under load produces one token request per in-flight call, which
* is exactly when the authorization server can least afford them and several providers rate-limit
* or invalidate on that pattern.
*
* <p>Two things about <em>where</em> that refresh runs used to be wrong, and both bite under
* exactly the load this class exists for.
*
* <p>It ran on the common {@link java.util.concurrent.ForkJoinPool}. That pool is sized for
* CPU-bound work and shared with every parallel stream in the process; a token endpoint that goes
* slow therefore parks common-pool threads and stalls unrelated work across the application. The
* refresh now runs on a small bounded pool of its own, so a slow authorization server costs only
* the threads dedicated to talking to it.
*
* <p>And the wait was {@code join()} unbounded. A token endpoint that accepted the connection and
* never answered blocked every caller of that credential indefinitely, past their own deadlines,
* with no exception to attribute it to. The wait is bounded and expiry is a stable authentication
* failure.
*/
public final class SingleFlightTokenLoader implements AutoCloseable {
/** Small on purpose: this pool exists to talk to one authorization server, not to scale out. */
private static final int DEFAULT_POOL_SIZE = 2;
private static final Duration DEFAULT_ACQUIRE_TIMEOUT = Duration.ofSeconds(10);
private final ConcurrentMap<OAuth2TokenCacheKey, CompletableFuture<AccessToken>> inFlight =
new ConcurrentHashMap<>();
private final Function<OAuth2TokenCacheKey, AccessToken> delegate;
private final ExecutorService refreshExecutor;
private final Duration acquireTimeout;
private final AtomicInteger joinedCallers = new AtomicInteger();
public SingleFlightTokenLoader(Function<OAuth2TokenCacheKey, AccessToken> delegate) {
this(delegate, DEFAULT_POOL_SIZE, DEFAULT_ACQUIRE_TIMEOUT);
}
public SingleFlightTokenLoader(
Function<OAuth2TokenCacheKey, AccessToken> delegate, int poolSize, Duration acquireTimeout) {
this.delegate = Objects.requireNonNull(delegate, "token loader delegate");
this.acquireTimeout = Objects.requireNonNull(acquireTimeout, "acquire timeout");
if (poolSize < 1) {
throw new IllegalArgumentException("token refresh pool size must be positive");
}
if (acquireTimeout.isNegative() || acquireTimeout.isZero()) {
throw new IllegalArgumentException("token acquire timeout must be positive");
}
this.refreshExecutor = Executors.newFixedThreadPool(poolSize, refreshThreadFactory());
}
public AccessToken load(OAuth2TokenCacheKey key) {
Objects.requireNonNull(key, "token cache key");
joinedCallers.incrementAndGet();
CompletableFuture<AccessToken> future =
inFlight.computeIfAbsent(
key,
ignored -> CompletableFuture.supplyAsync(() -> delegate.apply(key), refreshExecutor));
try {
return future.get(acquireTimeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException timedOut) {
// Cancelled rather than abandoned: leaving it running would let the next caller join a
// refresh that has already outlived its usefulness.
future.cancel(true);
throw new HttpAuthenticationException(
"oauth2 token refresh did not complete within " + acquireTimeout,
HttpFailureMetadata.startup(
new dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName(
key.registrationId())),
timedOut);
} catch (ExecutionException failed) {
Throwable cause = failed.getCause() == null ? failed : failed.getCause();
if (cause instanceof RuntimeException runtime) {
throw runtime;
}
throw new HttpAuthenticationException(
"oauth2 token refresh failed",
HttpFailureMetadata.startup(
new dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName(
key.registrationId())),
cause);
} catch (InterruptedException interrupted) {
// The caller was cancelled; propagate the interrupt rather than swallowing it.
Thread.currentThread().interrupt();
future.cancel(true);
throw new HttpAuthenticationException(
"oauth2 token refresh was interrupted",
HttpFailureMetadata.startup(
new dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName(
key.registrationId())),
interrupted);
} finally {
inFlight.remove(key, future);
}
}
public int inFlightRefreshes() {
return inFlight.size();
}
/**
* How many callers have entered {@link #load} over this loader's lifetime.
*
* <p>Exposed so a contention test can wait for an observable condition "all N callers have
* joined" — instead of sleeping for an arbitrary interval and hoping. A sleep-based test is
* simultaneously slower than it needs to be and unreliable on a loaded machine, which is the
* worst pair of properties for a test that only fails intermittently.
*
* @return the cumulative number of {@code load} entries
*/
public int joinedCallers() {
return joinedCallers.get();
}
@Override
public void close() {
refreshExecutor.shutdownNow();
}
private static ThreadFactory refreshThreadFactory() {
AtomicInteger counter = new AtomicInteger();
return runnable -> {
Thread thread =
new Thread(runnable, "httpclient-oauth2-refresh-" + counter.incrementAndGet());
thread.setDaemon(true);
return thread;
};
}
}
@@ -0,0 +1,78 @@
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
import java.net.InetAddress;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Carries the approved addresses of one dynamic call from validation to the socket.
*
* <p>This is the piece the SSRF defence was missing. {@link ValidatedDnsResolver} resolved a host,
* rejected the target if any answer was forbidden, and produced a {@link PinnedTarget} holding the
* exact approved addresses and then the gateway handed the transport a URL containing the
* <em>hostname</em>, and the transport resolved it again. Everything between the two resolutions
* was unvalidated: a DNS server under an attacker's control answers the first query with a public
* address and the second with {@code 169.254.169.254}, and the platform connects to the metadata
* service having "validated" the target. The classic rebinding attack, defeated by a check that
* discarded its own result.
*
* <p>The pin is thread-scoped because the dynamic gateway is blocking and thread-confined for the
* duration of a physical request; it is installed around the attempt and removed in a {@code
* finally}, so a pooled thread never carries one call's addresses into another's.
*
* <p>It deliberately does not cache. The previous approved-address map in the resolver lived for
* the lifetime of the process with no TTL and no bound, which is a second, slower version of the
* same problem: an address approved an hour ago is not evidence about the host now.
*/
public final class CallScopedDnsPin implements AutoCloseable {
private static final ThreadLocal<Map<String, List<InetAddress>>> CURRENT = new ThreadLocal<>();
private CallScopedDnsPin() {}
/**
* Installs the pin for the current thread.
*
* @param host the canonical host the addresses were approved for
* @param approvedAddresses the addresses the transport may connect to
* @return a handle that removes the pin
*/
public static CallScopedDnsPin open(String host, List<InetAddress> approvedAddresses) {
Objects.requireNonNull(host, "host");
Objects.requireNonNull(approvedAddresses, "approved addresses");
if (approvedAddresses.isEmpty()) {
throw new IllegalArgumentException("a dns pin needs at least one approved address");
}
CURRENT.set(Map.of(host.toLowerCase(java.util.Locale.ROOT), List.copyOf(approvedAddresses)));
return new CallScopedDnsPin();
}
/**
* The addresses the current call approved for a host.
*
* <p>An empty result means this host was not the one validated. The transport must then refuse
* rather than fall back to a system lookup a fallback would restore exactly the second,
* unvalidated resolution this class exists to remove.
*
* @param host the host the transport is about to connect to
* @return the approved addresses, or empty when the host was not pinned by this call
*/
public static List<InetAddress> addressesFor(String host) {
Map<String, List<InetAddress>> pinned = CURRENT.get();
if (pinned == null || host == null) {
return List.of();
}
return pinned.getOrDefault(host.toLowerCase(java.util.Locale.ROOT), List.of());
}
/** Whether a pin is installed on this thread. */
public static boolean active() {
return CURRENT.get() != null;
}
@Override
public void close() {
CURRENT.remove();
}
}
@@ -0,0 +1,228 @@
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRedirectRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
import dev.caskeleton.adapter.outbound.httpclient.api.result.IdempotencyKeyRequirement;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
import dev.caskeleton.adapter.outbound.httpclient.observation.HttpClientObservationNames;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingAttemptExecutor;
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.restclient.StatusHandlingPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.HeaderPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedTarget;
import io.micrometer.core.instrument.Tag;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
/**
* Executes a user-supplied URL under an explicit SSRF policy (design §22).
*
* <p>Every hop including the first goes through canonicalize allowlist resolve-all
* classify pin. Redirects are followed here rather than by the blocking coordinator precisely so
* the validation cannot be skipped for hop two.
*
* <p>No trusted credential, Cookie jar, or default header is inherited. A credential is attached
* only when a {@link DynamicCredentialBinding} names that exact canonical host.
*/
public final class DefaultDynamicTargetGateway implements DynamicTargetGateway {
private final Map<DynamicTargetPolicyName, DynamicTargetPolicy> policies;
private final Map<DynamicTargetPolicyName, ValidatedDnsResolver> resolvers;
private final ClientRuntimeRegistry runtimes;
private final BlockingAttemptExecutor executor;
private final TargetCanonicalizer canonicalizer;
private final List<DynamicCredentialBinding> credentialBindings;
private final Function<String, String> secretResolver;
public DefaultDynamicTargetGateway(
Map<DynamicTargetPolicyName, DynamicTargetPolicy> policies,
Map<DynamicTargetPolicyName, ValidatedDnsResolver> resolvers,
ClientRuntimeRegistry runtimes,
BlockingAttemptExecutor executor,
List<DynamicCredentialBinding> credentialBindings,
Function<String, String> secretResolver) {
this.policies = Map.copyOf(Objects.requireNonNull(policies, "dynamic target policies"));
this.resolvers = Map.copyOf(Objects.requireNonNull(resolvers, "validated dns resolvers"));
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
this.executor = Objects.requireNonNull(executor, "attempt executor");
this.credentialBindings = List.copyOf(Objects.requireNonNull(credentialBindings, "bindings"));
this.secretResolver = Objects.requireNonNull(secretResolver, "secret resolver");
this.canonicalizer = new TargetCanonicalizer();
}
@Override
public <T> HttpCallResult<T> exchange(
DynamicTargetPolicyName policyName,
URI target,
HttpOperation operation,
ResponseType<T> responseType) {
Objects.requireNonNull(policyName, "policy name");
Objects.requireNonNull(target, "target");
Objects.requireNonNull(operation, "operation");
DynamicTargetPolicy policy = requirePolicy(policyName);
ValidatedDnsResolver resolver = requireResolver(policyName);
try (ClientRuntimeLease lease = runtimes.acquire(new ClientProfileName(policyName.value()))) {
BlockingClientRuntime runtime = requireBlockingRuntime(lease);
HttpFailureMetadata metadata =
HttpFailureMetadata.validation(
runtime.name(),
operation.operationName(),
operation.method(),
operation.uriTemplate(),
operation.body().replayability());
URI current = target;
HttpMethod method = operation.method();
for (int hop = 0; ; hop++) {
PinnedTarget pinned = validate(policy, resolver, current);
PreparedOperation prepared =
prepare(runtime, operation.withMethod(method), pinned, metadata);
RequestCredentials credentials = credentialsFor(pinned.target());
// The pin is what makes the validation above binding. Without it the transport resolved the
// hostname a second time and could land anywhere; with it the socket may only reach an
// address this hop actually approved. Scoped to the hop, so the next redirect re-validates
// and re-pins rather than inheriting an earlier decision.
AttemptOutcome<T> outcome;
try (CallScopedDnsPin pin =
CallScopedDnsPin.open(pinned.target().host(), pinned.approvedAddresses())) {
Objects.requireNonNull(pin, "dns pin");
outcome =
executor.execute(
runtime,
prepared,
responseType,
StatusHandlingPolicy.RETURN_RESULT,
credentials,
1,
runtime.support().clock().instant(),
metadata);
} finally {
// Discarded at the end of the hop. Retaining it would grow without bound and, worse,
// would let a later call reuse an approval that was only ever made for this one.
resolver.forget(pinned.target().host());
}
HttpCallResult<T> result =
outcome.result().orElseThrow(() -> outcome.failure().orElseThrow());
Optional<URI> location = redirectLocation(result, pinned);
if (location.isEmpty()) {
return result;
}
if (hop >= policy.maxRedirectHops()) {
recordRejection(runtime, policyName, "MAX_HOPS");
throw new HttpRedirectRejectedException(
"dynamic target redirect exceeded the policy hop limit", metadata);
}
// 303 turns the follow-up into a GET; every other redirect keeps the method, and the next
// loop iteration revalidates the new target from scratch.
method = result.status().value() == 303 ? HttpMethod.GET : method;
current = location.get();
}
}
}
private PinnedTarget validate(
DynamicTargetPolicy policy, ValidatedDnsResolver resolver, URI target) {
CanonicalTarget canonical = canonicalizer.canonicalize(policy, target);
return resolver.pin(canonical);
}
private PreparedOperation prepare(
BlockingClientRuntime runtime,
HttpOperation operation,
PinnedTarget pinned,
HttpFailureMetadata metadata) {
Map<String, List<String>> headers =
HeaderPolicy.forOperation(IdempotencyKeyRequirement.none(), false)
.validate(operation.headers(), metadata);
runtime.bodyLimitPolicy().validate(operation.body(), metadata);
PreparedTarget target = PreparedTarget.of(pinned.target().toUri(), operation.uriTemplate());
return new PreparedOperation(
operation,
target,
headers,
runtime.profile().request().maxBodyBytes(),
runtime.profile().response().maxWireBytes(),
runtime.profile().response().maxDecodedBytes());
}
private <T> Optional<URI> redirectLocation(HttpCallResult<T> result, PinnedTarget pinned) {
int status = result.status().value();
if (status != 301 && status != 302 && status != 303 && status != 307 && status != 308) {
return Optional.empty();
}
return result.headers().entrySet().stream()
.filter(entry -> entry.getKey().equalsIgnoreCase("Location"))
.flatMap(entry -> entry.getValue().stream())
.findFirst()
.map(location -> pinned.target().toUri().resolve(location));
}
private RequestCredentials credentialsFor(CanonicalTarget target) {
Map<String, String> headers = new LinkedHashMap<>();
credentialBindings.stream()
.filter(binding -> binding.matches(target))
.forEach(
binding ->
headers.put(binding.headerName(), secretResolver.apply(binding.secretReference())));
return headers.isEmpty()
? RequestCredentials.none()
: new RequestCredentials(headers, Map.of());
}
private void recordRejection(
BlockingClientRuntime runtime, DynamicTargetPolicyName policyName, String reason) {
runtime
.support()
.meterRegistry()
.counter(
HttpClientObservationNames.SSRF_REJECTED,
List.of(Tag.of("clientName", policyName.value()), Tag.of("outcome", reason)))
.increment();
}
private DynamicTargetPolicy requirePolicy(DynamicTargetPolicyName name) {
DynamicTargetPolicy policy = policies.get(name);
if (policy == null) {
throw new NoSuchElementException("unregistered dynamic target policy: " + name.value());
}
return policy;
}
private ValidatedDnsResolver requireResolver(DynamicTargetPolicyName name) {
ValidatedDnsResolver resolver = resolvers.get(name);
if (resolver == null) {
throw new NoSuchElementException(
"no validated dns resolver for dynamic target policy: " + name.value());
}
return resolver;
}
private BlockingClientRuntime requireBlockingRuntime(ClientRuntimeLease lease) {
if (lease.runtime() instanceof BlockingClientRuntime blocking) {
return blocking;
}
throw new HttpTargetRejectedException(
"dynamic target policy is not bound to a blocking runtime",
HttpFailureMetadata.startup(lease.runtime().name()));
}
}
@@ -0,0 +1,79 @@
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/**
* An explicitly registered credential for one dynamic origin (design §9.4).
*
* <p>Dynamic targets inherit nothing. If a specific origin genuinely needs a credential, a security
* owner registers this binding for that exact scheme, host and port which makes the decision
* auditable instead of implicit.
*
* <p>The binding used to match on host alone. That sent the credential to {@code http://host} as
* readily as to {@code https://host}, and to any port the same host happened to serve so a target
* that downgraded to plaintext, or pointed at a different service on the same machine, received a
* secret registered for neither.
*
* @param scheme the exact scheme the credential is registered for
* @param canonicalHost the exact canonical host
* @param port the exact port
* @param headerName the header to carry the credential in; must be on the allowlist
* @param secretReference the reference a secret backend resolves
*/
public record DynamicCredentialBinding(
String scheme, String canonicalHost, int port, String headerName, String secretReference) {
/**
* Header names a dynamic credential may use.
*
* <p>An allowlist because the header name decides who reads the secret. Without one a binding
* could put a credential in {@code Host}, {@code Origin} or a header a proxy forwards onward.
*/
private static final Set<String> ALLOWED_HEADER_NAMES =
Set.of("authorization", "x-api-key", "api-key", "x-client-key", "x-webhook-token");
public DynamicCredentialBinding {
Objects.requireNonNull(scheme, "scheme");
Objects.requireNonNull(canonicalHost, "canonical host");
Objects.requireNonNull(headerName, "header name");
Objects.requireNonNull(secretReference, "secret reference");
if (scheme.isBlank()
|| canonicalHost.isBlank()
|| headerName.isBlank()
|| secretReference.isBlank()) {
throw new IllegalArgumentException("dynamic credential binding fields must not be blank");
}
if (port < 1 || port > 65535) {
throw new IllegalArgumentException("dynamic credential binding port must be 1..65535");
}
if (!ALLOWED_HEADER_NAMES.contains(headerName.toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException(
"dynamic credential header "
+ headerName
+ " is not on the allowlist "
+ ALLOWED_HEADER_NAMES);
}
scheme = scheme.toLowerCase(Locale.ROOT);
canonicalHost = canonicalHost.toLowerCase(Locale.ROOT);
}
/** Convenience for the common case: HTTPS on the default port. */
public static DynamicCredentialBinding httpsOn(
String canonicalHost, String headerName, String secretReference) {
return new DynamicCredentialBinding("https", canonicalHost, 443, headerName, secretReference);
}
/**
* Matches only the exact origin.
*
* @param target the canonicalized target of this call
* @return {@code true} when scheme, host and port all match
*/
public boolean matches(CanonicalTarget target) {
return scheme.equals(target.scheme())
&& canonicalHost.equals(target.host())
&& port == target.port();
}
}
@@ -0,0 +1,86 @@
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
import java.util.Objects;
import java.util.Set;
/**
* The rules a user-supplied URL must satisfy (design §22).
*
* <p>A Dynamic Target policy is deliberately separate from a Named Client Profile: it inherits no
* credential, no Cookie jar, and no default header (design D-03), so a webhook checker cannot
* accidentally speak with a trusted client's identity.
*/
public record DynamicTargetPolicy(
DynamicTargetPolicyName name,
Set<String> allowedSchemes,
Set<Integer> allowedPorts,
Set<String> allowedHostSuffixes,
Set<String> allowedHosts,
int maxRedirectHops,
boolean tracePropagation,
java.util.List<String> additionalBlockedCidrs) {
public DynamicTargetPolicy {
Objects.requireNonNull(name, "policy name");
Objects.requireNonNull(allowedSchemes, "allowed schemes");
Objects.requireNonNull(allowedPorts, "allowed ports");
Objects.requireNonNull(allowedHostSuffixes, "allowed host suffixes");
Objects.requireNonNull(allowedHosts, "allowed hosts");
Objects.requireNonNull(additionalBlockedCidrs, "additional blocked cidrs");
if (maxRedirectHops < 0) {
throw new IllegalArgumentException("max redirect hops must not be negative");
}
allowedSchemes = Set.copyOf(allowedSchemes);
allowedPorts = Set.copyOf(allowedPorts);
allowedHostSuffixes = Set.copyOf(allowedHostSuffixes);
allowedHosts = Set.copyOf(allowedHosts);
additionalBlockedCidrs = java.util.List.copyOf(additionalBlockedCidrs);
}
/** HTTPS-only public egress with no redirect following: the safest useful default. */
public static DynamicTargetPolicy publicHttpsOnly(String name) {
return new DynamicTargetPolicy(
new DynamicTargetPolicyName(name),
Set.of("https"),
Set.of(443),
Set.of(),
Set.of(),
0,
false,
java.util.List.of());
}
public boolean hostAllowed(String canonicalHost) {
if (allowedHosts.isEmpty() && allowedHostSuffixes.isEmpty()) {
return true;
}
if (allowedHosts.contains(canonicalHost)) {
return true;
}
return allowedHostSuffixes.stream()
.anyMatch(suffix -> isSubdomainOrExactMatch(canonicalHost, suffix));
}
/**
* Matches a suffix only at a label boundary.
*
* <p>Plain {@code endsWith} is not a domain rule. A policy allowing {@code example.com} also
* accepted {@code evil-example.com}, which an attacker registers precisely because the check is
* written this way the allowlist then reads as a restriction while permitting any domain whose
* name happens to end in the allowed text.
*
* <p>A leading dot in the configured suffix is tolerated and means the same thing, so {@code
* .example.com} and {@code example.com} both allow {@code api.example.com} and the apex.
*/
private static boolean isSubdomainOrExactMatch(String canonicalHost, String configuredSuffix) {
String suffix =
configuredSuffix.startsWith(".") ? configuredSuffix.substring(1) : configuredSuffix;
if (suffix.isEmpty()) {
return false;
}
if (canonicalHost.equals(suffix)) {
return true;
}
return canonicalHost.endsWith("." + suffix);
}
}
@@ -0,0 +1,215 @@
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* Decides whether a resolved address may be connected to (design §22.2).
*
* <p>Two details matter more than the list itself. IPv4-mapped IPv6 addresses are normalised back
* to IPv4 before classification, because {@code ::ffff:127.0.0.1} is loopback wearing a different
* hat. And cloud metadata endpoints are blocked explicitly rather than relying on the link-local
* rule, so an organisation-specific metadata address is still covered.
*/
public final class IpAddressClassifier {
private static final Set<String> METADATA_ADDRESSES =
Set.of("169.254.169.254", "fd00:ec2::254", "100.100.100.200", "192.0.0.192");
private final List<CidrRange> additionalBlockedRanges;
public IpAddressClassifier() {
this(List.of());
}
public IpAddressClassifier(List<String> additionalBlockedCidrs) {
Objects.requireNonNull(additionalBlockedCidrs, "additional blocked cidrs");
this.additionalBlockedRanges = additionalBlockedCidrs.stream().map(CidrRange::parse).toList();
}
/** Normalises an IPv4-mapped IPv6 address to its IPv4 form. */
public static InetAddress normalize(InetAddress address) {
if (!(address instanceof Inet6Address ipv6)) {
return address;
}
byte[] bytes = ipv6.getAddress();
boolean mapped = true;
for (int index = 0; index < 10; index++) {
if (bytes[index] != 0) {
mapped = false;
break;
}
}
if (!mapped || (bytes[10] & 0xFF) != 0xFF || (bytes[11] & 0xFF) != 0xFF) {
return address;
}
try {
return InetAddress.getByAddress(Arrays.copyOfRange(bytes, 12, 16));
} catch (UnknownHostException impossible) {
return address;
}
}
/**
* Whether an address must not be connected to.
*
* <p>Structured as an allowlist of globally routable unicast space, then the operator's own
* exclusions not as a list of bad ranges. A denylist has to enumerate every special-purpose
* block IANA has ever assigned, and the ones it forgets are reachable: {@code 192.0.2.0/24},
* {@code 198.18.0.0/15}, {@code 240.0.0.0/4} and the IPv6 documentation and Teredo prefixes were
* all absent, and each of them can be made to resolve somewhere useful to an attacker. Requiring
* global unicast inverts the burden: an address is refused unless it is the kind of address a
* public webhook could legitimately live on.
*
* @param rawAddress the resolved address, possibly IPv4-mapped
* @return {@code true} when the platform must refuse the address
*/
public boolean forbidden(InetAddress rawAddress) {
InetAddress address = normalize(rawAddress);
if (!globallyRoutableUnicast(address)) {
return true;
}
if (METADATA_ADDRESSES.contains(address.getHostAddress())) {
return true;
}
return additionalBlockedRanges.stream().anyMatch(range -> range.contains(address));
}
/**
* Whether the address is in globally routable unicast space.
*
* <p>The JDK predicates cover loopback, link-local, site-local, multicast and wildcard. The
* remaining special-purpose blocks are listed explicitly because the JDK has no predicate for
* them and their absence is what made the previous denylist incomplete.
*/
private boolean globallyRoutableUnicast(InetAddress address) {
if (address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()
|| address.isMulticastAddress()) {
return false;
}
byte[] bytes = address.getAddress();
if (address instanceof Inet4Address) {
int first = bytes[0] & 0xFF;
int second = bytes[1] & 0xFF;
int third = bytes[2] & 0xFF;
// 0.0.0.0/8 "this network"; 100.64.0.0/10 carrier-grade NAT, routinely internal;
// 192.0.0.0/24 IETF protocol assignments; 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24
// documentation ranges; 198.18.0.0/15 benchmarking; 240.0.0.0/4 reserved, which includes the
// 255.255.255.255 broadcast address.
if (first == 0
|| (first == 100 && (second & 0xC0) == 64)
|| (first == 192 && second == 0 && third == 0)
|| (first == 192 && second == 0 && third == 2)
|| (first == 198 && second == 51 && third == 100)
|| (first == 203 && second == 0 && third == 113)
|| (first == 198 && (second & 0xFE) == 18)
|| (first & 0xF0) == 240) {
return false;
}
return true;
}
if (address instanceof Inet6Address) {
// fc00::/7 unique local; 2001:db8::/32 documentation; 2001::/32 Teredo; 100::/64 discard.
if ((bytes[0] & 0xFE) == 0xFC) {
return false;
}
int firstWord = ((bytes[0] & 0xFF) << 8) | (bytes[1] & 0xFF);
int secondWord = ((bytes[2] & 0xFF) << 8) | (bytes[3] & 0xFF);
if (firstWord == 0x2001 && (secondWord == 0x0db8 || secondWord == 0x0000)) {
return false;
}
if (firstWord == 0x0100 && secondWord == 0x0000) {
return false;
}
// 2000::/3 is the only globally routable unicast range currently assigned.
return (bytes[0] & 0xE0) == 0x20;
}
return false;
}
/** Minimal CIDR matcher for organisation-defined internal ranges. */
private static final class CidrRange {
private static final java.util.regex.Pattern SLASH = java.util.regex.Pattern.compile("/");
/** IPv4 dotted quad or an IPv6 literal; anything else is a hostname and is refused. */
private static final java.util.regex.Pattern LITERAL_ADDRESS =
java.util.regex.Pattern.compile("^[0-9.]+$|^[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*$");
private final byte[] network;
private final int prefixLength;
private CidrRange(byte[] network, int prefixLength) {
this.network = network.clone();
this.prefixLength = prefixLength;
}
/**
* Parses a CIDR strictly.
*
* <p>Every rejection here used to be an acceptance. {@code Integer.parseInt} took {@code -1}
* and {@code 33} without complaint, producing a range that matched everything or nothing; and
* {@code InetAddress.getByName} accepts a <em>hostname</em>, so a typo'd entry performed a DNS
* lookup at startup and pinned the block to whatever that name resolved to at that moment. An
* operator's exclusion list is a security control, and every one of those outcomes silently
* turned it into something else.
*/
static CidrRange parse(String cidr) {
Objects.requireNonNull(cidr, "cidr");
String[] parts = SLASH.split(cidr, -1);
if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) {
throw new IllegalArgumentException("invalid cidr, expected <address>/<prefix>: " + cidr);
}
if (!LITERAL_ADDRESS.matcher(parts[0]).matches()) {
throw new IllegalArgumentException(
"cidr address must be an ip literal, not a hostname: " + cidr);
}
byte[] network;
try {
network = InetAddress.getByName(parts[0]).getAddress();
} catch (UnknownHostException invalid) {
throw new IllegalArgumentException("invalid cidr address: " + cidr, invalid);
}
int prefixLength;
try {
prefixLength = Integer.parseInt(parts[1]);
} catch (NumberFormatException notANumber) {
throw new IllegalArgumentException("cidr prefix must be an integer: " + cidr, notANumber);
}
int maximumPrefix = network.length * 8;
if (prefixLength < 0 || prefixLength > maximumPrefix) {
throw new IllegalArgumentException(
"cidr prefix must be 0.." + maximumPrefix + " for this address family: " + cidr);
}
return new CidrRange(network, prefixLength);
}
boolean contains(InetAddress address) {
byte[] candidate = address.getAddress();
if (candidate.length != network.length) {
return false;
}
int fullBytes = prefixLength / 8;
for (int index = 0; index < fullBytes; index++) {
if (candidate[index] != network[index]) {
return false;
}
}
int remainingBits = prefixLength % 8;
if (remainingBits == 0) {
return true;
}
int mask = 0xFF << (8 - remainingBits);
return (candidate[fullBytes] & mask) == (network[fullBytes] & mask);
}
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpDnsException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
/**
* Resolves a host and validates <em>every</em> answer (design §22.1 steps 6-9).
*
* <p>Validating only the first answer is a common and fatal shortcut: a host that resolves to one
* public and one private address would pass, and the connection could still land on the private
* one. Any forbidden address in the answer set rejects the whole target.
*
* <p>Approved addresses travel to the socket through {@link CallScopedDnsPin}, installed by the
* gateway for the duration of one hop. They are deliberately not retained here between calls: the
* previous per-host map had neither a TTL nor a size bound, so it grew without limit and, worse,
* answered a later call with an address that was only ever validated for an earlier one. An address
* approved five minutes ago is not evidence about the host now, which is the entire premise of
* rebinding.
*/
public final class ValidatedDnsResolver {
private static final HttpFailureMetadata SCOPE =
HttpFailureMetadata.startup(new ClientProfileName("dynamic-target"));
private final Function<String, InetAddress[]> systemResolver;
private final IpAddressClassifier classifier;
private final Map<String, List<InetAddress>> approved = new ConcurrentHashMap<>();
public ValidatedDnsResolver(IpAddressClassifier classifier) {
this(classifier, ValidatedDnsResolver::systemResolve);
}
public ValidatedDnsResolver(
IpAddressClassifier classifier, Function<String, InetAddress[]> systemResolver) {
this.classifier = Objects.requireNonNull(classifier, "ip address classifier");
this.systemResolver = Objects.requireNonNull(systemResolver, "system resolver");
}
public List<InetAddress> resolve(String host) {
Objects.requireNonNull(host, "host");
InetAddress[] answers = systemResolver.apply(host);
if (answers == null || answers.length == 0) {
throw new HttpDnsException("dynamic target host did not resolve", SCOPE);
}
List<InetAddress> normalized = new ArrayList<>(answers.length);
for (InetAddress answer : answers) {
if (classifier.forbidden(answer)) {
approved.remove(host);
throw new HttpTargetRejectedException(
"dynamic target resolves to a forbidden address range", SCOPE);
}
normalized.add(IpAddressClassifier.normalize(answer));
}
List<InetAddress> immutable = List.copyOf(normalized);
approved.put(host, immutable);
return immutable;
}
public PinnedTarget pin(CanonicalTarget target) {
return new PinnedTarget(target, resolve(target.host()));
}
/** Addresses the transport may connect to; empty when the host was never validated. */
public List<InetAddress> approvedAddresses(String host) {
return approved.getOrDefault(host, List.of());
}
public void forget(String host) {
approved.remove(host);
}
private static InetAddress[] systemResolve(String host) {
try {
return InetAddress.getAllByName(host);
} catch (UnknownHostException unknown) {
throw new HttpDnsException("dynamic target host did not resolve", SCOPE, unknown);
}
}
}
@@ -0,0 +1,121 @@
package dev.caskeleton.adapter.outbound.httpclient.http3;
import java.util.List;
import java.util.Objects;
/**
* What the Experimental HTTP/3 transport can and cannot prove (design §13.7, §29).
*
* <p>The contract suite runs only the subset declared here. Declaring less than the truth costs
* coverage; declaring more would let the Experimental transport claim Stable guarantees it has not
* demonstrated.
*
* <p>Two earlier defects in this report are worth naming, because both made it say the opposite of
* the truth.
*
* <p>The first is that it probed {@code
* org.eclipse.jetty.quic.client.QuicClientConnectorConfigurator}, a class that does not exist in
* the Jetty version this repository pins. The probe therefore reported "no QUIC" on a classpath
* that carries the entire QUIC and HTTP/3 client stack a false negative that nothing noticed
* because nothing acted on it.
*
* <p>The second is that the probe was disconnected from the provider. It asked whether *some* QUIC
* class existed, never whether {@link JettyHttp3TransportProvider} used one, and the provider was
* in fact building {@code HttpClientTransportOverHTTP}: plain HTTP/1.1 over TCP, reported as
* HTTP/3. The probe now names the exact classes the provider constructs, so the report cannot drift
* from it again without failing to load them.
*
* @param quicNativeSupportPresent whether the QUIC and HTTP/3 client classes the provider
* constructs are loadable
* @param tls13Available whether the runtime offers TLS 1.3, which HTTP/3 requires
* @param wireVerified whether a negotiated {@code h3} exchange against a real HTTP/3 server has
* been observed in this build; class presence is not evidence of interoperability
* @param dynamicTargetSupported always false H3 needs validated address pinning this transport
* cannot yet provide
* @param unsupportedContracts the contracts the Experimental transport does not run
*/
public record Http3CapabilityReport(
boolean quicNativeSupportPresent,
boolean tls13Available,
boolean wireVerified,
boolean dynamicTargetSupported,
List<String> unsupportedContracts) {
/**
* The classes the provider actually constructs. Probing anything else would let the report and
* the provider disagree.
*/
private static final List<String> REQUIRED_QUIC_CLASSES =
List.of(
"org.eclipse.jetty.quic.client.ClientQuicConfiguration",
"org.eclipse.jetty.http3.client.HTTP3Client",
"org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3");
public Http3CapabilityReport {
Objects.requireNonNull(unsupportedContracts, "unsupported contracts");
unsupportedContracts = List.copyOf(unsupportedContracts);
}
public static Http3CapabilityReport detect() {
return new Http3CapabilityReport(
quicClassesPresent(),
detectTls13Support(),
// No HTTP/3 server is stood up anywhere in this build, so nothing has observed a negotiated
// h3 exchange. Until something does, the transport stays un-promotable no matter how
// complete its classpath looks.
false,
false,
List.of(
"dynamic-target-pinning",
"pool-saturation-evidence",
"forward-proxy-tunnel",
"negotiated-protocol-wire-proof"));
}
/**
* Whether the transport may be advertised as anything beyond Experimental.
*
* <p>Requires wire proof, not classpath proof. A complete set of QUIC classes says the code can
* be constructed; it says nothing about whether a peer negotiated {@code h3}.
*
* @return {@code true} only when a real negotiated exchange has been observed
*/
public boolean promotableToBeta() {
return quicNativeSupportPresent && tls13Available && wireVerified;
}
/**
* Whether the provider can build its transport at all.
*
* @return {@code true} when every class the provider constructs is loadable and TLS 1.3 is
* offered
*/
public boolean constructible() {
return quicNativeSupportPresent && tls13Available;
}
static List<String> requiredQuicClasses() {
return REQUIRED_QUIC_CLASSES;
}
private static boolean quicClassesPresent() {
for (String className : REQUIRED_QUIC_CLASSES) {
try {
Class.forName(className, false, Http3CapabilityReport.class.getClassLoader());
} catch (ClassNotFoundException absent) {
return false;
}
}
return true;
}
private static boolean detectTls13Support() {
try {
return List.of(
javax.net.ssl.SSLContext.getDefault().getSupportedSSLParameters().getProtocols())
.contains("TLSv1.3");
} catch (java.security.NoSuchAlgorithmException unavailable) {
return false;
}
}
}
@@ -0,0 +1,190 @@
package dev.caskeleton.adapter.outbound.httpclient.http3;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportCapabilities;
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportProvider;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.http.HttpVersion;
import org.eclipse.jetty.http3.client.HTTP3Client;
import org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3;
import org.eclipse.jetty.io.Transport;
import org.eclipse.jetty.quic.client.ClientQuicConfiguration;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.http.client.reactive.JettyClientHttpConnector;
/**
* Experimental HTTP/3 transport (design D-08, §13.7, §32.7).
*
* <p>Three guards keep it Experimental in practice, not just in documentation: an exact
* acknowledgement string, a capability report that must show QUIC and TLS 1.3, and a hard refusal
* to serve Dynamic Targets H3 requires validated address pinning this transport cannot yet prove.
*
* <p>The Stable starter never auto-configures this provider.
*
* <p>This class used to build {@code HttpClientTransportOverHTTP} plain HTTP/1.1 over TCP and
* present it as HTTP/3. Every guard above passed, the profile declared {@code HTTP_3}, the
* acknowledgement was checked, and the resulting connection negotiated HTTP/1.1. A caller who opted
* into an experimental protocol got neither the protocol nor a warning. It now constructs the real
* QUIC-backed transport and refuses outright when it cannot, so the failure mode is a startup error
* rather than a silent downgrade.
*
* <p>Constructing the right transport is still not proof that HTTP/3 works. Nothing in this build
* stands up an HTTP/3 server, so {@link Http3CapabilityReport#promotableToBeta()} stays false
* regardless of how complete the classpath is.
*/
public final class JettyHttp3TransportProvider implements ReactiveTransportProvider {
private static final TransportId ID = new TransportId("jetty-http3");
private final JettyHttp3FailureClassifier classifier = new JettyHttp3FailureClassifier();
private final Map<TransportResourceKey, HttpClient> clients = new ConcurrentHashMap<>();
@Override
public TransportId id() {
return ID;
}
@Override
public ReactiveTransportCapabilities capabilities() {
return ReactiveTransportCapabilities.jettyHttp3Experimental();
}
public Http3CapabilityReport capabilityReport() {
return Http3CapabilityReport.detect();
}
@Override
public ClientHttpConnector create(
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) {
requireExperimentalAcknowledgement(profile);
if (profile.mode() == ClientMode.DYNAMIC) {
throw new HttpConfigurationException(
"the experimental http/3 transport does not support dynamic targets",
HttpFailureMetadata.startup(profile.name()));
}
HttpClient client = newHttp3Client(profile);
clients.put(new TransportResourceKey(profile.name(), generation), client);
listener.onRuntimeCreated(profile.name(), ID);
JettyClientHttpConnector connector = new JettyClientHttpConnector(client);
return connector;
}
/**
* Builds the QUIC-backed Jetty client, or refuses.
*
* <p>Package-visible so a test can assert which transport was constructed. Asserting on the
* connector cannot do it {@code JettyClientHttpConnector} does not expose the client and the
* defect this replaces was invisible precisely because nothing looked.
*
* @param profile the profile whose timeouts and pool bounds configure the client
* @return a client whose transport is HTTP/3 over QUIC
* @throws HttpConfigurationException when the runtime cannot offer TLS 1.3 or the QUIC classes
* are absent; never a downgrade to TCP
*/
static HttpClient newHttp3Client(ClientProfile profile) {
HttpClient client = new HttpClient(newHttp3Transport(profile));
client.setFollowRedirects(false);
client.setConnectTimeout(profile.timeout().connect().toMillis());
client.setIdleTimeout(profile.timeout().readIdle().toMillis());
client.setMaxConnectionsPerDestination(profile.pool().maxConnectionsPerRoute());
client.setMaxRequestsQueuedPerDestination(Math.max(1, profile.pool().maxPendingAcquires()));
return client;
}
/**
* The QUIC-backed transport, or a refusal.
*
* <p>Returned as its own value rather than read back off the client, because Jetty 12.1
* deprecated {@code HttpClient#getTransport()} for removal a test that reached through the
* client would be asserting on an API scheduled to disappear.
*
* @param profile the profile whose connect and idle budgets configure the QUIC session
* @return an HTTP/3-over-QUIC transport
* @throws HttpConfigurationException when TLS 1.3 or the QUIC classes are unavailable
*/
static HttpClientTransportOverHTTP3 newHttp3Transport(ClientProfile profile) {
Http3CapabilityReport report = Http3CapabilityReport.detect();
if (!report.tls13Available()) {
throw new HttpConfigurationException(
"http/3 requires TLS 1.3, which this runtime does not offer",
HttpFailureMetadata.startup(profile.name()));
}
if (!report.quicNativeSupportPresent()) {
throw new HttpConfigurationException(
"http/3 requires the Jetty QUIC client stack "
+ Http3CapabilityReport.requiredQuicClasses()
+ ", which is not on the classpath; refusing rather than falling back to TCP",
HttpFailureMetadata.startup(profile.name()));
}
HTTP3Client http3Client = new HTTP3Client(new ClientQuicConfiguration());
http3Client.getClientConnector().setConnectTimeout(profile.timeout().connect());
http3Client
.getHTTP3Configuration()
.setStreamIdleTimeout(profile.timeout().readIdle().toMillis());
return new HttpClientTransportOverHTTP3(http3Client, Transport.UDP_IP);
}
/**
* The wire version this transport is configured to speak.
*
* <p>Named for what it is. The previous {@code negotiatedVersion} claimed to describe the wire
* while reading only a classpath probe, and returned HTTP/2 for a client that was speaking
* HTTP/1.1. A negotiated version can only come from an exchange, and this build has none.
*
* @return always HTTP/3 the provider refuses to build anything else
*/
public HttpVersion configuredVersion() {
return HttpVersion.HTTP_3;
}
@Override
public TransportFailureClassifier failureClassifier() {
return classifier;
}
@Override
public void close(ClientProfile profile, RuntimeGeneration generation) {
HttpClient client = clients.remove(new TransportResourceKey(profile.name(), generation));
if (client == null) {
return;
}
try {
client.stop();
} catch (Exception failure) {
throw new IllegalStateException("jetty http/3 client did not stop cleanly", failure);
}
}
private void requireExperimentalAcknowledgement(ClientProfile profile) {
String acknowledgement =
profile
.experimentalAcknowledgement()
.orElseThrow(
() ->
new HttpConfigurationException(
"http/3 requires an explicit experimental acknowledgement",
HttpFailureMetadata.startup(profile.name())));
try {
Http3ExperimentalAcknowledgement unused =
new Http3ExperimentalAcknowledgement(acknowledgement);
assert unused != null;
} catch (IllegalArgumentException invalid) {
throw new HttpConfigurationException(
"http/3 requires an explicit experimental acknowledgement",
HttpFailureMetadata.startup(profile.name()),
invalid);
}
}
}
@@ -0,0 +1,83 @@
package dev.caskeleton.adapter.outbound.httpclient.jdk;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportCapabilities;
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportProvider;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey;
import java.net.http.HttpClient;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.JdkClientHttpRequestFactory;
/**
* Lightweight blocking alternative (design D-06, §13.5).
*
* <p>Capability validation runs before any client is built: a profile this transport cannot honour
* fails without ever reaching the network.
*/
public final class JdkBlockingTransportProvider implements BlockingTransportProvider {
private static final TransportId ID = new TransportId("jdk");
private final JdkClientFactory clientFactory = new JdkClientFactory();
private final JdkFailureClassifier classifier = new JdkFailureClassifier();
private final JdkTransportCapabilityPolicy capabilityPolicy = new JdkTransportCapabilityPolicy();
private final Map<TransportResourceKey, HttpClient> clients = new ConcurrentHashMap<>();
private final Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver;
public JdkBlockingTransportProvider() {
this(profile -> Optional.empty());
}
public JdkBlockingTransportProvider(
Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver) {
this.tlsMaterialResolver = Objects.requireNonNull(tlsMaterialResolver, "tls material resolver");
}
@Override
public TransportId id() {
return ID;
}
@Override
public BlockingTransportCapabilities capabilities() {
return BlockingTransportCapabilities.lightweightHttp11AndHttp2();
}
@Override
public ClientHttpRequestFactory create(
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(listener, "lifecycle listener");
capabilityPolicy.validate(profile);
HttpClient client = clientFactory.create(profile, tlsMaterialResolver.apply(profile));
clients.put(new TransportResourceKey(profile.name(), generation), client);
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(client);
factory.setReadTimeout(profile.timeout().responseHeader());
listener.onRuntimeCreated(profile.name(), ID);
return factory;
}
@Override
public TransportFailureClassifier failureClassifier() {
return classifier;
}
@Override
public void close(ClientProfile profile, RuntimeGeneration generation) {
HttpClient client = clients.remove(new TransportResourceKey(profile.name(), generation));
if (client != null) {
client.close();
}
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.adapter.outbound.httpclient.jdk;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol;
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
import java.net.InetSocketAddress;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.util.Objects;
import java.util.Optional;
import javax.net.ssl.SSLParameters;
/**
* Builds the JDK HttpClient runtime for one profile (design §13.5).
*
* <p>Redirects are always {@code NEVER}: the platform re-validates every hop itself, and the JDK's
* own follower would silently forward credentials across origins.
*/
public final class JdkClientFactory {
public HttpClient create(ClientProfile profile, Optional<SslContextMaterial> tlsMaterial) {
Objects.requireNonNull(profile, "profile");
HttpClient.Builder builder =
HttpClient.newBuilder()
.connectTimeout(profile.timeout().connect())
.followRedirects(HttpClient.Redirect.NEVER)
.version(
profile.protocols().contains(HttpProtocol.HTTP_2)
? HttpClient.Version.HTTP_2
: HttpClient.Version.HTTP_1_1);
// TLS parameters are applied whether or not the profile supplies custom material. They used to
// be set only inside this ifPresent, so a profile that declared `tls.protocols: [TLSv1.3]` and
// used the JVM trust store the common case configured nothing at all and negotiated
// whatever the platform default allowed, TLS 1.2 included. A declared TLS floor that only
// applies when you also supply a custom truststore is not a floor.
SSLParameters parameters = new SSLParameters();
parameters.setProtocols(
tlsMaterial
.map(SslContextMaterial::protocolArray)
.orElseGet(() -> profile.tls().protocols().toArray(String[]::new)));
// Endpoint identification is set explicitly: the JDK default for a raw SSLParameters
// instance is "no hostname check", which design §21.2 forbids.
parameters.setEndpointIdentificationAlgorithm("HTTPS");
// ALPN is declared explicitly. The JDK client also derives it from the requested version,
// so this is not load-bearing today (verified by NegotiatedProtocolContractTest, which
// still passes without it) it makes the advertised protocol set a property of the
// profile rather than of a JDK internal.
parameters.setApplicationProtocols(
profile.protocols().contains(HttpProtocol.HTTP_2)
? new String[] {"h2", "http/1.1"}
: new String[] {"http/1.1"});
tlsMaterial.ifPresent(material -> builder.sslContext(material.sslContext()));
builder.sslParameters(parameters);
if (profile.proxy().enabled()) {
builder.proxy(
ProxySelector.of(new InetSocketAddress(profile.proxy().host(), profile.proxy().port())));
} else {
builder.proxy(ProxySelector.of(null));
}
return builder.build();
}
}
@@ -0,0 +1,120 @@
package dev.caskeleton.adapter.outbound.httpclient.jdk;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import java.io.InterruptedIOException;
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.net.http.HttpConnectTimeoutException;
import java.net.http.HttpTimeoutException;
import java.security.cert.CertificateException;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLPeerUnverifiedException;
/**
* Maps JDK HttpClient failures onto stable evidence (design §13.3, §13.5).
*
* <p>The JDK exposes fewer stage-specific exceptions than Apache, so anything other than a proven
* pre-send failure stays conservative.
*/
public final class JdkFailureClassifier implements TransportFailureClassifier {
@Override
public TransportFailure classify(Throwable failure, AttemptStage lastObservedStage) {
// Spring and the JDK both wrap engine exceptions; the chain is inspected so a wrapped
// ConnectException still classifies as provably NOT_SENT.
for (Throwable cause : chain(failure)) {
TransportFailure recognized = recognize(cause, lastObservedStage);
if (recognized != null) {
return recognized;
}
}
return fallback(lastObservedStage);
}
private TransportFailure recognize(Throwable cause, AttemptStage lastObservedStage) {
if (cause instanceof HttpConnectTimeoutException
|| cause instanceof ConnectException
|| cause instanceof NoRouteToHostException) {
return TransportFailure.notSent(
AttemptStage.CONNECT, FailureCategory.CONNECT, "CONNECT_FAILED");
}
if (cause instanceof UnknownHostException) {
return TransportFailure.notSent(
AttemptStage.DNS, FailureCategory.DNS, "DNS_RESOLUTION_FAILED");
}
if (cause instanceof SSLPeerUnverifiedException || cause instanceof CertificateException) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_TRUST_FAILED");
}
if (cause instanceof SSLHandshakeException) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_HANDSHAKE_FAILED");
}
if (cause instanceof SSLException
&& !lastObservedStage.isAtLeast(AttemptStage.REQUEST_HEADERS)) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_TRANSIENT_FAILURE");
}
if (cause instanceof InterruptedIOException
&& !(cause instanceof HttpTimeoutException)
&& !(cause instanceof SocketTimeoutException)) {
// Cancellation, not a timeout. Classifying it as a timeout let the retry engine reissue a
// request the caller had just cancelled, and dropped the interrupt so the thread could not
// see its own cancellation either.
Thread.currentThread().interrupt();
if (lastObservedStage.provesNotSent()) {
return TransportFailure.notSent(
lastObservedStage, FailureCategory.CANCELLED, "ATTEMPT_INTERRUPTED");
}
return TransportFailure.sentNoResponse(
lastObservedStage, FailureCategory.CANCELLED, "ATTEMPT_INTERRUPTED");
}
if (cause instanceof HttpTimeoutException || cause instanceof SocketTimeoutException) {
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
return new TransportFailure(
lastObservedStage,
ExecutionEvidence.PARTIAL_RESPONSE,
FailureCategory.RESPONSE_TIMEOUT,
"RESPONSE_BODY_TIMEOUT");
}
return TransportFailure.sentNoResponse(
AttemptStage.RESPONSE_HEADERS,
FailureCategory.RESPONSE_TIMEOUT,
"RESPONSE_HEADER_TIMEOUT");
}
return null;
}
private TransportFailure fallback(AttemptStage lastObservedStage) {
if (lastObservedStage.provesNotSent()) {
return TransportFailure.notSent(
lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
}
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
return new TransportFailure(
lastObservedStage,
ExecutionEvidence.PARTIAL_RESPONSE,
FailureCategory.RESPONSE_TRUNCATED,
"TRANSPORT_FAILURE");
}
return TransportFailure.sentNoResponse(
lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
}
private java.util.List<Throwable> chain(Throwable failure) {
java.util.List<Throwable> chain = new java.util.ArrayList<>();
Throwable current = failure;
while (current != null && !chain.contains(current)) {
chain.add(current);
current = current.getCause();
}
return chain;
}
}
@@ -0,0 +1,291 @@
package dev.caskeleton.adapter.outbound.httpclient.profile;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Fail-closed startup validation for a Named Client Profile (design §11.2, §30.1).
*
* <p>Every guard in the design has exactly one stable violation code here. The result is sorted so
* a configuration error reports deterministically across runs and machines.
*/
public final class ClientProfileValidator {
public List<ClientProfileViolation> validate(
ClientProfile profile, RuntimeEnvironment environment) {
List<ClientProfileViolation> violations = new ArrayList<>();
validateTarget(profile, environment, violations);
validateRedirect(profile, violations);
validateTimeouts(profile, violations);
validateUnsupportedSettings(profile, violations);
validateLimits(profile, violations);
validateTransportCapability(profile, environment, violations);
validateCredentials(profile, violations);
validateTls(profile, environment, violations);
validateRetry(profile, violations);
validateObservability(profile, environment, violations);
validateProductionCompleteness(profile, environment, violations);
violations.sort(ClientProfileViolation::compareTo);
return List.copyOf(violations);
}
private void validateTarget(
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
URI baseUrl = profile.baseUrl();
if (profile.trusted() && baseUrl == null) {
out.add(violation("TRUSTED_BASE_URL_REQUIRED", profile, "base-url"));
return;
}
if (baseUrl == null) {
return;
}
if (baseUrl.getUserInfo() != null) {
out.add(violation("BASE_URL_USERINFO_FORBIDDEN", profile, "base-url"));
}
if (baseUrl.getRawQuery() != null) {
out.add(violation("BASE_URL_QUERY_FORBIDDEN", profile, "base-url"));
}
String scheme = baseUrl.getScheme() == null ? "" : baseUrl.getScheme().toLowerCase(Locale.ROOT);
if (!"https".equals(scheme) && environment.production()) {
out.add(violation("PLAINTEXT_PRODUCTION_TARGET", profile, "base-url"));
}
String host = baseUrl.getHost();
if (host != null
&& !profile.allowedHosts().isEmpty()
&& !profile.allowedHosts().contains(host.toLowerCase(Locale.ROOT))) {
out.add(violation("ALLOWED_HOST_MISMATCH", profile, "allowed-hosts"));
}
int port = baseUrl.getPort() >= 0 ? baseUrl.getPort() : defaultPort(scheme);
if (!profile.allowedPorts().isEmpty() && !profile.allowedPorts().contains(port)) {
out.add(violation("ALLOWED_PORT_MISMATCH", profile, "allowed-ports"));
}
}
private void validateRedirect(ClientProfile profile, List<ClientProfileViolation> out) {
RedirectSettings redirect = profile.redirect();
if (!redirect.enabled()) {
return;
}
if (redirect.maxHops() == 0) {
out.add(violation("REDIRECT_POLICY_INVALID", profile, "redirect.max-hops"));
}
if (redirect.allowCrossOrigin()
&& profile.authentication().type().attachesDefaultCredential()) {
out.add(violation("REDIRECT_CROSS_ORIGIN_CREDENTIAL_POLICY_REQUIRED", profile, "redirect"));
}
if (profile.api() == ClientApiType.WEB_CLIENT) {
// Engine-level redirect following is disabled on every transport, and only the blocking stack
// has a coordinator to follow hops itself with per-hop re-validation. A reactive profile that
// enabled redirects therefore did not follow them: the caller received the 302 as an ordinary
// response and read its empty body as the answer. Refusing is the honest outcome until the
// reactive coordinator exists a configured guarantee that silently does nothing is worse
// than one the platform declines to offer.
out.add(violation("REACTIVE_REDIRECT_UNSUPPORTED", profile, "redirect.enabled"));
}
}
/**
* Settings that bind but reach no transport are refused rather than ignored.
*
* <p>Three of them had no consumer anywhere: {@code timeout.dns}, {@code
* proxy.credential-provider} and {@code proxy.import-ambient-no-proxy}. An operator who set a DNS
* timeout believed resolution was bounded and it was not; one who named a proxy credential
* provider believed the proxy was authenticated and it was not. Neither Apache nor the JDK client
* exposes a DNS-resolution timeout, and no proxy credential path exists in this platform yet, so
* the honest position is to refuse a value the platform cannot honour instead of accepting it and
* doing nothing.
*
* <p>The default values are accepted, so an operator who never touched these settings is
* unaffected only a deliberate, unmet request fails.
*/
private void validateUnsupportedSettings(
ClientProfile profile, List<ClientProfileViolation> out) {
if (!TimeoutSettings.DEFAULT_DNS.equals(profile.timeout().dns())) {
out.add(violation("DNS_TIMEOUT_UNSUPPORTED", profile, "timeout.dns"));
}
if (profile.proxy().credentialProvider().isPresent()) {
out.add(violation("PROXY_CREDENTIAL_UNSUPPORTED", profile, "proxy.credential-provider"));
}
if (profile.proxy().importAmbientNoProxy()) {
out.add(
violation(
"PROXY_AMBIENT_NO_PROXY_UNSUPPORTED", profile, "proxy.import-ambient-no-proxy"));
}
}
private void validateTimeouts(ClientProfile profile, List<ClientProfileViolation> out) {
TimeoutSettings timeout = profile.timeout();
if (timeout.totalCall().compareTo(timeout.connect()) < 0
|| timeout.totalCall().compareTo(timeout.responseHeader()) < 0) {
out.add(violation("INVALID_TIMEOUT_BUDGET", profile, "timeout.total-call"));
}
if (timeout.totalCall().isZero() || timeout.totalCall().isNegative()) {
out.add(violation("INVALID_TIMEOUT_BUDGET", profile, "timeout.total-call"));
}
}
private void validateLimits(ClientProfile profile, List<ClientProfileViolation> out) {
if (profile.response().maxDecodedBytes() > ResponseLimits.GLOBAL_HARD_MAXIMUM_BYTES) {
out.add(violation("RESPONSE_HARD_MAXIMUM_EXCEEDED", profile, "response.max-decoded-bytes"));
}
}
/**
* Observability switches that describe an unsafe intent are refused in production.
*
* <p>Both settings were bindable and inert: nothing read {@code full-url-recording}, and {@code
* body-logging} reached only the actuator report. Leaving them that way is the worse of the two
* failure modes an operator who set them believed the platform was recording full URLs or
* bodies, and an operator who left them false had no assurance that it was not. Recording an
* expanded URL puts path identifiers and query strings into unbounded metric tags and logs;
* recording bodies puts someone else's data there. Neither belongs in production, so the intent
* is representable and rejectable rather than silently ignored.
*/
private void validateObservability(
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
if (!environment.production()) {
return;
}
if (profile.observability().fullUrlRecording()) {
out.add(
violation("FULL_URL_RECORDING_FORBIDDEN", profile, "observability.full-url-recording"));
}
if (profile.observability().bodyLogging()) {
out.add(violation("BODY_LOGGING_FORBIDDEN", profile, "observability.body-logging"));
}
}
private void validateTransportCapability(
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
if (profile.transport() == TransportType.SIMPLE && environment.production()) {
out.add(violation("PRODUCTION_SIMPLE_FACTORY_FORBIDDEN", profile, "transport"));
}
if (profile.transport() == TransportType.JDK
&& (profile.pool().requiresRoutePool() || profile.pool().requiresBoundedPendingQueue())) {
out.add(violation("JDK_FINE_GRAINED_POOL_UNSUPPORTED", profile, "transport"));
}
if (profile.protocols().contains(HttpProtocol.HTTP_3)
&& profile.experimentalAcknowledgement().isEmpty()) {
out.add(violation("HTTP3_STABLE_FORBIDDEN", profile, "protocols"));
}
if (profile.mode() == ClientMode.DYNAMIC
&& (profile.transport() == TransportType.JDK
|| profile.transport() == TransportType.JETTY)) {
out.add(violation("DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED", profile, "transport"));
}
if (profile.mode() == ClientMode.DYNAMIC && profile.baseUrl() == null) {
// A DYNAMIC profile takes its destination per call, but the runtime factory still builds its
// client from a base URL and called toString() on it unconditionally. The profile was
// accepted at startup and produced a NullPointerException while assembling the runtime.
out.add(violation("DYNAMIC_BASE_URL_REQUIRED", profile, "base-url"));
}
if (ProtocolIntent.of(profile.protocols()).requiresHttp2()
&& profile.transport() != TransportType.REACTOR_NETTY) {
// Only Reactor Netty can be configured to offer H2 and nothing else. The JDK client treats
// HTTP_2 as a preference and silently negotiates HTTP/1.1; Apache's classic client is
// HTTP/1.1
// only. A profile that requires H2 on either of them was getting HTTP/1.1 with no signal.
out.add(violation("HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED", profile, "protocols"));
}
if (profile.pool().maxConnectionsPerRoute() > profile.pool().maxTotalConnections()) {
// 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.
out.add(violation("POOL_ROUTE_EXCEEDS_TOTAL", profile, "pool.max-connections-per-route"));
}
if (profile.tls().protocols().isEmpty()) {
// An empty set passed validation and then let the JVM pick, so a profile that meant to pin a
// TLS floor got whatever the platform default happened to be including TLS 1.2 on a profile
// whose operator had deliberately emptied the list to "tighten" it.
out.add(violation("TLS_PROTOCOL_SET_REQUIRED", profile, "tls.protocols"));
}
if (profile.mode() == ClientMode.DYNAMIC && profile.proxy().enabled()) {
// A forward proxy re-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.
out.add(violation("DYNAMIC_TARGET_PROXY_UNSUPPORTED", profile, "proxy.enabled"));
}
}
private void validateCredentials(ClientProfile profile, List<ClientProfileViolation> out) {
if (profile.mode() == ClientMode.DYNAMIC
&& profile.authentication().type().attachesDefaultCredential()) {
out.add(violation("DYNAMIC_DEFAULT_CREDENTIAL_FORBIDDEN", profile, "authentication.type"));
}
if (profile.authentication().type() == AuthenticationType.OAUTH2_CLIENT_CREDENTIALS
&& profile.authentication().registrationId().isEmpty()) {
out.add(violation("OAUTH2_REGISTRATION_REQUIRED", profile, "authentication.registration-id"));
}
if (profile.authentication().type() == AuthenticationType.API_KEY_HEADER
&& profile.authentication().headerName().isEmpty()) {
out.add(violation("API_KEY_HEADER_NAME_REQUIRED", profile, "authentication.header-name"));
}
}
private void validateTls(
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
TlsSettings tls = profile.tls();
if (tls.trustAll()) {
out.add(violation("TRUST_ALL_FORBIDDEN", profile, "tls.trust-all"));
}
if (!tls.hostnameVerification()) {
out.add(violation("HOSTNAME_VERIFICATION_REQUIRED", profile, "tls.hostname-verification"));
}
if (tls.allowPlainHttp() && environment.production()) {
out.add(violation("PLAINTEXT_FALLBACK_FORBIDDEN", profile, "tls.allow-plain-http"));
}
boolean unsupportedProtocol =
tls.protocols().stream()
.anyMatch(value -> !"TLSv1.2".equals(value) && !"TLSv1.3".equals(value));
if (unsupportedProtocol) {
out.add(violation("TLS_PROTOCOL_FORBIDDEN", profile, "tls.protocols"));
}
}
private void validateRetry(ClientProfile profile, List<ClientProfileViolation> out) {
if (profile.retry().enabled()
&& profile.retry().baseBackoff().isZero()
&& profile.retry().jitter() == JitterStrategy.NONE) {
out.add(violation("RETRY_BACKOFF_REQUIRED", profile, "retry.base-backoff"));
}
// `policy` and `max-attempts` must agree. Nothing on the execution path read `policy` only
// `max-attempts` decided whether a call retried so the actuator could report
// `retryPolicy: none` for a profile that was retrying three times, and a profile named after a
// policy could have retry switched off by a `max-attempts` nobody re-read. A displayed policy
// that cannot contradict behaviour is worth more than one that describes an intention.
boolean declaredNone = "none".equalsIgnoreCase(profile.retry().policy());
if (declaredNone && profile.retry().enabled()) {
out.add(violation("RETRY_POLICY_CONTRADICTS_ATTEMPTS", profile, "retry.policy"));
}
if (!declaredNone && !profile.retry().enabled()) {
out.add(violation("RETRY_POLICY_CONTRADICTS_ATTEMPTS", profile, "retry.max-attempts"));
}
}
private void validateProductionCompleteness(
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
if (!environment.production()) {
return;
}
if (profile.trusted() && profile.allowedHosts().isEmpty()) {
out.add(violation("MISSING_PRODUCTION_SETTING", profile, "allowed-hosts"));
}
if (profile.request().maxBodyBytes() == 0) {
out.add(violation("MISSING_PRODUCTION_SETTING", profile, "request.max-body-bytes"));
}
if (profile.tls().profileId().isEmpty()) {
out.add(violation("MISSING_PRODUCTION_SETTING", profile, "tls.profile-id"));
}
}
private static int defaultPort(String scheme) {
return "http".equals(scheme) ? 80 : 443;
}
private static ClientProfileViolation violation(
String code, ClientProfile profile, String setting) {
return new ClientProfileViolation(
code, "profile=" + profile.name().value() + " setting=" + setting);
}
}
@@ -0,0 +1,187 @@
package dev.caskeleton.adapter.outbound.httpclient.profile;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* Atomic pointer from profile name to its current runtime generation (design §7.2).
*
* <p>A swap publishes the replacement first and drains the predecessor afterwards, so a rotation is
* never observable as a gap. The single scheduled executor exists only to enforce drain deadlines
* and is created lazily; it is shut down with the registry so no thread outlives it.
*/
public final class ClientRuntimeRegistry implements AutoCloseable {
private static final long SHUTDOWN_AWAIT_MILLIS = 5_000L;
private final ConcurrentMap<ClientProfileName, AtomicReference<ClientRuntime>> runtimes =
new ConcurrentHashMap<>();
private final AtomicReference<ScheduledExecutorService> drainScheduler = new AtomicReference<>();
/**
* Generations that have been replaced but are not yet closed.
*
* <p>Held so shutdown can reach them. A rotation moved the old generation out of {@code runtimes}
* and left it owned only by a scheduled drain task, so a registry that closed before that task
* fired leaked the whole generation and the resource-bound suite could not see it, because
* nothing enumerated it.
*/
private final Set<ClientRuntime> retired = java.util.concurrent.ConcurrentHashMap.newKeySet();
public ClientRuntimeRegistry(Map<ClientProfileName, ClientRuntime> initial) {
Objects.requireNonNull(initial, "initial runtimes");
initial.forEach((name, runtime) -> runtimes.put(name, new AtomicReference<>(runtime)));
}
public static ClientRuntimeRegistry empty() {
return new ClientRuntimeRegistry(Map.of());
}
public Set<ClientProfileName> names() {
return Set.copyOf(runtimes.keySet());
}
public void register(ClientRuntime runtime) {
Objects.requireNonNull(runtime, "runtime");
runtimes.put(runtime.name(), new AtomicReference<>(runtime));
}
/**
* Reserves the current generation. Retries against the newly published generation when the
* observed one began draining between the read and the reservation.
*/
public ClientRuntimeLease acquire(ClientProfileName name) {
AtomicReference<ClientRuntime> holder = holder(name);
while (true) {
ClientRuntime runtime = holder.get();
if (runtime.tryAcquire()) {
return new ClientRuntimeLease(runtime, runtime::release);
}
if (holder.get() == runtime) {
throw new IllegalStateException("http client runtime is shutting down: " + name.value());
}
}
}
public ClientRuntime current(ClientProfileName name) {
return holder(name).get();
}
public boolean contains(ClientProfileName name) {
return runtimes.containsKey(name);
}
/** Publishes {@code replacement} and drains the previous generation (design §7.2 steps 3-6). */
public void swap(ClientProfileName name, ClientRuntime replacement, Duration drainTimeout) {
Objects.requireNonNull(replacement, "replacement runtime");
Objects.requireNonNull(drainTimeout, "drain timeout");
ClientRuntime previous = holder(name).getAndSet(replacement);
if (previous == replacement) {
return;
}
// Tracked until it is actually closed. A retired generation that was still draining when the
// registry shut down was reachable from nothing: close() walked only the current generations,
// so its pool, its connections and its drain task outlived the registry that created them.
retired.add(previous);
previous.beginDrain(drainTimeout);
if (previous.state() != ClientRuntimeState.CLOSED && !drainTimeout.isZero()) {
ScheduledFuture<?> unusedDrainDeadline =
scheduler()
.schedule(
() -> {
try {
previous.forceClose();
} finally {
retired.remove(previous);
}
},
drainTimeout.toMillis(),
TimeUnit.MILLISECONDS);
assert unusedDrainDeadline != null;
} else {
retired.remove(previous);
}
}
@Override
public void close() {
List<ClientRuntime> all = new ArrayList<>();
runtimes.values().forEach(holder -> all.add(holder.get()));
// Retired-but-still-draining generations are closed too; they used to survive registry
// shutdown entirely.
all.addAll(retired);
// Every runtime is closed even when one refuses. forEach stopped at the first exception, so a
// single misbehaving pool left every remaining connection, thread and socket open shutdown
// leaked more the worse the failure was.
RuntimeException firstFailure = null;
for (ClientRuntime runtime : all) {
try {
runtime.forceClose();
} catch (RuntimeException failure) {
if (firstFailure == null) {
firstFailure = failure;
} else {
firstFailure.addSuppressed(failure);
}
}
}
retired.clear();
runtimes.clear();
if (firstFailure != null) {
throw firstFailure;
}
ScheduledExecutorService scheduler = drainScheduler.getAndSet(null);
if (scheduler != null) {
// Await termination: a registry that returns while its drain thread is still alive would
// leak a thread per rotation cycle, which the resource-bound suite exists to catch.
scheduler.shutdownNow();
try {
if (!scheduler.awaitTermination(SHUTDOWN_AWAIT_MILLIS, TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("http client drain scheduler did not terminate");
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
}
}
private AtomicReference<ClientRuntime> holder(ClientProfileName name) {
AtomicReference<ClientRuntime> holder = runtimes.get(name);
if (holder == null) {
throw new NoSuchElementException("unregistered http client profile: " + name.value());
}
return holder;
}
private ScheduledExecutorService scheduler() {
ScheduledExecutorService existing = drainScheduler.get();
if (existing != null) {
return existing;
}
ScheduledExecutorService created =
Executors.newSingleThreadScheduledExecutor(
runnable -> {
Thread thread = new Thread(runnable, "httpclient-runtime-drain");
thread.setDaemon(true);
return thread;
});
if (drainScheduler.compareAndSet(null, created)) {
return created;
}
created.shutdownNow();
return drainScheduler.get();
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.adapter.outbound.httpclient.profile;
import java.util.Set;
/**
* What a profile's declared protocol set actually asks for (design §6.3, §24).
*
* <p>A bare set of protocols does not say whether HTTP/2 is a preference or a requirement, and
* every transport resolved that ambiguity in the direction that could not fail. The JDK client
* treats {@code HTTP_2} as "try H2, fall back to H1"; Reactor Netty was configured with {@code {H2,
* HTTP11}} whenever H2 appeared at all. A profile that declared only {@code HTTP_2} the way an
* operator states a requirement therefore ran happily over HTTP/1.1, and nothing anywhere said
* so. gRPC-style upstreams, header-compression assumptions and concurrency budgets all quietly
* changed meaning.
*
* <p>Naming the intent makes the requirement expressible, and lets a transport that cannot honour
* it refuse at startup instead of downgrading at runtime.
*/
public enum ProtocolIntent {
/** Only HTTP/1.1 is acceptable. */
H1_ONLY,
/** Prefer HTTP/2, accept HTTP/1.1. The safe default for a general-purpose upstream. */
NEGOTIATE_H2_H1,
/** HTTP/2 is required; falling back to HTTP/1.1 is a failure, not a degradation. */
H2_REQUIRED,
/** Experimental HTTP/3, gated by the acknowledgement and its own transport. */
H3_EXPERIMENTAL;
/**
* Derives the intent a declared protocol set expresses.
*
* @param protocols the profile's declared protocols
* @return the intent; declaring HTTP/2 alone means it is required
*/
public static ProtocolIntent of(Set<HttpProtocol> protocols) {
if (protocols.contains(HttpProtocol.HTTP_3)) {
return H3_EXPERIMENTAL;
}
boolean h2 = protocols.contains(HttpProtocol.HTTP_2);
boolean h1 = protocols.contains(HttpProtocol.HTTP_1_1);
if (h2 && h1) {
return NEGOTIATE_H2_H1;
}
if (h2) {
return H2_REQUIRED;
}
return H1_ONLY;
}
public boolean requiresHttp2() {
return this == H2_REQUIRED;
}
public boolean allowsHttp2() {
return this == NEGOTIATE_H2_H1 || this == H2_REQUIRED;
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.outbound.httpclient.profile;
import java.time.Duration;
import java.util.Objects;
/**
* Stage timeouts and the total call budget (design §15.1).
*
* <p>{@link #totalCall()} is the upper budget for everything, including pool acquire and retry
* backoff. {@link #streamingIdle()} is deliberately separate so a long-lived SSE stream is not
* killed by the request-shaped total budget (design §15.3).
*/
public record TimeoutSettings(
// Hostname resolution budget. Currently unenforced: neither the Apache classic client nor the
// JDK client exposes a DNS resolution timeout, so the platform refuses a non-default value
// rather than accepting one it cannot honour. See DNS_TIMEOUT_UNSUPPORTED.
Duration dns,
Duration connect,
Duration tlsHandshake,
Duration proxyConnect,
Duration requestWriteIdle,
Duration responseHeader,
Duration readIdle,
Duration totalCall,
Duration streamingIdle) {
/**
* The shipped {@code timeout.dns} default.
*
* <p>Named so the validator can tell "the operator left this alone" from "the operator asked for
* a DNS budget the platform cannot deliver". Only the second is refused.
*/
public static final Duration DEFAULT_DNS = Duration.ofMillis(300);
public TimeoutSettings {
Objects.requireNonNull(dns, "dns timeout");
Objects.requireNonNull(connect, "connect timeout");
Objects.requireNonNull(tlsHandshake, "tls handshake timeout");
Objects.requireNonNull(proxyConnect, "proxy connect timeout");
Objects.requireNonNull(requestWriteIdle, "request write idle timeout");
Objects.requireNonNull(responseHeader, "response header timeout");
Objects.requireNonNull(readIdle, "read idle timeout");
Objects.requireNonNull(totalCall, "total call timeout");
Objects.requireNonNull(streamingIdle, "streaming idle timeout");
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.httpclient.reactor;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import java.util.Objects;
import reactor.netty.resources.ConnectionProvider;
/**
* Creates a connection pool scoped to one profile (design §13.6).
*
* <p>A shared global pool would let one slow upstream starve every other one, so each profile gets
* its own named provider with its own limits and eviction.
*/
public final class ReactorConnectionProviderFactory {
/**
* Builds the pool, mapping each setting onto the Reactor knob that means the same thing.
*
* <p>{@code ConnectionProvider.maxConnections} is a <em>per-remote-host</em> ceiling, and it was
* being handed {@code maxTotalConnections}. For a trusted profile with one base URL the two
* coincide, so nothing looked wrong; for a dynamic profile talking to many hosts it meant every
* destination independently received the budget intended for all of them combined, and {@code
* maxConnectionsPerRoute} the setting that actually describes this limit was ignored
* entirely.
*
* <p>Reactor Netty has no cross-destination ceiling to map {@code maxTotalConnections} onto. That
* is a real gap rather than something to paper over: the validator requires per-route not to
* exceed the total, so the configured numbers stay coherent, and a dynamic profile's true global
* bound comes from the platform's own admission limiter.
*/
public ConnectionProvider create(ClientProfile profile) {
Objects.requireNonNull(profile, "profile");
return ConnectionProvider.builder(profile.name().value())
.maxConnections(profile.pool().maxConnectionsPerRoute())
.pendingAcquireMaxCount(profile.pool().maxPendingAcquires())
.pendingAcquireTimeout(profile.pool().pendingAcquireTimeout())
.maxIdleTime(profile.pool().maxIdleTime())
.maxLifeTime(profile.pool().maxLifeTime())
.evictInBackground(profile.pool().evictionInterval())
.metrics(true)
.build();
}
}
@@ -0,0 +1,137 @@
package dev.caskeleton.adapter.outbound.httpclient.reactor;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.net.UnknownHostException;
import java.security.cert.CertificateException;
import java.util.concurrent.TimeoutException;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLPeerUnverifiedException;
/**
* Maps Reactor Netty failures onto the same stable evidence the blocking transports produce (design
* §13.3).
*
* <p>Reactor wraps causes, so the chain is unwrapped before classification; a cancellation is
* reported as such rather than as a timeout, because the two have different retry meaning.
*/
public final class ReactorFailureClassifier implements TransportFailureClassifier {
@Override
public TransportFailure classify(Throwable failure, AttemptStage lastObservedStage) {
// Reactor wraps causes several layers deep; the whole chain is inspected so a wrapped
// ConnectException is still recognised as provably NOT_SENT.
for (Throwable cause : chain(failure)) {
TransportFailure recognized = recognize(cause, lastObservedStage);
if (recognized != null) {
return recognized;
}
}
return fallback(lastObservedStage);
}
private TransportFailure recognize(Throwable cause, AttemptStage lastObservedStage) {
if (cause instanceof UnknownHostException) {
return TransportFailure.notSent(
AttemptStage.DNS, FailureCategory.DNS, "DNS_RESOLUTION_FAILED");
}
if (cause instanceof ConnectException || cause instanceof NoRouteToHostException) {
return TransportFailure.notSent(
AttemptStage.CONNECT, FailureCategory.CONNECT, "CONNECT_FAILED");
}
if (cause instanceof io.netty.handler.ssl.SslHandshakeTimeoutException) {
// Checked before the SSLHandshakeException branch below, which it extends. Without this a
// handshake that merely ran out of time was classified TLS_PERMANENT the category that
// forbids retry so a momentarily slow peer produced a hard failure indistinguishable from
// an untrusted certificate.
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_HANDSHAKE_TIMEOUT");
}
if (cause instanceof SSLPeerUnverifiedException || cause instanceof CertificateException) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_TRUST_FAILED");
}
if (cause instanceof SSLHandshakeException) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_HANDSHAKE_FAILED");
}
if (cause instanceof SSLException
&& !lastObservedStage.isAtLeast(AttemptStage.REQUEST_HEADERS)) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_TRANSIENT_FAILURE");
}
if (isPoolAcquireTimeout(cause)) {
return TransportFailure.notSent(
AttemptStage.POOL_ACQUIRE, FailureCategory.POOL_ACQUIRE_TIMEOUT, "POOL_ACQUIRE_TIMEOUT");
}
if (cause instanceof java.util.concurrent.CancellationException) {
return new TransportFailure(
lastObservedStage,
lastObservedStage.provesNotSent()
? ExecutionEvidence.NOT_SENT
: ExecutionEvidence.SENT_NO_RESPONSE,
FailureCategory.CANCELLED,
"CANCELLED");
}
if (cause instanceof io.netty.handler.timeout.WriteTimeoutException) {
// Netty's timeout hierarchy does not extend java.util.concurrent.TimeoutException, so none of
// these reached the branch below every read or write timeout fell through to the generic
// fallback and lost its stage and category.
return TransportFailure.sentNoResponse(
AttemptStage.REQUEST_BODY, FailureCategory.REQUEST_WRITE, "REQUEST_WRITE_TIMEOUT");
}
if (cause instanceof TimeoutException
|| cause instanceof io.netty.handler.timeout.ReadTimeoutException) {
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
return new TransportFailure(
lastObservedStage,
ExecutionEvidence.PARTIAL_RESPONSE,
FailureCategory.RESPONSE_TIMEOUT,
"RESPONSE_BODY_TIMEOUT");
}
return TransportFailure.sentNoResponse(
AttemptStage.RESPONSE_HEADERS,
FailureCategory.RESPONSE_TIMEOUT,
"RESPONSE_HEADER_TIMEOUT");
}
return null;
}
private TransportFailure fallback(AttemptStage lastObservedStage) {
if (lastObservedStage.provesNotSent()) {
return TransportFailure.notSent(
lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
}
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
return new TransportFailure(
lastObservedStage,
ExecutionEvidence.PARTIAL_RESPONSE,
FailureCategory.RESPONSE_TRUNCATED,
"TRANSPORT_FAILURE");
}
return TransportFailure.sentNoResponse(
lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
}
private boolean isPoolAcquireTimeout(Throwable cause) {
String message = cause.getMessage();
return cause.getClass().getName().contains("PoolAcquireTimeoutException")
|| (message != null && message.contains("Pool#acquire(Duration)"));
}
private java.util.List<Throwable> chain(Throwable failure) {
java.util.List<Throwable> chain = new java.util.ArrayList<>();
Throwable current = failure;
while (current != null && !chain.contains(current)) {
chain.add(current);
current = current.getCause();
}
return chain;
}
}
@@ -0,0 +1,137 @@
package dev.caskeleton.adapter.outbound.httpclient.reactor;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
import io.netty.channel.ChannelOption;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.resolver.AddressResolverGroup;
import java.net.InetAddress;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import reactor.netty.http.Http11SslContextSpec;
import reactor.netty.http.Http2SslContextSpec;
import reactor.netty.http.HttpProtocol;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
import reactor.netty.tcp.SslProvider;
import reactor.netty.transport.ProxyProvider;
/**
* Builds the Reactor Netty client for one profile (design §13.6).
*
* <p>Redirect following is disabled here for the same reason as in the blocking transports: the
* platform re-validates each hop and strips credentials across origins, and the engine's follower
* does neither.
*/
public final class ReactorHttpClientFactory {
public HttpClient create(
ClientProfile profile,
ConnectionProvider connectionProvider,
Optional<SslContextMaterial> tlsMaterial,
Optional<Function<String, List<InetAddress>>> approvedAddresses) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(connectionProvider, "connection provider");
HttpClient client =
HttpClient.create(connectionProvider)
.option(
ChannelOption.CONNECT_TIMEOUT_MILLIS,
Math.toIntExact(profile.timeout().connect().toMillis()))
.responseTimeout(profile.timeout().responseHeader())
.followRedirect(false)
.compress(profile.request().compression())
.protocol(protocols(profile))
.metrics(true, Function.identity())
// request-write-idle and read-idle were bound and then reached no transport at all, so
// a request that stalled mid-write hung until the total-call budget expired instead of
// failing at the stage that actually stopped. Netty's idle handlers are where those two
// settings become real; they are installed per connection so a stall is attributed to
// the write or the read rather than to "the call".
.doOnConnected(
connection ->
connection
.addHandlerLast(
new io.netty.handler.timeout.WriteTimeoutHandler(
profile.timeout().requestWriteIdle().toMillis(),
java.util.concurrent.TimeUnit.MILLISECONDS))
.addHandlerLast(
new io.netty.handler.timeout.ReadTimeoutHandler(
profile.timeout().readIdle().toMillis(),
java.util.concurrent.TimeUnit.MILLISECONDS)));
if (approvedAddresses.isPresent()) {
AddressResolverGroup<?> resolver = new ValidatedAddressResolverGroup(approvedAddresses.get());
client = client.resolver(resolver);
}
// Configured whether or not custom material is supplied. It used to be inside this isPresent,
// so a profile declaring `tls.protocols: [TLSv1.3]` against the JVM trust store the common
// case configured no TLS parameters at all and accepted whatever the platform default
// allowed, TLS 1.2 included. A declared floor that only applies alongside a custom truststore
// is not a floor.
SslProvider.GenericSslContextSpec<SslContextBuilder> contextSpec =
sslContextSpec(profile, tlsMaterial.orElse(null));
client = client.secure(spec -> spec.sslContext(contextSpec));
if (profile.proxy().enabled()) {
client =
client.proxy(
spec ->
spec.type(
profile.proxy().type()
== dev.caskeleton.adapter.outbound.httpclient.profile.ProxyType
.SOCKS
? ProxyProvider.Proxy.SOCKS5
: ProxyProvider.Proxy.HTTP)
.host(profile.proxy().host())
.port(profile.proxy().port())
.connectTimeoutMillis(profile.proxy().connectTimeout().toMillis()));
}
return client;
}
/**
* The TLS spec, with or without custom material.
*
* <p>{@code material} is nullable on purpose: a profile using the JVM trust store still declares
* a protocol floor, and that floor has to reach the SSL context.
*/
private SslProvider.GenericSslContextSpec<SslContextBuilder> sslContextSpec(
ClientProfile profile, SslContextMaterial material) {
String[] tlsProtocols =
material != null
? material.protocolArray()
: profile.tls().protocols().toArray(String[]::new);
java.util.function.Consumer<SslContextBuilder> configurer =
builder -> {
if (material != null) {
material.trustManagerFactory().ifPresent(builder::trustManager);
material.keyManagerFactory().ifPresent(builder::keyManager);
}
builder.protocols(tlsProtocols);
};
return profile
.protocols()
.contains(dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol.HTTP_2)
? Http2SslContextSpec.forClient().configure(configurer)
: Http11SslContextSpec.forClient().configure(configurer);
}
/**
* Configures exactly the protocols the profile asked for.
*
* <p>{@code {H2, HTTP11}} used to be configured whenever HTTP/2 appeared in the set at all, so a
* profile that declared only HTTP/2 an operator stating a requirement negotiated HTTP/1.1
* against any peer that offered it, silently. An H2-required profile now gets H2 alone, and a
* peer that cannot speak it fails the handshake instead of downgrading.
*/
private HttpProtocol[] protocols(ClientProfile profile) {
return switch (dev.caskeleton.adapter.outbound.httpclient.profile.ProtocolIntent.of(
profile.protocols())) {
case H2_REQUIRED -> new HttpProtocol[] {HttpProtocol.H2};
case NEGOTIATE_H2_H1 -> new HttpProtocol[] {HttpProtocol.H2, HttpProtocol.HTTP11};
default -> new HttpProtocol[] {HttpProtocol.HTTP11};
};
}
}
@@ -0,0 +1,94 @@
package dev.caskeleton.adapter.outbound.httpclient.reactor;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportCapabilities;
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportProvider;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey;
import io.micrometer.core.instrument.MeterRegistry;
import java.net.InetAddress;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import reactor.netty.resources.ConnectionProvider;
/**
* Reactive default transport (design D-07, §13.6).
*
* <p>Returns a Spring {@code ClientHttpConnector}; the Reactor Netty {@code HttpClient} stays
* inside this package so application code cannot bypass profile configuration.
*/
public final class ReactorNettyTransportProvider implements ReactiveTransportProvider {
private static final TransportId ID = new TransportId("reactor-netty");
private final ReactorConnectionProviderFactory poolFactory =
new ReactorConnectionProviderFactory();
private final ReactorHttpClientFactory clientFactory = new ReactorHttpClientFactory();
private final ReactorFailureClassifier classifier = new ReactorFailureClassifier();
private final Map<TransportResourceKey, ConnectionProvider> pools = new ConcurrentHashMap<>();
private final Optional<MeterRegistry> meterRegistry;
private final Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver;
private final Function<ClientProfile, Optional<Function<String, List<InetAddress>>>>
resolverFactory;
public ReactorNettyTransportProvider() {
this(Optional.empty(), profile -> Optional.empty(), profile -> Optional.empty());
}
public ReactorNettyTransportProvider(
Optional<MeterRegistry> meterRegistry,
Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver,
Function<ClientProfile, Optional<Function<String, List<InetAddress>>>> resolverFactory) {
this.meterRegistry = Objects.requireNonNull(meterRegistry, "meter registry");
this.tlsMaterialResolver = Objects.requireNonNull(tlsMaterialResolver, "tls material resolver");
this.resolverFactory = Objects.requireNonNull(resolverFactory, "dns resolver factory");
}
@Override
public TransportId id() {
return ID;
}
@Override
public ReactiveTransportCapabilities capabilities() {
return ReactiveTransportCapabilities.reactorNetty();
}
@Override
public ClientHttpConnector create(
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(listener, "lifecycle listener");
ConnectionProvider pool = poolFactory.create(profile);
pools.put(new TransportResourceKey(profile.name(), generation), pool);
meterRegistry.ifPresent(
registry -> ReactorPoolMetricsBinder.bind(registry, profile.name(), pool));
listener.onRuntimeCreated(profile.name(), ID);
return new ReactorClientHttpConnector(
clientFactory.create(
profile, pool, tlsMaterialResolver.apply(profile), resolverFactory.apply(profile)));
}
@Override
public TransportFailureClassifier failureClassifier() {
return classifier;
}
@Override
public void close(ClientProfile profile, RuntimeGeneration generation) {
ConnectionProvider pool = pools.remove(new TransportResourceKey(profile.name(), generation));
if (pool != null) {
pool.disposeLater().block(profile.pool().shutdownTimeout());
}
}
}
@@ -0,0 +1,135 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpBulkheadRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpCircuitOpenException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Fixed guard order for every physical attempt (design D-11, §18): Circuit Breaker Rate Limiter
* Bulkhead HTTP call, released in reverse.
*
* <p>The order is not cosmetic. An open circuit must reject before a rate token or a bulkhead
* permit is spent, otherwise a dead upstream keeps consuming the quota and concurrency that healthy
* upstreams need.
*
* <p>A local rejection (rate limiter or bulkhead) is deliberately <em>not</em> recorded as a
* circuit error: the upstream never saw the request, and counting our own back-pressure as upstream
* failure would open the breaker on a healthy dependency.
*/
public final class AttemptResiliencePipeline {
private final AttemptCircuitBreaker circuitBreaker;
private final AttemptRateLimiter rateLimiter;
private final BlockingAttemptBulkhead bulkhead;
private final Supplier<HttpFailureMetadata> metadataSupplier;
private final ResilienceRejectionRecorder rejections;
/** Keeps the existing four-argument shape for callers that do not record metrics. */
public AttemptResiliencePipeline(
AttemptCircuitBreaker circuitBreaker,
AttemptRateLimiter rateLimiter,
BlockingAttemptBulkhead bulkhead,
Supplier<HttpFailureMetadata> metadataSupplier) {
this(
circuitBreaker,
rateLimiter,
bulkhead,
metadataSupplier,
ResilienceRejectionRecorder.noop());
}
public AttemptResiliencePipeline(
AttemptCircuitBreaker circuitBreaker,
AttemptRateLimiter rateLimiter,
BlockingAttemptBulkhead bulkhead,
Supplier<HttpFailureMetadata> metadataSupplier,
ResilienceRejectionRecorder rejections) {
this.circuitBreaker = Objects.requireNonNull(circuitBreaker, "circuit breaker");
this.rateLimiter = Objects.requireNonNull(rateLimiter, "rate limiter");
this.bulkhead = Objects.requireNonNull(bulkhead, "bulkhead");
this.metadataSupplier = Objects.requireNonNull(metadataSupplier, "failure metadata supplier");
this.rejections = Objects.requireNonNull(rejections, "rejection recorder");
}
public String circuitState() {
return circuitBreaker.state();
}
public <T> T execute(AttemptCall<T> call) {
return execute(call, result -> Optional.empty());
}
/**
* Runs one attempt and records the breaker outcome from the <em>remote</em> result.
*
* <p>The classifier exists because a returned value is not necessarily a success. The blocking
* executor used to run only the raw send inside this pipeline and map the response to a stable
* exception afterwards, outside it so a 503 completed the call normally, the breaker recorded a
* success, and an upstream that answered nothing but 503 never opened its circuit. The thing the
* breaker is for was the one thing it could not see.
*
* @param call the attempt, including any redirect hops it follows
* @param remoteFailure returns the failure to record when the value represents an upstream error
* @return the attempt's value, whether or not it represents a remote failure
*/
public <T> T execute(AttemptCall<T> call, Function<T, Optional<Throwable>> remoteFailure) {
Objects.requireNonNull(remoteFailure, "remote failure classifier");
if (!circuitBreaker.tryAcquirePermission()) {
rejections.circuitOpen();
throw new HttpCircuitOpenException(
"upstream circuit breaker is open", metadataSupplier.get());
}
if (!rateLimiter.tryAcquirePermission()) {
rejections.rateLimited();
throw new HttpRateLimitRejectedException(
"local attempt rate limit reached", metadataSupplier.get());
}
if (!bulkhead.tryAcquire()) {
rateLimiter.onCompleted();
rejections.bulkheadRejected();
throw new HttpBulkheadRejectedException(
"attempt bulkhead has no permit available", metadataSupplier.get());
}
long started = System.nanoTime();
try {
T result = call.call();
releaseAttemptPermits();
Optional<Throwable> upstreamFailure = remoteFailure.apply(result);
if (upstreamFailure.isPresent()) {
circuitBreaker.onError(System.nanoTime() - started, upstreamFailure.get());
} else {
circuitBreaker.onSuccess(System.nanoTime() - started);
}
return result;
} catch (Throwable failure) {
releaseAttemptPermits();
circuitBreaker.onError(System.nanoTime() - started, failure);
throw translate(failure);
}
}
private void releaseAttemptPermits() {
bulkhead.release();
rateLimiter.onCompleted();
}
private RuntimeException translate(Throwable failure) {
if (failure instanceof HttpClientException stable) {
return stable;
}
if (failure instanceof RuntimeException runtime) {
return runtime;
}
if (failure instanceof Error error) {
throw error;
}
return new IllegalStateException("outbound http attempt failed", failure);
}
}
@@ -0,0 +1,133 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import java.time.Duration;
import java.util.Optional;
/**
* The complete ordered retry decision table (design §17.3).
*
* <p>The order is the point. Cheap absolute blockers come first (attempts, budget, replayability,
* first byte, deadline, draining), then ambiguity, then status- and failure-specific rules. A later
* rule can never re-enable something an earlier rule forbade.
*/
public final class DefaultRetryEligibilityEngine implements RetryEligibilityEngine {
@Override
public RetryDecision decide(RetryContext context) {
if (context.attempt() >= context.maxAttempts()) {
return RetryDenied.maxAttempts();
}
if (!context.budget().available()) {
return RetryDenied.budgetExhausted();
}
if (!context.replayability().canReplay()) {
return RetryDenied.bodyNotReplayable();
}
if (context.firstByteDelivered()) {
return RetryDenied.responseAlreadyDelivered();
}
if (context.runtimeDraining()) {
return RetryDenied.runtimeDraining();
}
if (context.remainingDeadline().compareTo(context.minimumAttemptBudget()) <= 0) {
return RetryDenied.deadline();
}
if (context.failureCategory().permanent()) {
return RetryDenied.permanentFailure(context.failureCategory().name());
}
if (context.evidence() == ExecutionEvidence.PARTIAL_RESPONSE) {
// A partial response that never reached the caller may still be retried for a safe
// operation; once a byte was delivered the earlier guard has already denied it.
return context.safelyIdempotent()
? RetryAllowed.of("PARTIAL_RESPONSE")
: AmbiguousFailure.remoteOutcomeUnknown();
}
if (context.evidence() == ExecutionEvidence.SENT_NO_RESPONSE && !context.safelyIdempotent()) {
return AmbiguousFailure.remoteOutcomeUnknown();
}
return statusOrFailureDecision(context);
}
private RetryDecision statusOrFailureDecision(RetryContext context) {
Optional<HttpStatus> status = context.responseStatus();
if (status.isPresent()) {
return statusDecision(context, status.get().value());
}
return failureDecision(context);
}
private RetryDecision statusDecision(RetryContext context, int status) {
return switch (status) {
// 408, 425 and 429 all mean the request reached the upstream and was answered, so repeating
// one is only safe under the same rule as every other repeat. These three used to skip that
// check: a non-idempotent POST answered 429 was retried, and a rate-limited upstream that had
// already accepted the work got it a second time. A 429 is a scheduling signal, never a
// statement that nothing happened.
case 408 ->
context.safelyIdempotent()
? allowWithin(context, "REQUEST_TIMEOUT")
: AmbiguousFailure.remoteOutcomeUnknown();
// 425 Too Early: repeating once without early data is safe; repeating repeatedly is not.
case 425 -> {
if (!context.safelyIdempotent()) {
yield AmbiguousFailure.remoteOutcomeUnknown();
}
yield context.attempt() == 1
? allowWithin(context, "TOO_EARLY")
: RetryDenied.maxAttempts();
}
case 429 ->
context.safelyIdempotent()
? allowWithin(context, "RATE_LIMITED")
: RetryDenied.notRetryableStatus(status);
case 401 ->
context.credentialRefreshAvailable()
&& context.attempt() == 1
&& context.safelyIdempotent()
? RetryAllowed.of("UNAUTHORIZED_REFRESH")
: RetryDenied.notRetryableStatus(status);
case 500 ->
context.transientServerErrorStatuses().contains(500) && context.safelyIdempotent()
? allowWithin(context, "UPSTREAM_TRANSIENT")
: RetryDenied.notRetryableStatus(status);
case 502, 503, 504 ->
context.safelyIdempotent()
? allowWithin(context, "UPSTREAM_UNAVAILABLE")
: AmbiguousFailure.remoteOutcomeUnknown();
default -> RetryDenied.notRetryableStatus(status);
};
}
private RetryDecision failureDecision(RetryContext context) {
return switch (context.failureCategory()) {
case POOL_ACQUIRE_TIMEOUT -> RetryAllowed.of("POOL_ACQUIRE_TIMEOUT");
case DNS -> RetryAllowed.of("DNS");
case CONNECT -> RetryAllowed.of("CONNECT");
case PROXY -> RetryAllowed.of("PROXY");
case TLS_TRANSIENT -> RetryAllowed.of("TLS_TRANSIENT");
case REQUEST_WRITE, RESPONSE_TIMEOUT, RESPONSE_TRUNCATED ->
context.safelyIdempotent()
? RetryAllowed.of(context.failureCategory().name())
: AmbiguousFailure.remoteOutcomeUnknown();
case NONE -> RetryDenied.success();
case CIRCUIT_OPEN, RATE_LIMIT_REJECTED, BULKHEAD_REJECTED ->
RetryDenied.permanentFailure(context.failureCategory().name());
default -> RetryDenied.permanentFailure(context.failureCategory().name());
};
}
/** Honors {@code Retry-After} only when the wait still fits inside the remaining deadline. */
private RetryDecision allowWithin(RetryContext context, String reason) {
Optional<Duration> retryAfter = context.retryAfter();
if (retryAfter.isEmpty()) {
return RetryAllowed.of(reason);
}
Duration required = retryAfter.get().plus(context.minimumAttemptBudget());
if (required.compareTo(context.remainingDeadline()) > 0) {
return RetryDenied.deadline();
}
return RetryAllowed.after(reason, retryAfter.get());
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import dev.caskeleton.adapter.outbound.httpclient.profile.JitterStrategy;
import dev.caskeleton.adapter.outbound.httpclient.profile.RetryAfterPolicy;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
import java.util.random.RandomGenerator;
/**
* Exponential backoff with jitter, bounded by max backoff, {@code Retry-After}, and the remaining
* deadline (design §17.5).
*
* <p>Jitter is not decoration: without it, a fleet that failed together retries together, and the
* upstream recovery window never opens. The random source is injectable so the schedule is
* testable.
*/
public final class ExponentialFullJitterBackoff implements BackoffStrategy {
private final Duration baseBackoff;
private final Duration maxBackoff;
private final JitterStrategy jitter;
private final RetryAfterPolicy retryAfterPolicy;
private final RandomGenerator random;
private Duration previousDelay;
public ExponentialFullJitterBackoff(
Duration baseBackoff,
Duration maxBackoff,
JitterStrategy jitter,
RetryAfterPolicy retryAfterPolicy,
RandomGenerator random) {
this.baseBackoff = Objects.requireNonNull(baseBackoff, "base backoff");
this.maxBackoff = Objects.requireNonNull(maxBackoff, "max backoff");
this.jitter = Objects.requireNonNull(jitter, "jitter strategy");
this.retryAfterPolicy = Objects.requireNonNull(retryAfterPolicy, "retry-after policy");
this.random = Objects.requireNonNull(random, "random generator");
this.previousDelay = baseBackoff;
}
/**
* The wait before the next attempt.
*
* <p>An honoured {@code Retry-After} is <em>not</em> clamped to {@code maxBackoff}. It used to
* be, which made {@link RetryAfterPolicy#HONOR} and {@link RetryAfterPolicy#CAP} the same policy:
* a profile that chose to honour a rate limiter's instruction still retried after its own 200ms
* ceiling, hammering an upstream that had asked for thirty seconds. Two settings that cannot
* differ are one setting and a false promise.
*
* <p>The remaining deadline still bounds everything, because waiting past the point where the
* next attempt could finish is not a retry it is a slower failure. The retry engine separately
* refuses to honour a {@code Retry-After} that does not fit, so the two agree.
*/
@Override
public Duration delay(
int completedAttempts, Optional<Duration> retryAfter, Duration remainingDeadline) {
Optional<Duration> honoured = honoredRetryAfter(retryAfter);
Duration candidate = honoured.orElseGet(() -> min(computed(completedAttempts), maxBackoff));
previousDelay = candidate.isZero() ? baseBackoff : min(candidate, maxBackoff);
// Never wait past the point where the following attempt could still finish.
return min(candidate, remainingDeadline);
}
private Optional<Duration> honoredRetryAfter(Optional<Duration> retryAfter) {
return switch (retryAfterPolicy) {
case IGNORE -> Optional.empty();
case HONOR -> retryAfter;
case CAP -> retryAfter.map(value -> min(value, maxBackoff));
};
}
private Duration computed(int completedAttempts) {
long exponent = Math.max(0, completedAttempts - 1);
long scaled = baseBackoff.toMillis() << Math.min(exponent, 20);
long capped = Math.min(scaled, maxBackoff.toMillis());
return switch (jitter) {
case NONE -> Duration.ofMillis(capped);
case FULL -> Duration.ofMillis(capped <= 0 ? 0 : random.nextLong(capped + 1));
case DECORRELATED -> {
long lower = baseBackoff.toMillis();
long upper = Math.min(maxBackoff.toMillis(), Math.max(lower, previousDelay.toMillis() * 3));
yield Duration.ofMillis(upper <= lower ? lower : random.nextLong(lower, upper + 1));
}
};
}
private static Duration min(Duration left, Duration right) {
if (left.isNegative()) {
return Duration.ZERO;
}
return left.compareTo(right) <= 0 ? left : right;
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.observation.HttpClientObservationNames;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tag;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Publishes the local back-pressure signals the platform already produced but never recorded.
*
* <p>{@code http.client.rate_limit.rejected}, {@code http.client.bulkhead.rejected} and {@code
* http.client.circuit.state} were declared in the metric vocabulary, documented in the support
* matrix, and emitted by nothing. That is the worst arrangement of the three possibilities: an
* operator building a dashboard finds the names, charts them, and sees a flat zero during the exact
* incident the metrics exist to explain a saturated bulkhead and an open breaker look identical
* to a healthy system.
*
* <p>The circuit state is a gauge rather than a counter because "how long was it open" is the
* question an incident actually asks; the rejections are counters because each one is a request
* that did not happen.
*/
public interface ResilienceRejectionRecorder {
void circuitOpen();
void rateLimited();
void bulkheadRejected();
/** Used where metrics are not wired, such as hand-constructed test pipelines. */
static ResilienceRejectionRecorder noop() {
return new ResilienceRejectionRecorder() {
@Override
public void circuitOpen() {
// no-op
}
@Override
public void rateLimited() {
// no-op
}
@Override
public void bulkheadRejected() {
// no-op
}
};
}
/**
* Binds the counters and the circuit-state gauge for one profile.
*
* @param registry the meter registry
* @param clientName the profile the meters are tagged with
* @param circuitState supplies the breaker's current state name for the gauge
* @return a recorder that publishes to {@code registry}
*/
static ResilienceRejectionRecorder micrometer(
MeterRegistry registry, ClientProfileName clientName, Supplier<String> circuitState) {
Objects.requireNonNull(registry, "meter registry");
Objects.requireNonNull(clientName, "client name");
Objects.requireNonNull(circuitState, "circuit state supplier");
List<Tag> tags = List.of(Tag.of("clientName", clientName.value()));
// 1 while the breaker is refusing traffic, 0 otherwise. A state *name* cannot be a gauge value,
// and putting it in a tag would make the series change identity every time the breaker moved.
registry.gauge(
HttpClientObservationNames.CIRCUIT_STATE,
tags,
circuitState,
supplier -> "OPEN".equalsIgnoreCase(supplier.get()) ? 1.0 : 0.0);
return new ResilienceRejectionRecorder() {
@Override
public void circuitOpen() {
registry.counter(HttpClientObservationNames.CIRCUIT_STATE + ".rejected", tags).increment();
}
@Override
public void rateLimited() {
registry.counter(HttpClientObservationNames.RATE_LIMIT_REJECTED, tags).increment();
}
@Override
public void bulkheadRejected() {
registry.counter(HttpClientObservationNames.BULKHEAD_REJECTED, tags).increment();
}
};
}
}
@@ -0,0 +1,77 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus;
import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.OperationIdempotency;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/**
* Everything the retry decision is allowed to depend on (design §17.1).
*
* <p>The HTTP method is deliberately absent: design D-09 makes idempotency an explicit operation
* property, so a POST with a registered idempotency key and a GET against a non-idempotent RPC
* endpoint are both handled correctly instead of by method-name folklore.
*/
public record RetryContext(
OperationIdempotency idempotency,
Optional<IdempotencyKey> idempotencyKey,
boolean idempotencyKeySent,
BodyReplayability replayability,
ExecutionEvidence evidence,
FailureCategory failureCategory,
Optional<HttpStatus> responseStatus,
Optional<Duration> retryAfter,
int attempt,
int maxAttempts,
boolean firstByteDelivered,
Duration remainingDeadline,
Duration minimumAttemptBudget,
RetryBudgetSnapshot budget,
Set<Integer> transientServerErrorStatuses,
boolean credentialRefreshAvailable,
boolean runtimeDraining) {
public RetryContext {
Objects.requireNonNull(idempotency, "idempotency");
Objects.requireNonNull(idempotencyKey, "idempotency key");
Objects.requireNonNull(replayability, "replayability");
Objects.requireNonNull(evidence, "evidence");
Objects.requireNonNull(failureCategory, "failure category");
Objects.requireNonNull(responseStatus, "response status");
Objects.requireNonNull(retryAfter, "retry-after");
Objects.requireNonNull(remainingDeadline, "remaining deadline");
Objects.requireNonNull(minimumAttemptBudget, "minimum attempt budget");
Objects.requireNonNull(budget, "retry budget snapshot");
Objects.requireNonNull(transientServerErrorStatuses, "transient server error statuses");
if (attempt < 1) {
throw new IllegalArgumentException("attempt must be at least 1");
}
if (maxAttempts < 1) {
throw new IllegalArgumentException("max attempts must be at least 1");
}
transientServerErrorStatuses = Set.copyOf(transientServerErrorStatuses);
}
/**
* True when repeating a request that may already have been processed is contractually safe.
*
* <p>For a key-bearing operation this requires that the key was actually written to the request,
* not merely that the caller supplied one. The two used to be conflated: the platform read {@code
* idempotencyKey.isPresent()}, concluded the upstream could deduplicate, and retried while the
* header was never sent, so the upstream had nothing to deduplicate against and processed the
* request twice. Possession of a key is the caller's intent; transmission is the upstream's
* ability to honour it, and only the second one makes a repeat safe.
*/
public boolean safelyIdempotent() {
return idempotency.safeToRepeatWithoutKey()
|| (idempotency == OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED
&& idempotencyKey.isPresent()
&& idempotencyKeySent);
}
}
@@ -0,0 +1,269 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptProgressTracker;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ProtocolEvidence;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedTarget;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Executes exactly one physical attempt, including its redirect hops (design §7.1 steps 9-12).
*
* <p>Progress is tracked as the attempt advances so the evidence classifier has real observations
* to work from rather than an exception type. Failures become {@link AttemptOutcome} values instead
* of escaping, because the coordinator not the transport decides whether a failure is final.
*/
public final class BlockingAttemptExecutor {
public <T> AttemptOutcome<T> execute(
BlockingClientRuntime runtime,
PreparedOperation prepared,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy,
RequestCredentials credentials,
int attemptNumber,
Instant startedAt,
HttpFailureMetadata baseMetadata) {
Objects.requireNonNull(runtime, "runtime");
BlockingExecutionSupport support = runtime.support();
AttemptProgressTracker tracker = new AttemptProgressTracker();
ResponseSizeLimiter limiter =
new ResponseSizeLimiter(
prepared.maxResponseWireBytes(), prepared.maxResponseDecodedBytes(), baseMetadata);
try {
// Send, follow redirects, and map the response all inside one set of resilience permits.
// Two things depended on that: the breaker now sees the mapped remote outcome rather than
// "the socket returned bytes", and a redirect hop no longer re-enters the pipeline while the
// permits for its own attempt are still held which with a single-permit bulkhead was a
// guaranteed self-rejection, and with any configuration double-counted the rate limiter.
return runtime
.resiliencePipeline()
.execute(
() -> {
RestClientResponseReader.RawResponse response =
sendWithRedirects(
runtime, prepared, credentials, tracker, limiter, baseMetadata);
Duration elapsed = Duration.between(startedAt, support.clock().instant());
try {
return AttemptOutcome.succeeded(
support
.responseMapper()
.map(
response,
responseType,
runtime.profile().response(),
statusHandlingPolicy,
attemptNumber,
elapsed,
baseMetadata));
} catch (HttpRemoteErrorException remoteError) {
// Retry-After is read from the response the mapper already bounded, so the
// decision engine can honour it without the transport interpreting it.
return AttemptOutcome.<T>failed(
remoteError,
FailureCategory.REMOTE_STATUS,
retryAfter(response),
tracker.firstByteDelivered());
} catch (HttpClientException stable) {
return AttemptOutcome.<T>failed(
stable, categoryOf(stable), Optional.empty(), tracker.firstByteDelivered());
}
},
outcome ->
outcome.failureCategory() == FailureCategory.REMOTE_STATUS
? outcome.failure().map(failure -> (Throwable) failure)
: Optional.empty());
} catch (HttpClientException stable) {
return AttemptOutcome.failed(
stable, categoryOf(stable), Optional.empty(), tracker.firstByteDelivered());
} catch (RuntimeException engineFailure) {
TransportFailure classified =
runtime.failureClassifier().classify(engineFailure, tracker.stage());
HttpFailureMetadata metadata =
baseMetadata
.withEvidence(
support
.evidenceClassifier()
.classify(tracker.snapshot(), ProtocolEvidence.none()))
.withStage(classified.stage());
HttpClientException mapped =
support.exceptionMapper().map(classified, metadata, engineFailure);
return AttemptOutcome.failed(
mapped, classified.category(), Optional.empty(), tracker.firstByteDelivered());
}
}
private RestClientResponseReader.RawResponse sendWithRedirects(
BlockingClientRuntime runtime,
PreparedOperation prepared,
RequestCredentials credentials,
AttemptProgressTracker tracker,
ResponseSizeLimiter limiter,
HttpFailureMetadata metadata) {
Map<String, List<String>> headers = withCredentials(prepared.headers(), credentials);
PreparedTarget target = prepared.target();
URI initialUri = withCredentialQuery(target.uri(), credentials);
RestClientResponseReader.RawResponse first =
send(
runtime,
initialUri,
prepared.operation().method(),
headers,
prepared.operation().body(),
tracker,
limiter,
metadata);
BlockingRedirectCoordinator coordinator =
new BlockingRedirectCoordinator(
runtime.support().redirectEvaluator(),
runtime.support().headerStripper(),
runtime.targetPolicy()::requireAllowedTarget);
return coordinator.follow(
first,
runtime.redirectPolicy(),
PreparedTarget.of(initialUri, target.uriTemplate()),
prepared.operation().method(),
prepared.operation().body(),
headers,
// No nested pipeline. A hop runs under the permits its own attempt already holds; taking a
// second set would deadlock a single-permit bulkhead against itself and charge the rate
// limiter twice for one logical attempt.
(hopTarget, hopMethod, hopHeaders, hopBody) ->
send(
runtime,
hopTarget,
hopMethod,
hopHeaders,
hopBody,
new AttemptProgressTracker(),
limiter,
metadata),
metadata);
}
private RestClientResponseReader.RawResponse send(
BlockingClientRuntime runtime,
URI uri,
HttpMethod method,
Map<String, List<String>> headers,
dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource body,
AttemptProgressTracker tracker,
ResponseSizeLimiter limiter,
HttpFailureMetadata metadata) {
tracker.enter(AttemptStage.POOL_ACQUIRE);
RestClient.RequestBodySpec spec =
runtime
.restClient()
.method(org.springframework.http.HttpMethod.valueOf(method.name()))
.uri(uri);
headers.forEach((name, values) -> values.forEach(value -> spec.header(name, value)));
tracker.enter(AttemptStage.REQUEST_HEADERS);
RestClient.RequestHeadersSpec<?> request =
runtime.support().bodyWriter().write(spec, body, runtime.bodyLimitPolicy(), metadata);
tracker.enter(AttemptStage.REQUEST_BODY);
tracker.requestWriteStarted();
return request.exchange(
(httpRequest, httpResponse) -> {
tracker.enter(AttemptStage.RESPONSE_HEADERS);
tracker.responseHeadersReceived();
RestClientResponseReader.RawResponse response =
runtime
.support()
.responseReader()
.readBounded(
httpResponse.getStatusCode().value(),
httpResponse.getHeaders(),
httpResponse.getBody(),
limiter);
tracker.enter(AttemptStage.COMPLETE);
return response;
},
true);
}
private Map<String, List<String>> withCredentials(
Map<String, List<String>> headers, RequestCredentials credentials) {
if (credentials.empty()) {
return headers;
}
Map<String, List<String>> merged = new LinkedHashMap<>(headers);
credentials.headers().forEach((name, value) -> merged.put(name, List.of(value)));
return Map.copyOf(merged);
}
private URI withCredentialQuery(URI uri, RequestCredentials credentials) {
if (credentials.queryParameters().isEmpty()) {
return uri;
}
UriComponentsBuilder builder = UriComponentsBuilder.fromUri(uri);
credentials.queryParameters().forEach(builder::queryParam);
return builder.build(true).toUri();
}
/** Reads {@code Retry-After} from a response the mapper already bounded. */
public static Optional<Duration> retryAfter(RestClientResponseReader.RawResponse response) {
Optional<String> header = response.firstHeader(HttpHeaders.RETRY_AFTER);
if (header.isEmpty()) {
return Optional.empty();
}
try {
return Optional.of(Duration.ofSeconds(Long.parseLong(header.get().trim())));
} catch (NumberFormatException httpDate) {
// An HTTP-date Retry-After is valid but its value depends on clock agreement we do not have;
// falling back to the platform backoff is safer than trusting a skewed absolute time.
return Optional.empty();
}
}
private FailureCategory categoryOf(HttpClientException failure) {
return switch (failure.getClass().getSimpleName()) {
case "HttpDnsException" -> FailureCategory.DNS;
case "HttpPoolAcquireTimeoutException" -> FailureCategory.POOL_ACQUIRE_TIMEOUT;
case "HttpConnectException" -> FailureCategory.CONNECT;
case "HttpProxyException" -> FailureCategory.PROXY;
case "HttpTlsException" -> FailureCategory.TLS_PERMANENT;
case "HttpRequestWriteException" -> FailureCategory.REQUEST_WRITE;
case "HttpResponseTimeoutException" -> FailureCategory.RESPONSE_TIMEOUT;
case "HttpResponseTruncatedException" -> FailureCategory.RESPONSE_TRUNCATED;
case "HttpResponseTooLargeException" -> FailureCategory.RESPONSE_TOO_LARGE;
case "HttpSerializationException" -> FailureCategory.SERIALIZATION;
case "HttpTargetRejectedException" -> FailureCategory.TARGET_REJECTED;
case "HttpRedirectRejectedException" -> FailureCategory.REDIRECT_REJECTED;
case "HttpAuthenticationException" -> FailureCategory.AUTHENTICATION;
case "HttpCircuitOpenException" -> FailureCategory.CIRCUIT_OPEN;
case "HttpRateLimitRejectedException" -> FailureCategory.RATE_LIMIT_REJECTED;
case "HttpBulkheadRejectedException" -> FailureCategory.BULKHEAD_REJECTED;
case "HttpDeadlineExceededException" -> FailureCategory.DEADLINE_EXCEEDED;
case "HttpConfigurationException" -> FailureCategory.CONFIGURATION;
default -> FailureCategory.UNKNOWN;
};
}
}
@@ -0,0 +1,132 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentialProvider;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptResiliencePipeline;
import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy;
import dev.caskeleton.adapter.outbound.httpclient.resilience.LogicalAdmissionLimiter;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget;
import dev.caskeleton.adapter.outbound.httpclient.security.BodyLimitPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.TrustedTargetPolicy;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
import java.util.Objects;
import java.util.function.Supplier;
import org.springframework.web.client.RestClient;
/**
* One immutable blocking generation: a RestClient plus everything needed to execute against it
* (design §7.2, §26.1).
*
* <p>The {@code RestClient} is created once and never mutated. Callers cannot obtain its builder,
* which is what stops a caller from quietly removing an interceptor or changing a timeout.
*/
public final class BlockingClientRuntime extends ClientRuntime {
private final RestClient restClient;
private final TransportId transportId;
private final TransportFailureClassifier failureClassifier;
private final TrustedTargetPolicy targetPolicy;
private final BodyLimitPolicy bodyLimitPolicy;
private final RedirectPolicy redirectPolicy;
private final AttemptResiliencePipeline resiliencePipeline;
private final LogicalAdmissionLimiter admissionLimiter;
private final RetryBudget retryBudget;
private final Supplier<BackoffStrategy> backoffFactory;
private final RequestCredentialProvider credentialProvider;
private final BlockingExecutionSupport support;
public BlockingClientRuntime(
ClientProfile profile,
RuntimeGeneration generation,
Runnable resourceCloser,
RestClient restClient,
TransportId transportId,
TransportFailureClassifier failureClassifier,
AttemptResiliencePipeline resiliencePipeline,
LogicalAdmissionLimiter admissionLimiter,
RetryBudget retryBudget,
Supplier<BackoffStrategy> backoffFactory,
RequestCredentialProvider credentialProvider,
BlockingExecutionSupport support) {
super(profile, generation, resourceCloser);
this.restClient = Objects.requireNonNull(restClient, "rest client");
this.transportId = Objects.requireNonNull(transportId, "transport id");
this.failureClassifier = Objects.requireNonNull(failureClassifier, "failure classifier");
this.resiliencePipeline = Objects.requireNonNull(resiliencePipeline, "resilience pipeline");
this.admissionLimiter = Objects.requireNonNull(admissionLimiter, "admission limiter");
this.retryBudget = Objects.requireNonNull(retryBudget, "retry budget");
this.backoffFactory = Objects.requireNonNull(backoffFactory, "backoff factory");
this.credentialProvider = Objects.requireNonNull(credentialProvider, "credential provider");
this.support = Objects.requireNonNull(support, "execution support");
this.targetPolicy = new TrustedTargetPolicy(profile);
this.bodyLimitPolicy = BodyLimitPolicy.maxRequestBytes(profile.request().maxBodyBytes());
this.redirectPolicy =
profile.mode() == dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode.DYNAMIC
? RedirectPolicy.managedByCaller()
: RedirectPolicy.from(profile.redirect());
}
/**
* The engine client, visible only inside this package.
*
* <p>It used to be public, which meant any caller holding a runtime could execute a request that
* skipped target policy, credentials, admission, deadline, resilience, byte limits, stable error
* mapping and observation every guarantee the profile exists to provide. The typed registries
* did exactly that. Package-private is what makes the platform's guarantees structural rather
* than a convention, and {@code PublicApiArchitectureTest} keeps Spring's client types confined
* here.
*
* @return the profile's immutable {@code RestClient}
*/
RestClient restClient() {
return restClient;
}
public TransportId transportId() {
return transportId;
}
public TransportFailureClassifier failureClassifier() {
return failureClassifier;
}
public TrustedTargetPolicy targetPolicy() {
return targetPolicy;
}
public BodyLimitPolicy bodyLimitPolicy() {
return bodyLimitPolicy;
}
public RedirectPolicy redirectPolicy() {
return redirectPolicy;
}
public AttemptResiliencePipeline resiliencePipeline() {
return resiliencePipeline;
}
public LogicalAdmissionLimiter admissionLimiter() {
return admissionLimiter;
}
public RetryBudget retryBudget() {
return retryBudget;
}
public BackoffStrategy newBackoff() {
return backoffFactory.get();
}
public RequestCredentialProvider credentialProvider() {
return credentialProvider;
}
public BlockingExecutionSupport support() {
return support;
}
}
@@ -0,0 +1,114 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRedirectRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedTarget;
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectContext;
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectDecision;
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectEvaluator;
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.SensitiveHeaderStripper;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Follows redirects explicitly, one evaluated hop at a time (design §12.4).
*
* <p>Engine-level redirect following is disabled in every transport, so this is the only place a
* hop can happen. Each hop re-applies target policy and, when the origin changes, drops the
* credentials which is exactly what an engine's built-in follower does not do.
*
* <p>A hop is a physical request: it passes through the resilience pipeline via the supplied
* sender, so it consumes rate and bulkhead capacity. It is not a retry, because nothing failed.
*/
public final class BlockingRedirectCoordinator {
/** Issues one physical request; supplied by the executor so hops share the attempt pipeline. */
@FunctionalInterface
public interface HopSender {
RestClientResponseReader.RawResponse send(
URI target, HttpMethod method, Map<String, List<String>> headers, BodySource body);
}
/** Re-applies the profile's own origin allowlist to a hop target. */
@FunctionalInterface
public interface TargetGuard {
void requireAllowed(URI target, HttpFailureMetadata metadata);
}
private final RedirectEvaluator evaluator;
private final SensitiveHeaderStripper headerStripper;
private final TargetGuard targetGuard;
public BlockingRedirectCoordinator(
RedirectEvaluator evaluator,
SensitiveHeaderStripper headerStripper,
TargetGuard targetGuard) {
this.evaluator = Objects.requireNonNull(evaluator, "redirect evaluator");
this.headerStripper = Objects.requireNonNull(headerStripper, "sensitive header stripper");
this.targetGuard = Objects.requireNonNull(targetGuard, "target guard");
}
public RestClientResponseReader.RawResponse follow(
RestClientResponseReader.RawResponse initial,
RedirectPolicy policy,
PreparedTarget initialTarget,
HttpMethod method,
BodySource body,
Map<String, List<String>> headers,
HopSender sender,
HttpFailureMetadata metadata) {
if (policy.callerManaged()) {
// A Dynamic Target owns its own hop validation; following here would skip it.
return initial;
}
RestClientResponseReader.RawResponse response = initial;
PreparedTarget currentTarget = initialTarget;
Map<String, List<String>> currentHeaders = headers;
HttpMethod currentMethod = method;
BodySource currentBody = body;
for (int hop = 0; isRedirect(response.status()); hop++) {
Optional<String> location = response.firstHeader("Location");
if (location.isEmpty()) {
return response;
}
URI target = currentTarget.uri().resolve(location.get());
RedirectContext context =
RedirectContext.of(policy, hop, response.status(), currentBody, currentTarget, target);
RedirectDecision decision = evaluator.evaluate(context);
if (decision instanceof RedirectDecision.Reject reject) {
throw new HttpRedirectRejectedException("redirect rejected: " + reject.code(), metadata);
}
RedirectDecision.Follow follow = (RedirectDecision.Follow) decision;
// The redirect policy decides whether a hop is permissible in shape; the profile decides
// whether its destination is permissible at all. Only the first check existed, so an upstream
// could redirect a trusted profile to an origin its allowlist excluded.
targetGuard.requireAllowed(follow.target(), metadata);
if (follow.crossOrigin()) {
currentHeaders = headerStripper.stripForCrossOrigin(currentHeaders);
}
// 303 explicitly converts to GET, which means dropping the body as well as changing the
// method. Changing only the method sent the original payload as a GET body to a destination
// the upstream chose. 301/302 keep the method because the platform refuses to guess a rewrite
// the caller did not ask for.
if (response.status() == 303) {
currentMethod = HttpMethod.GET;
currentBody = EmptyBody.instance();
}
currentTarget = PreparedTarget.of(follow.target(), currentTarget.uriTemplate());
response = sender.send(follow.target(), currentMethod, currentHeaders, currentBody);
}
return response;
}
private boolean isRedirect(int status) {
return status == 301 || status == 302 || status == 303 || status == 307 || status == 308;
}
}
@@ -0,0 +1,169 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.BlockingStreamingResponse;
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.resilience.DeadlineGuard;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.springframework.web.client.RestClient;
/**
* Streaming download for the blocking stack (design §23.2, §23.3).
*
* <p>The status is validated before any body byte is handed over, and retries are already
* impossible once the caller reads: the platform hands out an {@link BlockingStreamingResponse}
* rather than a raw stream so the connection is released on every exit path.
*/
public final class BlockingStreamingGateway {
private final ClientRuntimeRegistry runtimes;
public BlockingStreamingGateway(ClientRuntimeRegistry runtimes) {
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
}
public BlockingStreamingResponse download(
ClientProfileName profileName, HttpOperation operation) {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(operation, "operation");
ClientRuntimeLease lease = runtimes.acquire(profileName);
boolean handedOff = false;
try {
if (!(lease.runtime() instanceof BlockingClientRuntime runtime)) {
throw new IllegalStateException(
"profile " + profileName.value() + " is not configured for the blocking api");
}
HttpFailureMetadata metadata =
HttpFailureMetadata.validation(
profileName,
operation.operationName(),
operation.method(),
operation.uriTemplate(),
operation.body().replayability());
PreparedOperation prepared = runtime.targetPolicy().prepare(operation);
ResponseSizeLimiter limiter =
new ResponseSizeLimiter(
prepared.maxResponseWireBytes(), prepared.maxResponseDecodedBytes(), metadata);
// A streaming download is a call, not an exemption. This path used to apply target policy and
// the wire-byte limit and nothing else: no credential was resolved, no admission permit
// taken,
// no deadline checked, no breaker or rate limiter entered. A profile whose upstream was down
// could therefore be hammered indefinitely through its streaming surface while its
// non-streaming surface sat behind an open circuit.
new DeadlineGuard(runtime.support().clock())
.requireTimeRemaining(
runtime
.support()
.deadlineCalculator()
.effective(
operation.deadline(),
runtime.profile().timeout().totalCall(),
runtime.support().clock()),
metadata);
RequestCredentials credentials =
runtime
.credentialProvider()
.resolve(
new CredentialRequest(
runtime.name(),
operation.operationName(),
runtime.profile().authentication(),
prepared.target().uri(),
java.util.Optional.empty(),
runtime.profile().tls().keyMaterialReference(),
false));
Map<String, List<String>> headers = new LinkedHashMap<>(prepared.headers());
credentials.headers().forEach((name, value) -> headers.put(name, List.of(value)));
RestClient.RequestBodySpec spec =
runtime
.restClient()
.method(org.springframework.http.HttpMethod.valueOf(operation.method().name()))
.uri(prepared.target().uri());
headers.forEach((name, values) -> values.forEach(value -> spec.header(name, value)));
// The admission permit is held for the life of the stream, not just the request: a streamed
// body occupies a connection until the caller closes it, and counting only the handshake made
// the limiter blind to exactly the calls that hold resources longest.
AutoCloseable admission = runtime.admissionLimiter().admit(metadata);
BlockingStreamingResponse response;
try {
response =
spec.exchange(
(request, rawResponse) -> {
int status = rawResponse.getStatusCode().value();
Map<String, List<String>> responseHeaders = new LinkedHashMap<>();
rawResponse
.getHeaders()
.forEach(
(name, values) ->
responseHeaders.put(name, List.copyOf(new ArrayList<>(values))));
if (status < 200 || status >= 300) {
// Status is validated before any byte is delivered, so a failed download never
// becomes a half-consumed stream the caller has to reason about.
rawResponse.close();
throw new HttpRemoteErrorException(
"streaming download returned an error status",
metadata
.withStatus(new HttpStatus(status))
.withEvidence(ExecutionEvidence.RESPONSE_RECEIVED));
}
InputStream bounded =
new CountingBoundedInputStream(
rawResponse.getBody(), limiter::recordWireBytes);
return new DefaultBlockingStreamingResponse(
new HttpStatus(status), responseHeaders, bounded, rawResponse::close);
},
false);
} catch (RuntimeException failure) {
closeQuietly(admission);
throw failure;
}
BlockingStreamingResponse wrapped =
new DefaultBlockingStreamingResponse(
response.status(),
response.headers(),
response.body(),
() -> {
response.close();
closeQuietly(admission);
lease.close();
});
handedOff = true;
return wrapped;
} finally {
if (!handedOff) {
lease.close();
}
}
}
/** A permit release must not mask the failure that is already propagating. */
private static void closeQuietly(AutoCloseable closeable) {
try {
closeable.close();
} catch (Exception ignored) {
// The admission limiter's release cannot fail meaningfully; swallowing keeps the original
// failure attributable.
}
}
}
@@ -0,0 +1,304 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAmbiguousExecutionException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
import dev.caskeleton.adapter.outbound.httpclient.auth.UnauthorizedRetryContext;
import dev.caskeleton.adapter.outbound.httpclient.observation.LogicalCallObservation;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptBudgetCalculator;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
import dev.caskeleton.adapter.outbound.httpclient.resilience.BlockingLogicalCall;
import dev.caskeleton.adapter.outbound.httpclient.resilience.BlockingRetryCoordinator;
import dev.caskeleton.adapter.outbound.httpclient.resilience.Deadline;
import dev.caskeleton.adapter.outbound.httpclient.resilience.DeadlineGuard;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryAllowed;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryContext;
import dev.caskeleton.adapter.outbound.httpclient.resilience.Sleeper;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/**
* H2 Generic Exchange over the blocking stack (design §9.3, §7.1).
*
* <p>The logical call is assembled here and then handed to the retry coordinator: lease admission
* target policy deadline credentials attempts. Every one of those is a place a caller could
* otherwise bypass a profile guarantee, which is why none of them are optional.
*/
public final class DefaultGenericHttpGateway implements GenericHttpGateway {
private final ClientRuntimeRegistry runtimes;
private final BlockingAttemptExecutor executor;
public DefaultGenericHttpGateway(
ClientRuntimeRegistry runtimes, BlockingAttemptExecutor executor) {
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
this.executor = Objects.requireNonNull(executor, "attempt executor");
}
@Override
public <T> HttpCallResult<T> exchange(
ClientProfileName profileName, HttpOperation operation, ResponseType<T> responseType) {
return exchange(profileName, operation, responseType, StatusHandlingPolicy.THROW_ON_ERROR);
}
@Override
public <T> HttpCallResult<T> exchange(
ClientProfileName profileName,
HttpOperation operation,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy) {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(operation, "operation");
Objects.requireNonNull(responseType, "response type");
try (ClientRuntimeLease lease = runtimes.acquire(profileName)) {
BlockingClientRuntime runtime = requireBlockingRuntime(lease);
BlockingExecutionSupport support = runtime.support();
HttpFailureMetadata metadata =
HttpFailureMetadata.validation(
profileName,
operation.operationName(),
operation.method(),
operation.uriTemplate(),
operation.body().replayability());
try (AutoCloseable admission = runtime.admissionLimiter().admit(metadata);
AutoCloseable context =
BlockingOperationContext.set(profileName, operation.operationName())) {
PreparedOperation prepared = runtime.targetPolicy().prepare(operation);
Deadline deadline =
support
.deadlineCalculator()
.effective(
operation.deadline(), runtime.profile().timeout().totalCall(), support.clock());
new DeadlineGuard(support.clock()).requireTimeRemaining(deadline, metadata);
LogicalCallObservation observation =
LogicalCallObservation.start(
support.meterRegistry(),
support.tagPolicy(),
profileName,
operation.operationName(),
operation.method().name(),
operation.uriTemplate());
BlockingRetryCoordinator coordinator =
new BlockingRetryCoordinator(
support.eligibilityEngine(),
runtime.newBackoff(),
runtime.retryBudget(),
Sleeper.threadSleep(),
support.clock());
GenericLogicalCall<T> call =
new GenericLogicalCall<>(
runtime,
prepared,
responseType,
statusHandlingPolicy,
deadline,
metadata,
observation);
try {
HttpCallResult<T> result = coordinator.execute(call);
observation.stop(Optional.of(result.status()), result.evidence(), "success");
return result;
} catch (HttpAmbiguousExecutionException ambiguous) {
observation.recordAmbiguous();
observation.stop(Optional.empty(), ambiguous.metadata().evidence(), "ambiguous");
throw ambiguous;
} catch (HttpClientException failure) {
observation.stop(failure.metadata().status(), failure.metadata().evidence(), "failure");
throw failure;
}
} catch (RuntimeException failure) {
throw failure;
} catch (Exception unexpected) {
throw new IllegalStateException("outbound http call could not complete", unexpected);
}
}
}
private BlockingClientRuntime requireBlockingRuntime(ClientRuntimeLease lease) {
if (lease.runtime() instanceof BlockingClientRuntime blocking) {
return blocking;
}
throw new IllegalStateException(
"profile " + lease.runtime().name().value() + " is not configured for the blocking api");
}
/** Bridges one prepared operation to the retry coordinator (design §7.1 steps 8-13). */
private final class GenericLogicalCall<T> implements BlockingLogicalCall<T> {
private final BlockingClientRuntime runtime;
private final PreparedOperation prepared;
private final ResponseType<T> responseType;
private final StatusHandlingPolicy statusHandlingPolicy;
private final Deadline deadline;
private final HttpFailureMetadata metadata;
private final LogicalCallObservation observation;
private final Instant startedAt;
private RequestCredentials credentials;
private int credentialRefreshes;
private GenericLogicalCall(
BlockingClientRuntime runtime,
PreparedOperation prepared,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy,
Deadline deadline,
HttpFailureMetadata metadata,
LogicalCallObservation observation) {
this.runtime = runtime;
this.prepared = prepared;
this.responseType = responseType;
this.statusHandlingPolicy = statusHandlingPolicy;
this.deadline = deadline;
this.metadata = metadata;
this.observation = observation;
this.startedAt = runtime.support().clock().instant();
this.credentials = resolveCredentials(false);
}
@Override
public AttemptOutcome<T> attempt(int attemptNumber) {
if (!runtime.acceptsNewAttempts() && attemptNumber > 1) {
throw new HttpRateLimitRejectedException(
"runtime is draining and refuses new attempts", metadata);
}
// Checked before every attempt, not only before the first. The deadline used to be verified
// once at the start of the logical call, so a retry could begin after the budget had already
// expired and run to its own transport timeout the caller's deadline was a suggestion the
// second attempt onwards ignored. AttemptBudgetCalculator and DeadlineGuard both existed for
// this and neither was on the execution path.
BlockingExecutionSupport attemptSupport = runtime.support();
DeadlineGuard guard = new DeadlineGuard(attemptSupport.clock());
guard.requireTimeRemaining(deadline, metadata.withAttempt(attemptNumber));
guard.requireAttemptBudget(
new AttemptBudgetCalculator(attemptSupport.clock())
.nextAttempt(
deadline, Duration.ZERO, attemptSupport.minimumAttemptBudget(), Duration.ZERO),
metadata.withAttempt(attemptNumber));
return executor.execute(
runtime,
prepared,
responseType,
statusHandlingPolicy,
credentials,
attemptNumber,
startedAt,
metadata.withAttempt(attemptNumber));
}
@Override
public RetryContext context(AttemptOutcome<T> outcome, int attemptNumber) {
BlockingExecutionSupport support = runtime.support();
boolean unauthorized = outcome.status().map(status -> status.value() == 401).orElse(false);
boolean refreshAllowed =
unauthorized
&& support
.unauthorizedRetryPolicy()
.mayRetry(
new UnauthorizedRetryContext(
prepared.operation().idempotency(),
prepared.operation().body().replayability(),
credentialRefreshes,
true));
if (refreshAllowed) {
runtime.credentialProvider().invalidate(credentialRequest(false));
credentials = resolveCredentials(true);
credentialRefreshes++;
}
return new RetryContext(
prepared.operation().idempotency(),
prepared.operation().idempotencyKey(),
prepared.idempotencyKeySent(),
prepared.operation().body().replayability(),
outcome.evidence(),
outcome.failureCategory(),
outcome.status(),
outcome.retryAfter(),
attemptNumber,
runtime.profile().retry().maxAttempts(),
outcome.firstByteDelivered(),
deadline.remaining(support.clock()),
support.minimumAttemptBudget(),
runtime.retryBudget().snapshot(),
support.transientServerErrorStatuses(),
refreshAllowed,
!runtime.acceptsNewAttempts());
}
@Override
public Deadline deadline() {
return deadline;
}
@Override
public HttpCallResult<T> finish(AttemptOutcome<T> outcome, int attemptNumber) {
return outcome
.result()
.map(
value ->
new HttpCallResult<>(
value.status(),
value.headers(),
value.body(),
attemptNumber,
Duration.between(startedAt, runtime.support().clock().instant()),
value.evidence(),
value.remoteProblem()))
.orElseThrow(() -> outcome.failure().orElseThrow());
}
@Override
public HttpClientException ambiguous(AttemptOutcome<T> outcome, int attemptNumber) {
return new HttpAmbiguousExecutionException(
"request was sent but the remote outcome is unknown",
metadata.withAttempt(attemptNumber).withEvidence(ExecutionEvidence.SENT_NO_RESPONSE));
}
@Override
public HttpClientException retryExhausted(int attemptNumber) {
observation.recordRetryExhausted();
return new HttpRateLimitRejectedException(
"retry budget for this upstream is exhausted", metadata.withAttempt(attemptNumber));
}
@Override
public void onRetryGranted(RetryAllowed allowed, int attemptNumber) {
observation.recordRetry(allowed.reason());
}
private RequestCredentials resolveCredentials(boolean forceRefresh) {
return runtime.credentialProvider().resolve(credentialRequest(forceRefresh));
}
private CredentialRequest credentialRequest(boolean forceRefresh) {
return new CredentialRequest(
runtime.name(),
prepared.operation().operationName(),
runtime.profile().authentication(),
prepared.target().uri(),
Optional.empty(),
runtime.profile().tls().keyMaterialReference(),
forceRefresh);
}
}
}
@@ -0,0 +1,206 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpSerializationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ByteArrayResponseType;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ClassResponseType;
import dev.caskeleton.adapter.outbound.httpclient.api.result.EmptyResponseType;
import dev.caskeleton.adapter.outbound.httpclient.api.result.GenericResponseType;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.profile.ResponseLimits;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
/**
* Reads and decodes a response inside the profile's byte budget (design §23.2).
*
* <p>The body is bounded while it is read and then decoded from memory by a Spring message
* converter. Doing the bounding first is what makes a decompression bomb or an unexpectedly huge
* payload a rejected request instead of an out-of-memory error.
*/
public final class RestClientResponseReader {
private final List<HttpMessageConverter<?>> converters;
public RestClientResponseReader() {
this(defaultConverters());
}
public RestClientResponseReader(List<HttpMessageConverter<?>> converters) {
this.converters = List.copyOf(Objects.requireNonNull(converters, "message converters"));
}
public static List<HttpMessageConverter<?>> defaultConverters() {
List<HttpMessageConverter<?>> converters = new ArrayList<>();
converters.add(new ByteArrayHttpMessageConverter());
converters.add(new StringHttpMessageConverter());
converters.add(new FormHttpMessageConverter());
converters.add(new JacksonJsonHttpMessageConverter());
return List.copyOf(converters);
}
/**
* Bounded snapshot of one HTTP response.
*
* <p>A final class rather than a record: the body is an array and must be copied on construction
* and on every access so a caller cannot mutate a buffer the retry path may re-read.
*/
public static final class RawResponse {
private final int status;
private final Map<String, List<String>> headers;
private final byte[] body;
public RawResponse(int status, Map<String, List<String>> headers, byte[] body) {
this.status = status;
this.headers = Map.copyOf(Objects.requireNonNull(headers, "headers"));
this.body = Objects.requireNonNull(body, "body").clone();
}
public int status() {
return status;
}
public Map<String, List<String>> headers() {
return headers;
}
public byte[] body() {
return body.clone();
}
public int bodyLength() {
return body.length;
}
public java.util.Optional<String> firstHeader(String name) {
return headers.entrySet().stream()
.filter(entry -> entry.getKey().equalsIgnoreCase(name))
.flatMap(entry -> entry.getValue().stream())
.findFirst();
}
}
public RawResponse readBounded(
int status, HttpHeaders headers, InputStream body, ResponseSizeLimiter limiter)
throws IOException {
try (InputStream counted = new CountingBoundedInputStream(body, limiter::recordWireBytes)) {
byte[] bytes = counted.readAllBytes();
limiter.recordDecodedBytes(bytes.length);
return new RawResponse(status, toMap(headers), bytes);
}
}
private static Map<String, List<String>> toMap(HttpHeaders headers) {
Map<String, List<String>> copy = new java.util.LinkedHashMap<>();
headers.forEach((name, values) -> copy.put(name, List.copyOf(values)));
return copy;
}
public void requireAllowedContentType(
RawResponse response, ResponseLimits limits, HttpFailureMetadata metadata) {
if (response.bodyLength() == 0) {
return;
}
String contentType = response.firstHeader(HttpHeaders.CONTENT_TYPE).orElse("");
if (contentType.isEmpty() || limits.permits(contentType)) {
return;
}
throw new HttpTargetRejectedException(
"response content type is not permitted by the profile", metadata);
}
@SuppressWarnings("unchecked")
public <T> T decode(
RawResponse response, ResponseType<T> responseType, HttpFailureMetadata metadata) {
if (responseType instanceof EmptyResponseType) {
return null;
}
if (responseType instanceof ByteArrayResponseType) {
return (T) response.body();
}
MediaType contentType =
response
.firstHeader(HttpHeaders.CONTENT_TYPE)
.map(MediaType::parseMediaType)
.orElse(MediaType.APPLICATION_OCTET_STREAM);
Type target = responseType.type();
try {
for (HttpMessageConverter<?> converter : converters) {
if (responseType instanceof ClassResponseType<T> classType
&& converter.canRead(classType.rawType(), contentType)) {
HttpMessageConverter<T> typed = (HttpMessageConverter<T>) converter;
return typed.read(classType.rawType(), inputMessage(response, contentType));
}
if (responseType instanceof GenericResponseType
&& converter instanceof GenericHttpMessageConverter<?> generic
&& generic.canRead(target, null, contentType)) {
GenericHttpMessageConverter<T> typed = (GenericHttpMessageConverter<T>) generic;
return typed.read(target, null, inputMessage(response, contentType));
}
}
} catch (IOException | RuntimeException failure) {
throw new HttpSerializationException("response body could not be decoded", metadata, failure);
}
throw new HttpSerializationException(
"no message converter can read the declared response type", metadata);
}
/**
* Convenience for the typed client, which knows its target as a {@link
* ParameterizedTypeReference}.
*/
@SuppressWarnings("unchecked")
public static <T> ResponseType<T> responseTypeOf(ParameterizedTypeReference<T> reference) {
Type type = reference.getType();
if (type == Void.class || type == void.class) {
return (ResponseType<T>) ResponseType.empty();
}
if (type == byte[].class) {
return (ResponseType<T>) ResponseType.ofBytes();
}
// A non-generic reference must become a ClassResponseType. Wrapping it as a generic one looked
// harmless but sent every plain DTO down the GenericHttpMessageConverter branch, where the
// converters this reader holds decline to read it so a typed client that declared
// `UserResponse` failed with "no message converter can read the declared response type".
if (type instanceof Class<?> rawType) {
return (ResponseType<T>) ResponseType.of(rawType);
}
return new GenericResponseType<>(type);
}
private HttpInputMessage inputMessage(RawResponse response, MediaType contentType) {
HttpHeaders headers = new HttpHeaders();
response.headers().forEach(headers::addAll);
headers.setContentType(contentType);
byte[] body = response.body();
return new HttpInputMessage() {
@Override
public InputStream getBody() {
return new ByteArrayInputStream(body);
}
@Override
public HttpHeaders getHeaders() {
return headers;
}
};
}
}
@@ -0,0 +1,144 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialProviderRegistry;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeFactory;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptResiliencePipeline;
import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ExponentialFullJitterBackoff;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRegistry;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget;
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportProvider;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportCapabilityValidator;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
import java.time.Duration;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
import java.util.random.RandomGenerator;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
/**
* Builds a blocking runtime generation for a profile (design §26.1).
*
* <p>Capability validation runs before any client exists, so a profile whose transport cannot
* honour it fails at startup rather than at the first production request.
*/
public final class RestClientRuntimeFactory implements ClientRuntimeFactory {
private final Map<TransportType, BlockingTransportProvider> providers =
new EnumMap<>(TransportType.class);
private final TransportCapabilityValidator capabilityValidator =
new TransportCapabilityValidator();
private final ResilienceRegistry resilienceRegistry;
private final CredentialProviderRegistry credentialProviders;
private final BlockingExecutionSupport support;
private final TransportLifecycleListener lifecycleListener;
private final RandomGenerator random;
public RestClientRuntimeFactory(
Map<TransportType, BlockingTransportProvider> providers,
ResilienceRegistry resilienceRegistry,
CredentialProviderRegistry credentialProviders,
BlockingExecutionSupport support,
TransportLifecycleListener lifecycleListener,
RandomGenerator random) {
Objects.requireNonNull(providers, "transport providers").forEach(this.providers::put);
this.resilienceRegistry = Objects.requireNonNull(resilienceRegistry, "resilience registry");
this.credentialProviders = Objects.requireNonNull(credentialProviders, "credential providers");
this.support = Objects.requireNonNull(support, "execution support");
this.lifecycleListener = Objects.requireNonNull(lifecycleListener, "lifecycle listener");
this.random = Objects.requireNonNull(random, "random generator");
}
@Override
public ClientRuntime create(ClientProfile profile, RuntimeGeneration generation) {
Objects.requireNonNull(profile, "profile");
BlockingTransportProvider provider = providers.get(profile.transport());
if (provider == null) {
throw new HttpConfigurationException(
"no blocking transport provider is registered for " + profile.transport(),
HttpFailureMetadata.startup(profile.name()));
}
capabilityValidator.validate(profile, provider.capabilities());
ClientHttpRequestFactory requestFactory =
provider.create(profile, generation, lifecycleListener);
RestClient restClient =
RestClient.builder()
.requestFactory(requestFactory)
.baseUrl(profile.baseUrl().toString())
.build();
AttemptResiliencePipeline pipeline = newResiliencePipeline(profile);
RetryBudget retryBudget =
profile
.retry()
.budget()
.map(
name ->
resilienceRegistry.retryBudget(
name, retryCapacity(profile), Duration.ofMinutes(1)))
.orElseGet(RetryBudget::unlimited);
Supplier<BackoffStrategy> backoffFactory =
() ->
new ExponentialFullJitterBackoff(
profile.retry().baseBackoff(),
profile.retry().maxBackoff(),
profile.retry().jitter(),
profile.retry().retryAfter(),
random);
return new BlockingClientRuntime(
profile,
generation,
() -> provider.close(profile, generation),
restClient,
provider.id(),
provider.failureClassifier(),
pipeline,
resilienceRegistry.admission(
profile.name(),
profile.pool().maxPendingAcquires() + profile.pool().maxTotalConnections()),
retryBudget,
backoffFactory,
credentialProviders.require(profile.name(), profile.authentication().type()),
support);
}
/**
* Retry capacity is derived from the pool budget rather than invented: a retry storm is bounded
* by what the upstream can absorb, and the pool is the only declared statement of that.
*/
private long retryCapacity(ClientProfile profile) {
return Math.max(1L, profile.pool().maxTotalConnections() / 10L);
}
/**
* Builds the guard chain with its rejection metrics attached.
*
* <p>The three local back-pressure signals circuit open, rate limited, bulkhead full were
* declared in the metric vocabulary and emitted by nothing, so a saturated client looked exactly
* like a healthy one on a dashboard.
*/
private AttemptResiliencePipeline newResiliencePipeline(ClientProfile profile) {
dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptCircuitBreaker breaker =
resilienceRegistry.circuitBreaker(profile.name());
return new AttemptResiliencePipeline(
breaker,
resilienceRegistry.rateLimiter(profile.name()),
resilienceRegistry.bulkhead(profile.name(), profile.pool().maxTotalConnections()),
() -> HttpFailureMetadata.startup(profile.name()),
dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRejectionRecorder
.micrometer(support.meterRegistry(), profile.name(), breaker::state));
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.adapter.outbound.httpclient.security;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* An operation that has passed target, header, and body-size policy and is ready to execute (design
* §12).
*
* <p>Nothing downstream re-derives a URL, re-adds a header, or re-checks a limit: the executor may
* only use what this record already approved.
*/
public record PreparedOperation(
HttpOperation operation,
PreparedTarget target,
Map<String, List<String>> headers,
long maxRequestBytes,
long maxResponseWireBytes,
long maxResponseDecodedBytes) {
public PreparedOperation {
Objects.requireNonNull(operation, "operation");
Objects.requireNonNull(target, "target");
Objects.requireNonNull(headers, "headers");
headers = Map.copyOf(headers);
}
/**
* Whether the operation's idempotency key is present in the headers that will be sent.
*
* <p>Read by the retry decision instead of {@code operation.idempotencyKey().isPresent()}. The
* distinction is the whole point: a key the caller supplied but the platform never wrote gives
* the upstream nothing to deduplicate against, so a repeat is a duplicate side effect rather than
* a safe retry.
*
* @return {@code true} when a key exists and a header carries its exact value
*/
public boolean idempotencyKeySent() {
return operation
.idempotencyKey()
.map(
key ->
headers.values().stream()
.flatMap(List::stream)
.anyMatch(value -> value.equals(key.value())))
.orElse(false);
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.outbound.httpclient.security;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Removes credentials when a redirect crosses an origin (design §12.4, §22.4).
*
* <p>Forwarding {@code Authorization} to a redirect target is a credential disclosure to whoever
* controls that target, which for a Dynamic Target is by definition not us.
*/
public final class SensitiveHeaderStripper {
private static final Set<String> ALWAYS_STRIPPED =
Set.of("authorization", "proxy-authorization", "cookie", "set-cookie");
/** Conventional API-key header names, stripped even when a profile names a different one. */
private static final Set<String> CONVENTIONAL_API_KEY_HEADERS = Set.of("x-api-key", "api-key");
private final Set<String> additionalSensitiveHeaders;
private SensitiveHeaderStripper(Set<String> additionalSensitiveHeaders) {
this.additionalSensitiveHeaders = additionalSensitiveHeaders;
}
public static SensitiveHeaderStripper standard() {
return new SensitiveHeaderStripper(CONVENTIONAL_API_KEY_HEADERS);
}
/**
* Adds profile-specific credential headers to the conventional set.
*
* <p>It adds rather than replaces, which its name always claimed and its behaviour did not. A
* profile that named a custom API-key header {@code X-Client-Key}, say produced a stripper
* that dropped only that one and forwarded {@code X-Api-Key} across an origin boundary, so
* configuring a custom header made the default headers <em>less</em> protected than leaving it
* alone.
*
* @param headerNames additional credential-bearing header names, case-insensitive
* @return a stripper covering the conventional names plus these
*/
public static SensitiveHeaderStripper withAdditional(Set<String> headerNames) {
Objects.requireNonNull(headerNames, "additional sensitive header names");
Set<String> lower =
java.util.stream.Stream.concat(
CONVENTIONAL_API_KEY_HEADERS.stream(),
headerNames.stream().map(name -> name.toLowerCase(Locale.ROOT)))
.collect(Collectors.toUnmodifiableSet());
return new SensitiveHeaderStripper(lower);
}
public Map<String, List<String>> stripForCrossOrigin(Map<String, List<String>> headers) {
Map<String, List<String>> retained = new LinkedHashMap<>();
headers.forEach(
(name, values) -> {
String lower = name.toLowerCase(Locale.ROOT);
if (!ALWAYS_STRIPPED.contains(lower) && !additionalSensitiveHeaders.contains(lower)) {
retained.put(name, List.copyOf(values));
}
});
return Map.copyOf(retained);
}
}
@@ -0,0 +1,78 @@
package dev.caskeleton.adapter.outbound.httpclient.security;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeFactory;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import java.time.Duration;
import java.util.Objects;
/**
* Rotates certificates and secrets by building a new runtime generation (design §7.2, §21.1).
*
* <p>Rotation is a generation swap rather than an in-place mutation because a connection pool holds
* sockets that were established under the previous identity: replacing the material without
* replacing the pool leaves live connections authenticated by a certificate that is meant to be
* gone.
*/
public final class TlsRuntimeRotationCoordinator {
private final ClientRuntimeRegistry registry;
private final ClientRuntimeFactory runtimeFactory;
private final Duration drainTimeout;
public TlsRuntimeRotationCoordinator(
ClientRuntimeRegistry registry, ClientRuntimeFactory runtimeFactory, Duration drainTimeout) {
this.registry = Objects.requireNonNull(registry, "runtime registry");
this.runtimeFactory = Objects.requireNonNull(runtimeFactory, "runtime factory");
this.drainTimeout = Objects.requireNonNull(drainTimeout, "drain timeout");
}
/**
* Rotates the profiles that actually use the rotated identity.
*
* <p>The identity argument used to be required and then ignored: every registered profile was
* rotated whatever certificate had changed. Rotating a runtime is not free it discards a warm
* pool, forces fresh handshakes, and drains connections mid-flight so a single certificate
* renewal caused a connection storm across every upstream the service talks to, most of which had
* nothing to do with that certificate. It also hid the real failure: if the rotation was wrong,
* every profile degraded at once and nothing pointed at the cause.
*
* <p>A profile participates when its TLS settings name the rotated identity, either as the key
* material reference or as the TLS profile id. A profile with no client certificate cannot be
* affected by a client-certificate rotation at all.
*
* @param identity the certificate identity that changed
* @return the profiles that were rotated, so a caller can log or assert on the blast radius
*/
public java.util.List<ClientProfileName> rotate(ClientCertificateIdentity identity) {
Objects.requireNonNull(identity, "client certificate identity");
java.util.List<ClientProfileName> rotated = new java.util.ArrayList<>();
for (ClientProfileName name : registry.names()) {
if (participatesIn(name, identity)) {
rotateProfile(name);
rotated.add(name);
}
}
return java.util.List.copyOf(rotated);
}
/** Whether this profile's TLS material is the one that rotated. */
private boolean participatesIn(ClientProfileName name, ClientCertificateIdentity identity) {
dev.caskeleton.adapter.outbound.httpclient.profile.TlsSettings tls =
registry.current(name).profile().tls();
if (tls.keyMaterialReference().isEmpty()) {
// No client certificate: a client-certificate rotation cannot reach this profile.
return false;
}
return tls.keyMaterialReference().filter(identity.value()::equals).isPresent()
|| tls.profileId().filter(identity.value()::equals).isPresent();
}
public void rotateProfile(ClientProfileName name) {
ClientRuntime previous = registry.current(name);
ClientRuntime replacement =
runtimeFactory.create(previous.profile(), previous.generation().next());
registry.swap(name, replacement, drainTimeout);
}
}
@@ -0,0 +1,176 @@
package dev.caskeleton.adapter.outbound.httpclient.security;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.IdempotencyKeyRequirement;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/**
* Turns a caller's operation into a {@link PreparedOperation} for a Trusted profile (design §12.1).
*
* <p>An absolute URI is rejected here rather than sanitised: H2 exists to vary method, relative
* path, query, approved headers, and body not the destination. Changing the destination is what
* H3 is for, and H3 has its own policy, credentials, and DNS validation.
*/
public final class TrustedTargetPolicy {
private final ClientProfile profile;
private final UriTemplateExpander expander;
private final BodyLimitPolicy bodyLimitPolicy;
public TrustedTargetPolicy(ClientProfile profile) {
this.profile = Objects.requireNonNull(profile, "profile");
this.expander = new UriTemplateExpander(profile.baseUrl());
this.bodyLimitPolicy = BodyLimitPolicy.maxRequestBytes(profile.request().maxBodyBytes());
}
public ClientProfile profile() {
return profile;
}
/**
* Prepares an operation, deriving its idempotency-key requirement from the operation itself.
*
* <p>Deriving rather than defaulting to {@link IdempotencyKeyRequirement#none()} is deliberate: a
* default of "no key" silently dropped a key the caller had registered, and the request went out
* without the one header that would have let the upstream deduplicate it.
*
* @param operation the caller's operation
* @return the prepared operation, with the key rendered as a header when one exists
*/
public PreparedOperation prepare(HttpOperation operation) {
return prepare(operation, requirementFor(operation));
}
/**
* The requirement an operation implies.
*
* @param operation the operation to inspect
* @return {@code required} under the standard header name when the operation carries a key
*/
public static IdempotencyKeyRequirement requirementFor(HttpOperation operation) {
return operation.idempotencyKey().isPresent()
? IdempotencyKeyRequirement.required("Idempotency-Key")
: IdempotencyKeyRequirement.none();
}
public PreparedOperation prepare(
HttpOperation operation, IdempotencyKeyRequirement idempotencyKeyRequirement) {
Objects.requireNonNull(operation, "operation");
HttpFailureMetadata metadata =
HttpFailureMetadata.validation(
profile.name(),
operation.operationName(),
operation.method(),
operation.uriTemplate(),
operation.body().replayability());
requireRelativeTemplate(operation.uriTemplate(), metadata);
PreparedTarget target = expander.expand(operation.uriTemplate(), operation.uriVariables());
requireAllowedOrigin(target, metadata);
Map<String, List<String>> headers =
HeaderPolicy.forOperation(idempotencyKeyRequirement, false)
.validate(operation.headers(), metadata);
headers = withIdempotencyKey(operation, idempotencyKeyRequirement, headers, metadata);
bodyLimitPolicy.validate(operation.body(), metadata);
return new PreparedOperation(
operation,
target,
headers,
profile.request().maxBodyBytes(),
profile.response().maxWireBytes(),
profile.response().maxDecodedBytes());
}
/**
* Renders the operation's idempotency key as the header the upstream will actually receive.
*
* <p>The platform owns this header. Before, the key was carried on the operation, checked for
* presence by the retry engine, and never written to the wire: the upstream saw no key, could not
* deduplicate, and the platform meanwhile treated a repeat as contractually safe. A duplicated
* payment is the shape of that bug.
*
* <p>A caller-supplied value for the same header is refused rather than merged. Two keys for one
* request is a contradiction, and silently preferring either one would decide on the caller's
* behalf which request the upstream is allowed to deduplicate against.
*/
private Map<String, List<String>> withIdempotencyKey(
HttpOperation operation,
IdempotencyKeyRequirement requirement,
Map<String, List<String>> headers,
HttpFailureMetadata metadata) {
if (operation.idempotencyKey().isEmpty()) {
return headers;
}
if (!requirement.required()) {
throw new HttpTargetRejectedException(
"the operation carries an idempotency key but its descriptor does not register one, so "
+ "the platform has no header to send it in",
metadata);
}
String headerName = requirement.headerName();
boolean callerSupplied =
headers.keySet().stream()
.anyMatch(
name -> name.toLowerCase(Locale.ROOT).equals(headerName.toLowerCase(Locale.ROOT)));
if (callerSupplied) {
throw new HttpTargetRejectedException(
"header " + headerName + " is owned by the platform and must not be supplied by a caller",
metadata);
}
Map<String, List<String>> merged = new LinkedHashMap<>(headers);
merged.put(headerName, List.of(operation.idempotencyKey().orElseThrow().value()));
return Map.copyOf(merged);
}
private void requireRelativeTemplate(String uriTemplate, HttpFailureMetadata metadata) {
if (uriTemplate.isEmpty()) {
throw new HttpTargetRejectedException("uri template must not be empty", metadata);
}
String lower = uriTemplate.toLowerCase(Locale.ROOT);
if (lower.startsWith("//") || lower.contains("://")) {
throw new HttpTargetRejectedException(
"a trusted generic exchange accepts only a profile-relative uri template", metadata);
}
if (!uriTemplate.startsWith("/")) {
throw new HttpTargetRejectedException(
"uri template must start with '/' relative to the profile base url", metadata);
}
}
/**
* Re-applies the profile's origin allowlist to a target the caller did not choose.
*
* <p>Public because a redirect hop needs it. The coordinator used to evaluate a hop against the
* redirect policy alone hop count, cross-origin flag, method rewrite and never against the
* profile's own allowed hosts and ports. An upstream could therefore redirect a trusted profile
* to any origin the redirect policy tolerated, including one the operator had explicitly excluded
* from the allowlist, and the platform would follow it.
*
* @param target the hop target
* @param metadata failure metadata for the rejection
* @throws HttpTargetRejectedException when the host or port is not on the profile's allowlist
*/
public void requireAllowedTarget(java.net.URI target, HttpFailureMetadata metadata) {
requireAllowedOrigin(PreparedTarget.of(target, target.getPath()), metadata);
}
private void requireAllowedOrigin(PreparedTarget target, HttpFailureMetadata metadata) {
if (!profile.allowedHosts().isEmpty() && !profile.allowedHosts().contains(target.host())) {
throw new HttpTargetRejectedException(
"target host is not on the profile allowlist", metadata);
}
if (!profile.allowedPorts().isEmpty() && !profile.allowedPorts().contains(target.port())) {
throw new HttpTargetRejectedException(
"target port is not on the profile allowlist", metadata);
}
}
}
@@ -0,0 +1,115 @@
package dev.caskeleton.adapter.outbound.httpclient.service;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.restclient.GenericHttpGateway;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/**
* Builds validated typed clients over a profile's immutable RestClient (design §26.3).
*
* <p>Interfaces are scanned and validated at creation, and the resulting proxy is cached per
* (profile, interface): building it per call would re-run reflection on every request and lose the
* startup-failure guarantee.
*/
public final class DefaultHttpServiceRegistry implements HttpServiceRegistry {
private final ClientRuntimeRegistry runtimes;
private final GenericHttpGateway gateway;
private final ServiceOperationDescriptorScanner scanner;
private final OperationContextHolder contextHolder;
private final Map<String, Object> clients = new ConcurrentHashMap<>();
public DefaultHttpServiceRegistry(ClientRuntimeRegistry runtimes, GenericHttpGateway gateway) {
this(
runtimes,
gateway,
new ServiceOperationDescriptorScanner(),
OperationContextHolder.instance());
}
public DefaultHttpServiceRegistry(
ClientRuntimeRegistry runtimes,
GenericHttpGateway gateway,
ServiceOperationDescriptorScanner scanner,
OperationContextHolder contextHolder) {
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
this.gateway = Objects.requireNonNull(gateway, "generic http gateway");
this.scanner = Objects.requireNonNull(scanner, "descriptor scanner");
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
}
@Override
public <T> T client(Class<T> serviceType) {
return client(scanner.profileOf(serviceType), serviceType);
}
@Override
public <T> T client(ClientProfileName profileName, Class<T> serviceType) {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(serviceType, "service type");
String key = profileName.value() + '|' + serviceType.getName();
return serviceType.cast(
clients.computeIfAbsent(key, ignored -> build(profileName, serviceType)));
}
private <T> T build(ClientProfileName profileName, Class<T> serviceType) {
List<ServiceOperationDescriptor> descriptors = scanner.scan(serviceType);
ClientProfileName declared = scanner.profileOf(serviceType);
if (!declared.equals(profileName)) {
throw new HttpConfigurationException(
"interface "
+ serviceType.getName()
+ " declares profile "
+ declared.value()
+ " but was requested for "
+ profileName.value(),
HttpFailureMetadata.startup(profileName));
}
if (descriptors.stream().anyMatch(ServiceOperationDescriptor::reactive)) {
throw new HttpConfigurationException(
"interface " + serviceType.getName() + " is reactive; use the reactive registry",
HttpFailureMetadata.startup(profileName));
}
try (ClientRuntimeLease lease = runtimes.acquire(profileName)) {
if (!(lease.runtime() instanceof BlockingClientRuntime)) {
throw new HttpConfigurationException(
"profile " + profileName.value() + " is not configured for the blocking api",
HttpFailureMetadata.startup(profileName));
}
}
// Deliberately not RestClientAdapter over the profile's RestClient. That adapter reaches the
// network directly, so a typed call bypassed target policy, credentials, admission, deadline,
// resilience, byte limits, stable error mapping and observation every guarantee the profile
// exists to provide. Routing through the gateway makes the typed surface the same code path as
// the generic one rather than a parallel one that happens to look similar.
T springProxy =
HttpServiceProxyFactory.builderFor(
new KernelHttpExchangeAdapter(gateway, profileName, contextHolder))
.build()
.createClient(serviceType);
Map<Method, ServiceOperationDescriptor> byMethod = new LinkedHashMap<>();
descriptors.forEach(descriptor -> byMethod.put(descriptor.method(), descriptor));
InvocationHandler handler =
new BlockingServiceInvocationHandler(springProxy, profileName, byMethod, contextHolder);
return serviceType.cast(
Proxy.newProxyInstance(
serviceType.getClassLoader(), new Class<?>[] {serviceType}, handler));
}
}
@@ -0,0 +1,108 @@
package dev.caskeleton.adapter.outbound.httpclient.service;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveHttpGateway;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/**
* Builds validated reactive typed clients over a profile's immutable WebClient (design §26.3).
*
* <p>An interface that is entirely blocking is rejected here for the same reason its mirror image
* is rejected by the blocking registry: one interface, one execution model.
*/
public final class DefaultReactiveHttpServiceRegistry implements ReactiveHttpServiceRegistry {
/**
* Only reached by a caller that blocks on a reactive typed method, which the platform forbids.
*/
private static final Duration BLOCK_TIMEOUT = Duration.ofSeconds(30);
private final ClientRuntimeRegistry runtimes;
private final ReactiveHttpGateway gateway;
private final ServiceOperationDescriptorScanner scanner;
private final OperationContextHolder contextHolder;
private final Map<String, Object> clients = new ConcurrentHashMap<>();
public DefaultReactiveHttpServiceRegistry(
ClientRuntimeRegistry runtimes, ReactiveHttpGateway gateway) {
this(
runtimes,
gateway,
new ServiceOperationDescriptorScanner(),
OperationContextHolder.instance());
}
public DefaultReactiveHttpServiceRegistry(
ClientRuntimeRegistry runtimes,
ReactiveHttpGateway gateway,
ServiceOperationDescriptorScanner scanner,
OperationContextHolder contextHolder) {
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
this.gateway = Objects.requireNonNull(gateway, "reactive http gateway");
this.scanner = Objects.requireNonNull(scanner, "descriptor scanner");
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
}
@Override
public <T> T client(Class<T> serviceType) {
return client(scanner.profileOf(serviceType), serviceType);
}
@Override
public <T> T client(ClientProfileName profileName, Class<T> serviceType) {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(serviceType, "service type");
String key = profileName.value() + '|' + serviceType.getName();
return serviceType.cast(
clients.computeIfAbsent(key, ignored -> build(profileName, serviceType)));
}
private <T> T build(ClientProfileName profileName, Class<T> serviceType) {
List<ServiceOperationDescriptor> descriptors = scanner.scan(serviceType);
if (descriptors.stream().anyMatch(descriptor -> !descriptor.reactive())) {
throw new HttpConfigurationException(
"interface " + serviceType.getName() + " is blocking; use the blocking registry",
HttpFailureMetadata.startup(profileName));
}
try (ClientRuntimeLease lease = runtimes.acquire(profileName)) {
if (!(lease.runtime() instanceof ReactiveClientRuntime)) {
throw new HttpConfigurationException(
"profile " + profileName.value() + " is not configured for the reactive api",
HttpFailureMetadata.startup(profileName));
}
}
// Not WebClientAdapter over the raw WebClient: that reached the network with no target policy,
// credential, admission, deadline, resilience, byte limit, stable error mapping or observation.
T springProxy =
HttpServiceProxyFactory.builderFor(
new ReactiveKernelHttpExchangeAdapter(
gateway, profileName, contextHolder, BLOCK_TIMEOUT))
.build()
.createClient(serviceType);
Map<Method, ServiceOperationDescriptor> byMethod = new LinkedHashMap<>();
descriptors.forEach(descriptor -> byMethod.put(descriptor.method(), descriptor));
InvocationHandler handler =
new ReactiveServiceInvocationHandler(springProxy, profileName, byMethod, contextHolder);
return serviceType.cast(
Proxy.newProxyInstance(
serviceType.getClassLoader(), new Class<?>[] {serviceType}, handler));
}
}
@@ -0,0 +1,211 @@
package dev.caskeleton.adapter.outbound.httpclient.service;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey;
import dev.caskeleton.adapter.outbound.httpclient.api.OperationName;
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
import dev.caskeleton.adapter.outbound.httpclient.api.body.ObjectBody;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.restclient.GenericHttpGateway;
import dev.caskeleton.adapter.outbound.httpclient.restclient.RestClientResponseReader;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.service.invoker.HttpExchangeAdapter;
import org.springframework.web.service.invoker.HttpRequestValues;
/**
* Runs every typed-client call through the platform kernel instead of a raw Spring client.
*
* <p>The typed registries used to hand {@code RestClientAdapter.create(runtime.restClient())} to
* Spring's proxy factory, which meant an {@code @HttpExchange} method reached the network through
* the profile's {@code RestClient} and nothing else. It passed no trusted-target policy, resolved
* no credential, took no admission permit, respected no total deadline, entered no circuit breaker,
* rate limiter or bulkhead, produced no evidence-based retry, applied no request or response byte
* limit, mapped no stable exception, and recorded no logical or attempt observation. The proxy did
* publish an operation descriptor into a thread local, but no code on the execution path read it
* so the descriptor described a call the platform was not making.
*
* <p>This adapter is the seam that closes that. Spring still owns argument binding and response
* decoding, which is where its annotation model earns its place; the exchange itself is translated
* into an {@link HttpOperation} and handed to {@link GenericHttpGateway}, the same entry the
* generic H2 surface uses. Everything listed above therefore applies to a typed client because it
* is the same code path, not because it was reimplemented alongside.
*
* <p>The descriptor comes from {@link OperationContextHolder}, bound by the invocation handler for
* the duration of the call. A call with no binding is refused rather than executed with invented
* defaults: a typed method whose descriptor the scanner never produced has no declared idempotency,
* and guessing one is exactly the decision this platform exists to stop callers making by accident.
*/
public final class KernelHttpExchangeAdapter implements HttpExchangeAdapter {
private final GenericHttpGateway gateway;
private final ClientProfileName profileName;
private final OperationContextHolder contextHolder;
public KernelHttpExchangeAdapter(
GenericHttpGateway gateway,
ClientProfileName profileName,
OperationContextHolder contextHolder) {
this.gateway = Objects.requireNonNull(gateway, "generic http gateway");
this.profileName = Objects.requireNonNull(profileName, "profile name");
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
}
/**
* Request attributes are not supported.
*
* <p>They are a Spring-side side channel into the underlying client, and the platform's whole
* position is that the underlying client is not reachable from a caller.
*/
@Override
public boolean supportsRequestAttributes() {
return false;
}
@Override
public void exchange(HttpRequestValues values) {
execute(values, ResponseType.empty());
}
@Override
public HttpHeaders exchangeForHeaders(HttpRequestValues values) {
return headersOf(execute(values, ResponseType.empty()));
}
@Override
public <T> T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
return execute(values, responseTypeOf(bodyType)).body();
}
@Override
public ResponseEntity<Void> exchangeForBodilessEntity(HttpRequestValues values) {
HttpCallResult<Void> result = execute(values, ResponseType.empty());
return ResponseEntity.status(statusOf(result)).headers(headersOf(result)).build();
}
@Override
public <T> ResponseEntity<T> exchangeForEntity(
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
HttpCallResult<T> result = execute(values, responseTypeOf(bodyType));
return ResponseEntity.status(statusOf(result)).headers(headersOf(result)).body(result.body());
}
private <T> HttpCallResult<T> execute(HttpRequestValues values, ResponseType<T> responseType) {
ServiceOperationDescriptor descriptor = requireDescriptor();
return gateway.exchange(profileName, operationOf(values, descriptor), responseType);
}
private ServiceOperationDescriptor requireDescriptor() {
return contextHolder
.current()
.filter(binding -> binding.clientName().equals(profileName))
.map(OperationContextHolder.Binding::descriptor)
.orElseThrow(
() ->
new HttpConfigurationException(
"a typed client call for profile "
+ profileName.value()
+ " reached the platform without a registered operation descriptor",
HttpFailureMetadata.startup(profileName)));
}
/**
* Translates Spring's request values into a platform operation.
*
* <p>The URI template is carried through unexpanded. Spring hands over both the template and its
* variables, and keeping them separate is what lets observability tags stay low-cardinality and
* lets the trusted-target policy expand the path itself against the profile's base URL.
*/
private HttpOperation operationOf(
HttpRequestValues values, ServiceOperationDescriptor descriptor) {
String uriTemplate = values.getUriTemplate();
if (uriTemplate == null) {
// A pre-expanded URI would let a typed method choose its own destination, which is what H3
// and its separate policy exist for.
throw new HttpConfigurationException(
"typed operation "
+ descriptor.operationName().value()
+ " must declare a profile-relative uri template rather than an absolute url",
HttpFailureMetadata.startup(profileName));
}
org.springframework.http.HttpMethod method = values.getHttpMethod();
if (method == null) {
throw new HttpConfigurationException(
"typed operation " + descriptor.operationName().value() + " declares no http method",
HttpFailureMetadata.startup(profileName));
}
Map<String, List<String>> headers = new LinkedHashMap<>();
Optional<IdempotencyKey> idempotencyKey = Optional.empty();
String keyHeader = descriptor.idempotencyKeyRequirement().headerName();
// headerSet() rather than entrySet(): Spring 7's HttpHeaders is no longer a MultiValueMap.
for (Map.Entry<String, List<String>> header : values.getHeaders().headerSet()) {
if (descriptor.idempotencyKeyRequirement().required()
&& header.getKey().equalsIgnoreCase(keyHeader)) {
// Lifted out of the headers and onto the operation, so the trusted-target policy renders it
// and the retry decision can see that it was actually sent.
idempotencyKey = Optional.of(new IdempotencyKey(header.getValue().getFirst()));
continue;
}
headers.put(header.getKey(), List.copyOf(new ArrayList<>(header.getValue())));
}
if (descriptor.idempotencyKeyRequirement().required() && idempotencyKey.isEmpty()) {
throw new HttpConfigurationException(
"typed operation "
+ descriptor.operationName().value()
+ " requires an idempotency key but the call supplied no "
+ keyHeader
+ " header",
HttpFailureMetadata.startup(profileName));
}
return new HttpOperation(
descriptor.operationName(),
HttpMethod.valueOf(method.name()),
uriTemplate,
Map.copyOf(values.getUriVariables()),
Map.copyOf(headers),
bodyOf(values),
descriptor.idempotency(),
idempotencyKey,
Optional.empty());
}
private BodySource bodyOf(HttpRequestValues values) {
Object body = values.getBodyValue();
return body == null ? EmptyBody.instance() : ObjectBody.json(body);
}
private <T> ResponseType<T> responseTypeOf(ParameterizedTypeReference<T> bodyType) {
return RestClientResponseReader.responseTypeOf(bodyType);
}
private HttpStatusCode statusOf(HttpCallResult<?> result) {
return HttpStatusCode.valueOf(result.status().value());
}
private HttpHeaders headersOf(HttpCallResult<?> result) {
HttpHeaders headers = new HttpHeaders();
result.headers().forEach((name, valueList) -> valueList.forEach(v -> headers.add(name, v)));
return headers;
}
/** The operation name a call is executing, for tests and diagnostics. */
OperationName currentOperation() {
return requireDescriptor().operationName();
}
}
@@ -0,0 +1,272 @@
package dev.caskeleton.adapter.outbound.httpclient.service;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey;
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
import dev.caskeleton.adapter.outbound.httpclient.api.body.ObjectBody;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.restclient.RestClientResponseReader;
import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveHttpGateway;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.service.invoker.HttpRequestValues;
import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* The reactive twin of {@link KernelHttpExchangeAdapter}.
*
* <p>The reactive typed registry had the same defect as the blocking one, with the same
* consequence: {@code WebClientAdapter.create(runtime.webClient())} handed Spring the profile's raw
* {@code WebClient}, so a reactive typed call reached the network with none of the platform's
* guarantees. It also put a descriptor into the Reactor context that no code on the execution path
* read.
*
* <p>Routing through {@link ReactiveHttpGateway} makes the reactive typed surface the same
* execution path as the reactive generic one. The subscription is where the work happens, so the
* descriptor is captured at assembly time the thread that assembles a {@code Mono} is not
* necessarily the thread that subscribes to it, and reading a thread local at subscription would
* find nothing.
*/
public final class ReactiveKernelHttpExchangeAdapter implements ReactorHttpExchangeAdapter {
private final ReactiveHttpGateway gateway;
private final ClientProfileName profileName;
private final OperationContextHolder contextHolder;
private final Duration blockTimeout;
public ReactiveKernelHttpExchangeAdapter(
ReactiveHttpGateway gateway,
ClientProfileName profileName,
OperationContextHolder contextHolder,
Duration blockTimeout) {
this.gateway = Objects.requireNonNull(gateway, "reactive http gateway");
this.profileName = Objects.requireNonNull(profileName, "profile name");
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
this.blockTimeout = Objects.requireNonNull(blockTimeout, "block timeout");
}
@Override
public boolean supportsRequestAttributes() {
return false;
}
@Override
public ReactiveAdapterRegistry getReactiveAdapterRegistry() {
return ReactiveAdapterRegistry.getSharedInstance();
}
@Override
public Duration getBlockTimeout() {
return blockTimeout;
}
@Override
public Mono<Void> exchangeForMono(HttpRequestValues values) {
return execute(values, ResponseType.empty()).then();
}
@Override
public Mono<HttpHeaders> exchangeForHeadersMono(HttpRequestValues values) {
return execute(values, ResponseType.empty()).map(this::headersOf);
}
@Override
public <T> Mono<T> exchangeForBodyMono(
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
return execute(values, RestClientResponseReader.responseTypeOf(bodyType))
.mapNotNull(HttpCallResult::body);
}
/**
* A {@code Flux} return type is served by decoding the whole body and then emitting its elements.
*
* <p>It is deliberately not a streaming subscription. A streamed response cannot be bounded by
* the profile's decoded-byte limit or retried on evidence, and a typed method that looks like an
* ordinary call must not quietly acquire different safety properties because its return type is a
* {@code Flux}. Genuine streaming has its own gateway, with its own contract.
*/
@Override
public <T> Flux<T> exchangeForBodyFlux(
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
return execute(values, RestClientResponseReader.<T>responseTypeOf(bodyType))
.flatMapMany(result -> elementsOf(result.body()));
}
@Override
public Mono<ResponseEntity<Void>> exchangeForBodilessEntityMono(HttpRequestValues values) {
return execute(values, ResponseType.empty())
.map(result -> ResponseEntity.status(statusOf(result)).headers(headersOf(result)).build());
}
@Override
public <T> Mono<ResponseEntity<T>> exchangeForEntityMono(
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
return execute(values, RestClientResponseReader.responseTypeOf(bodyType))
.map(
result ->
ResponseEntity.status(statusOf(result))
.headers(headersOf(result))
.body(result.body()));
}
@Override
public <T> Mono<ResponseEntity<Flux<T>>> exchangeForEntityFlux(
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
return execute(values, RestClientResponseReader.<T>responseTypeOf(bodyType))
.map(
result ->
ResponseEntity.status(statusOf(result))
.headers(headersOf(result))
.body(elementsOf(result.body())));
}
/** Blocking-surface members of the interface are unreachable for a reactive typed client. */
@Override
public void exchange(HttpRequestValues values) {
exchangeForMono(values).block(blockTimeout);
}
@Override
public HttpHeaders exchangeForHeaders(HttpRequestValues values) {
return exchangeForHeadersMono(values).block(blockTimeout);
}
@Override
public <T> T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
return exchangeForBodyMono(values, bodyType).block(blockTimeout);
}
@Override
public ResponseEntity<Void> exchangeForBodilessEntity(HttpRequestValues values) {
return exchangeForBodilessEntityMono(values).block(blockTimeout);
}
@Override
public <T> ResponseEntity<T> exchangeForEntity(
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
return exchangeForEntityMono(values, bodyType).block(blockTimeout);
}
/**
* Resolves the descriptor now and defers the call.
*
* <p>The descriptor lookup is eager on purpose: it reads a thread local the invocation handler
* bound around the assembly of this {@code Mono}, and by the time anything subscribes that
* binding is gone.
*/
private <T> Mono<HttpCallResult<T>> execute(
HttpRequestValues values, ResponseType<T> responseType) {
ServiceOperationDescriptor descriptor = requireDescriptor();
HttpOperation operation = operationOf(values, descriptor);
return Mono.defer(() -> gateway.exchange(profileName, operation, responseType));
}
private ServiceOperationDescriptor requireDescriptor() {
return contextHolder
.current()
.filter(binding -> binding.clientName().equals(profileName))
.map(OperationContextHolder.Binding::descriptor)
.orElseThrow(
() ->
new HttpConfigurationException(
"a reactive typed client call for profile "
+ profileName.value()
+ " reached the platform without a registered operation descriptor",
HttpFailureMetadata.startup(profileName)));
}
private HttpOperation operationOf(
HttpRequestValues values, ServiceOperationDescriptor descriptor) {
String uriTemplate = values.getUriTemplate();
if (uriTemplate == null) {
throw new HttpConfigurationException(
"typed operation "
+ descriptor.operationName().value()
+ " must declare a profile-relative uri template rather than an absolute url",
HttpFailureMetadata.startup(profileName));
}
org.springframework.http.HttpMethod method = values.getHttpMethod();
if (method == null) {
throw new HttpConfigurationException(
"typed operation " + descriptor.operationName().value() + " declares no http method",
HttpFailureMetadata.startup(profileName));
}
Map<String, List<String>> headers = new LinkedHashMap<>();
Optional<IdempotencyKey> idempotencyKey = Optional.empty();
String keyHeader = descriptor.idempotencyKeyRequirement().headerName();
for (Map.Entry<String, List<String>> header : values.getHeaders().headerSet()) {
if (descriptor.idempotencyKeyRequirement().required()
&& header.getKey().equalsIgnoreCase(keyHeader)) {
idempotencyKey = Optional.of(new IdempotencyKey(header.getValue().getFirst()));
continue;
}
headers.put(header.getKey(), List.copyOf(new ArrayList<>(header.getValue())));
}
if (descriptor.idempotencyKeyRequirement().required() && idempotencyKey.isEmpty()) {
throw new HttpConfigurationException(
"typed operation "
+ descriptor.operationName().value()
+ " requires an idempotency key but the call supplied no "
+ keyHeader
+ " header",
HttpFailureMetadata.startup(profileName));
}
return new HttpOperation(
descriptor.operationName(),
HttpMethod.valueOf(method.name()),
uriTemplate,
Map.copyOf(values.getUriVariables()),
Map.copyOf(headers),
bodyOf(values),
descriptor.idempotency(),
idempotencyKey,
Optional.empty());
}
private BodySource bodyOf(HttpRequestValues values) {
Object body = values.getBodyValue();
return body == null ? EmptyBody.instance() : ObjectBody.json(body);
}
@SuppressWarnings("unchecked")
private <T> Flux<T> elementsOf(Object body) {
if (body == null) {
return Flux.empty();
}
if (body instanceof Collection<?> elements) {
return Flux.fromIterable((Collection<T>) elements);
}
return Flux.just((T) body);
}
private HttpStatusCode statusOf(HttpCallResult<?> result) {
return HttpStatusCode.valueOf(result.status().value());
}
private HttpHeaders headersOf(HttpCallResult<?> result) {
HttpHeaders headers = new HttpHeaders();
result.headers().forEach((name, valueList) -> valueList.forEach(v -> headers.add(name, v)));
return headers;
}
}
@@ -0,0 +1,79 @@
package dev.caskeleton.adapter.outbound.httpclient.service;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.Objects;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* Attaches the operation descriptor to the Reactor Context of the returned publisher (design §26.3
* step 7).
*
* <p>A non-reactive return value is rejected rather than adapted: adapting it would mean blocking
* somewhere, and design §18.2 forbids that on this path.
*/
public final class ReactiveServiceInvocationHandler implements InvocationHandler {
private final Object delegate;
private final ClientProfileName profileName;
private final Map<Method, ServiceOperationDescriptor> descriptors;
private final OperationContextHolder contextHolder;
public ReactiveServiceInvocationHandler(
Object delegate,
ClientProfileName profileName,
Map<Method, ServiceOperationDescriptor> descriptors,
OperationContextHolder contextHolder) {
this.delegate = Objects.requireNonNull(delegate, "delegate proxy");
this.profileName = Objects.requireNonNull(profileName, "profile name");
this.descriptors = Map.copyOf(Objects.requireNonNull(descriptors, "descriptors"));
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
}
/**
* Binds the descriptor for the duration of assembly, then attaches it to the returned publisher.
*
* <p>Two bindings for two readers. The Reactor context reaches operators that run at subscription
* time; the thread-local reaches the exchange adapter, which translates the call into a platform
* operation while this method is still on the stack. Only the second one existed as a consumer
* before and nothing read it, because the raw {@code WebClient} was doing the work.
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
ServiceOperationDescriptor descriptor = descriptors.get(method);
Object result;
if (descriptor == null) {
result = invokeDelegate(method, args);
} else {
try (AutoCloseable binding = contextHolder.bind(profileName, descriptor)) {
result = invokeDelegate(method, args);
}
}
if (descriptor == null) {
return result;
}
if (result instanceof Mono<?> mono) {
return mono.contextWrite(context -> context.put(ReactiveOperationContext.KEY, descriptor));
}
if (result instanceof Flux<?> flux) {
return flux.contextWrite(context -> context.put(ReactiveOperationContext.KEY, descriptor));
}
throw new HttpConfigurationException(
"reactive service method must return Mono or Flux",
HttpFailureMetadata.startup(profileName));
}
private Object invokeDelegate(Method method, Object[] args) throws Throwable {
try {
return method.invoke(delegate, args);
} catch (InvocationTargetException invocationFailure) {
throw invocationFailure.getCause();
}
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.httpclient.transport;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import org.springframework.http.client.ClientHttpRequestFactory;
/**
* Blocking transport SPI (design §13.1).
*
* <p>The provider returns a Spring {@link ClientHttpRequestFactory} and never the native engine
* client: design D-04 and §9.5 make native access an internal concern of this package.
*
* <p>Resources belong to a <em>generation</em>, not to a profile. Keying them by profile name meant
* a rotation overwrote the map entry with the new generation's client, and the old generation's
* closer which runs after its drain completes then closed the <em>replacement</em> while the
* connections it was supposed to release stayed open. Every rotation leaked one pool and broke the
* live one.
*/
public interface BlockingTransportProvider {
TransportId id();
BlockingTransportCapabilities capabilities();
ClientHttpRequestFactory create(
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener);
TransportFailureClassifier failureClassifier();
/** Releases the engine resources created for exactly this profile generation. */
void close(ClientProfile profile, RuntimeGeneration generation);
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.httpclient.transport;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import org.springframework.http.client.reactive.ClientHttpConnector;
/**
* Reactive transport SPI (design §13.2).
*
* <p>Returns a Spring {@link ClientHttpConnector}; the native reactive client stays internal.
*
* <p>Resources are owned per generation, for the reason spelled out on {@link
* BlockingTransportProvider}: a profile-keyed map lets a rotation's closer release the generation
* that replaced it.
*/
public interface ReactiveTransportProvider {
TransportId id();
ReactiveTransportCapabilities capabilities();
ClientHttpConnector create(
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener);
TransportFailureClassifier failureClassifier();
void close(ClientProfile profile, RuntimeGeneration generation);
}
@@ -0,0 +1,90 @@
package dev.caskeleton.adapter.outbound.httpclient.transport;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol;
import java.util.ArrayList;
import java.util.List;
/**
* Startup guard that fails when a transport is weaker than the profile it was selected for (design
* §13.3, Task 8).
*
* <p>Messages name profile settings and capability names only never a URL, address, or secret.
*/
public final class TransportCapabilityValidator {
public void validate(ClientProfile profile, BlockingTransportCapabilities capabilities) {
List<String> missing = new ArrayList<>();
collectProtocolGaps(profile, capabilities.protocols(), missing);
if (profile.pool().requiresRoutePool() && !capabilities.routeScopedPool()) {
missing.add("route pool");
}
if (profile.pool().requiresBoundedPendingQueue()
&& !capabilities.boundedPendingAcquireQueue()) {
missing.add("bounded pending acquire queue");
}
if (profile.proxy().enabled() && !capabilities.proxySupport()) {
missing.add("proxy");
}
if (profile.tls().mutualTls() && !capabilities.mutualTls()) {
missing.add("mutual TLS");
}
if (profile.mode() == ClientMode.DYNAMIC && !capabilities.dynamicTargetStable()) {
missing.add("validated DNS pinning for dynamic targets");
}
if (profile.mode() == ClientMode.DYNAMIC && !capabilities.validatedDnsPinning()) {
// `validatedDnsPinning` was declared on every capability record and read by nothing. It is
// the capability that decides whether the SSRF address validation survives to the socket, so
// a transport that does not have it cannot serve a dynamic target no matter what its
// `dynamicTargetStable` flag says the two were being conflated.
missing.add("call-scoped validated DNS pinning");
}
reject(profile, missing);
}
public void validate(ClientProfile profile, ReactiveTransportCapabilities capabilities) {
List<String> missing = new ArrayList<>();
collectProtocolGaps(profile, capabilities.protocols(), missing);
if (profile.pool().requiresRoutePool() && !capabilities.routeScopedPool()) {
missing.add("route pool");
}
if (profile.pool().requiresBoundedPendingQueue()
&& !capabilities.boundedPendingAcquireQueue()) {
missing.add("bounded pending acquire queue");
}
if (profile.proxy().enabled() && !capabilities.proxySupport()) {
missing.add("proxy");
}
if (profile.tls().mutualTls() && !capabilities.mutualTls()) {
missing.add("mutual TLS");
}
if (profile.mode() == ClientMode.DYNAMIC && !capabilities.dynamicTargetStable()) {
missing.add("validated DNS pinning for dynamic targets");
}
reject(profile, missing);
}
private void collectProtocolGaps(
ClientProfile profile, java.util.Set<HttpProtocol> supported, List<String> missing) {
for (HttpProtocol protocol : profile.protocols()) {
if (!supported.contains(protocol)) {
missing.add(protocol.name());
}
}
}
private void reject(ClientProfile profile, List<String> missing) {
if (missing.isEmpty()) {
return;
}
throw new HttpConfigurationException(
"transport capability is weaker than profile "
+ profile.name().value()
+ " requires: "
+ String.join(", ", missing),
HttpFailureMetadata.startup(profile.name()));
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.httpclient.transport;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import java.util.Objects;
/**
* Identifies the engine resources belonging to one profile generation.
*
* <p>Transport providers used to key their pools and clients by profile name alone. A rotation
* creates generation N+1 while generation N is still draining, so the new entry overwrote the old
* one; when N's drain finished and its closer ran, it looked up the profile name and closed N+1
* the generation that was serving traffic while N's sockets stayed open. Each rotation therefore
* leaked a pool and broke the live client, which is the opposite of what draining is for.
*
* @param profileName the profile the resources serve
* @param generation the generation that owns them
*/
public record TransportResourceKey(ClientProfileName profileName, RuntimeGeneration generation) {
public TransportResourceKey {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(generation, "runtime generation");
}
}
@@ -0,0 +1,330 @@
package dev.caskeleton.adapter.outbound.httpclient.webclient;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAmbiguousExecutionException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
import dev.caskeleton.adapter.outbound.httpclient.observation.LogicalCallObservation;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
import dev.caskeleton.adapter.outbound.httpclient.resilience.Deadline;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ReactiveLogicalCall;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ReactiveRetryCoordinator;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryAllowed;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryContext;
import dev.caskeleton.adapter.outbound.httpclient.restclient.StatusHandlingPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import reactor.core.publisher.Mono;
/**
* Reactive H2 gateway (design §9.3, §26.2).
*
* <p>Runtime leases are acquired and released with {@code Mono.usingWhen} so a generation cannot be
* closed underneath an in-flight subscription, and operation metadata travels in the Reactor
* Context rather than a ThreadLocal which would be wrong the moment the pipeline switches
* threads.
*/
public final class DefaultReactiveHttpGateway implements ReactiveHttpGateway {
private final ClientRuntimeRegistry runtimes;
private final ReactiveAttemptExecutor executor;
public DefaultReactiveHttpGateway(
ClientRuntimeRegistry runtimes, ReactiveAttemptExecutor executor) {
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
this.executor = Objects.requireNonNull(executor, "reactive attempt executor");
}
@Override
public <T> Mono<HttpCallResult<T>> exchange(
ClientProfileName profileName, HttpOperation operation, ResponseType<T> responseType) {
return exchange(
profileName,
operation,
Optional.empty(),
responseType,
StatusHandlingPolicy.THROW_ON_ERROR);
}
public <T> Mono<HttpCallResult<T>> exchange(
ClientProfileName profileName,
HttpOperation operation,
Optional<ReactiveBodySource> reactiveBody,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy) {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(operation, "operation");
Objects.requireNonNull(responseType, "response type");
return Mono.usingWhen(
Mono.fromSupplier(() -> runtimes.acquire(profileName)),
lease -> execute(lease, operation, reactiveBody, responseType, statusHandlingPolicy),
lease -> Mono.fromRunnable(lease::close),
(lease, failure) -> Mono.fromRunnable(lease::close),
lease -> Mono.fromRunnable(lease::close));
}
private <T> Mono<HttpCallResult<T>> execute(
ClientRuntimeLease lease,
HttpOperation operation,
Optional<ReactiveBodySource> reactiveBody,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy) {
if (!(lease.runtime() instanceof ReactiveClientRuntime runtime)) {
return Mono.error(
new IllegalStateException(
"profile "
+ lease.runtime().name().value()
+ " is not configured for the reactive api"));
}
HttpFailureMetadata metadata =
HttpFailureMetadata.validation(
runtime.name(),
operation.operationName(),
operation.method(),
operation.uriTemplate(),
operation.body().replayability());
return Mono.usingWhen(
Mono.fromSupplier(() -> runtime.admissionLimiter().admit(metadata)),
admission ->
runCall(runtime, operation, reactiveBody, responseType, statusHandlingPolicy, metadata),
admission -> Mono.fromRunnable(() -> closeQuietly(admission)),
(admission, failure) -> Mono.fromRunnable(() -> closeQuietly(admission)),
admission -> Mono.fromRunnable(() -> closeQuietly(admission)));
}
private <T> Mono<HttpCallResult<T>> runCall(
ReactiveClientRuntime runtime,
HttpOperation operation,
Optional<ReactiveBodySource> reactiveBody,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy,
HttpFailureMetadata metadata) {
PreparedOperation prepared = runtime.targetPolicy().prepare(operation);
Deadline deadline =
runtime
.support()
.deadlineCalculator()
.effective(
operation.deadline(),
runtime.profile().timeout().totalCall(),
runtime.support().clock());
LogicalCallObservation observation =
LogicalCallObservation.start(
runtime.support().meterRegistry(),
runtime.support().tagPolicy(),
runtime.name(),
operation.operationName(),
operation.method().name(),
operation.uriTemplate());
ReactiveRetryCoordinator coordinator =
new ReactiveRetryCoordinator(
runtime.support().eligibilityEngine(),
runtime.newBackoff(),
runtime.retryBudget(),
runtime.support().clock());
return runtime
.credentialProvider()
.resolve(credentialRequest(runtime, prepared, false))
.defaultIfEmpty(RequestCredentials.none())
.flatMap(
credentials ->
coordinator.execute(
new ReactiveGatewayCall<>(
runtime,
prepared,
reactiveBody,
responseType,
statusHandlingPolicy,
deadline,
metadata,
observation,
credentials)))
.doOnSuccess(
result ->
observation.stop(
Optional.ofNullable(result).map(HttpCallResult::status),
result == null ? ExecutionEvidence.NOT_SENT : result.evidence(),
"success"))
.doOnError(
failure -> {
if (failure instanceof HttpAmbiguousExecutionException ambiguous) {
observation.recordAmbiguous();
observation.stop(Optional.empty(), ambiguous.metadata().evidence(), "ambiguous");
} else if (failure instanceof HttpClientException stable) {
observation.stop(
stable.metadata().status(), stable.metadata().evidence(), "failure");
}
})
.contextWrite(
context ->
context.put(
ReactiveOperationContextKeys.OPERATION_NAME,
operation.operationName().value()));
}
private void closeQuietly(AutoCloseable closeable) {
try {
closeable.close();
} catch (Exception ignored) {
// Releasing an admission permit cannot fail meaningfully.
}
}
private CredentialRequest credentialRequest(
ReactiveClientRuntime runtime, PreparedOperation prepared, boolean forceRefresh) {
return new CredentialRequest(
runtime.name(),
prepared.operation().operationName(),
runtime.profile().authentication(),
prepared.target().uri(),
Optional.empty(),
runtime.profile().tls().keyMaterialReference(),
forceRefresh);
}
/** Reactor Context keys used to carry operation metadata without a ThreadLocal. */
public static final class ReactiveOperationContextKeys {
public static final String OPERATION_NAME = "httpclient.operationName";
private ReactiveOperationContextKeys() {}
}
private final class ReactiveGatewayCall<T> implements ReactiveLogicalCall<T> {
private final ReactiveClientRuntime runtime;
private final PreparedOperation prepared;
private final Optional<ReactiveBodySource> reactiveBody;
private final ResponseType<T> responseType;
private final StatusHandlingPolicy statusHandlingPolicy;
private final Deadline deadline;
private final HttpFailureMetadata metadata;
private final LogicalCallObservation observation;
private final RequestCredentials credentials;
private final Instant startedAt;
private ReactiveGatewayCall(
ReactiveClientRuntime runtime,
PreparedOperation prepared,
Optional<ReactiveBodySource> reactiveBody,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy,
Deadline deadline,
HttpFailureMetadata metadata,
LogicalCallObservation observation,
RequestCredentials credentials) {
this.runtime = runtime;
this.prepared = prepared;
this.reactiveBody = reactiveBody;
this.responseType = responseType;
this.statusHandlingPolicy = statusHandlingPolicy;
this.deadline = deadline;
this.metadata = metadata;
this.observation = observation;
this.credentials = credentials;
this.startedAt = runtime.support().clock().instant();
}
@Override
public Mono<AttemptOutcome<T>> attempt(int attemptNumber) {
if (!runtime.acceptsNewAttempts() && attemptNumber > 1) {
return Mono.error(
new HttpRateLimitRejectedException(
"runtime is draining and refuses new attempts", metadata));
}
return executor.execute(
runtime,
prepared,
reactiveBody,
responseType,
statusHandlingPolicy,
credentials,
attemptNumber,
startedAt,
metadata.withAttempt(attemptNumber));
}
@Override
public RetryContext context(AttemptOutcome<T> outcome, int attemptNumber) {
return new RetryContext(
prepared.operation().idempotency(),
prepared.operation().idempotencyKey(),
prepared.idempotencyKeySent(),
reactiveBody
.map(ReactiveBodySource::replayability)
.orElseGet(() -> prepared.operation().body().replayability()),
outcome.evidence(),
outcome.failureCategory(),
outcome.status(),
outcome.retryAfter(),
attemptNumber,
runtime.profile().retry().maxAttempts(),
outcome.firstByteDelivered(),
deadline.remaining(runtime.support().clock()),
runtime.support().minimumAttemptBudget(),
runtime.retryBudget().snapshot(),
runtime.support().transientServerErrorStatuses(),
false,
!runtime.acceptsNewAttempts());
}
@Override
public Deadline deadline() {
return deadline;
}
@Override
public Mono<HttpCallResult<T>> finish(AttemptOutcome<T> outcome, int attemptNumber) {
return outcome
.result()
.map(
value ->
Mono.just(
new HttpCallResult<>(
value.status(),
value.headers(),
value.body(),
attemptNumber,
Duration.between(startedAt, runtime.support().clock().instant()),
value.evidence(),
value.remoteProblem())))
.orElseGet(() -> Mono.error(outcome.failure().orElseThrow()));
}
@Override
public HttpClientException ambiguous(AttemptOutcome<T> outcome, int attemptNumber) {
return new HttpAmbiguousExecutionException(
"request was sent but the remote outcome is unknown",
metadata.withAttempt(attemptNumber).withEvidence(ExecutionEvidence.SENT_NO_RESPONSE));
}
@Override
public HttpClientException retryExhausted(int attemptNumber) {
observation.recordRetryExhausted();
return new HttpRateLimitRejectedException(
"retry budget for this upstream is exhausted", metadata.withAttempt(attemptNumber));
}
@Override
public void onRetryGranted(RetryAllowed allowed, int attemptNumber) {
observation.recordRetry(allowed.reason());
}
}
}
@@ -0,0 +1,183 @@
package dev.caskeleton.adapter.outbound.httpclient.webclient;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ClassResponseType;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
/**
* Bounded SSE client (design §23.4).
*
* <p>Three budgets are kept apart deliberately: a setup deadline for establishing the stream, an
* idle timeout for silence once it is open, and an optional maximum lifetime. Reconnects consume
* the retry budget like any other physical attempt, and cancelling the subscription stops both the
* stream and any pending reconnect.
*/
public final class DefaultReactiveSseGateway implements ReactiveSseGateway {
private final ClientRuntimeRegistry runtimes;
public DefaultReactiveSseGateway(ClientRuntimeRegistry runtimes) {
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
}
@Override
public <T> Flux<ServerSentEvent<T>> connect(
ClientProfileName profileName, SseOperation operation, ResponseType<T> eventType) {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(operation, "sse operation");
Objects.requireNonNull(eventType, "event type");
AtomicReference<String> lastEventId = new AtomicReference<>();
return Flux.usingWhen(
Mono.fromSupplier(() -> runtimes.acquire(profileName)),
lease -> stream(lease, operation, eventType, lastEventId),
lease -> Mono.fromRunnable(lease::close),
(lease, failure) -> Mono.fromRunnable(lease::close),
lease -> Mono.fromRunnable(lease::close));
}
private <T> Flux<ServerSentEvent<T>> stream(
ClientRuntimeLease lease,
SseOperation operation,
ResponseType<T> eventType,
AtomicReference<String> lastEventId) {
if (!(lease.runtime() instanceof ReactiveClientRuntime runtime)) {
return Flux.error(
new IllegalStateException(
"profile "
+ lease.runtime().name().value()
+ " is not configured for the reactive api"));
}
HttpFailureMetadata metadata =
HttpFailureMetadata.validation(
runtime.name(),
operation.operationName(),
dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod.GET,
operation.uriTemplate(),
BodyReplayability.REPLAYABLE);
Flux<ServerSentEvent<T>> events =
open(runtime, operation, eventType, lastEventId)
.timeout(
operation.streamingIdleTimeout(),
Flux.error(new SseIdleTimeoutException(operation.operationName(), metadata)))
.doOnNext(event -> rememberEventId(event, lastEventId));
Flux<ServerSentEvent<T>> bounded =
operation.maxStreamDuration().map(events::take).orElse(events);
return operation.reconnectPolicy().enabled()
? bounded.retryWhen(reconnectSpec(runtime, operation))
: bounded;
}
private <T> Flux<ServerSentEvent<T>> open(
ReactiveClientRuntime runtime,
SseOperation operation,
ResponseType<T> eventType,
AtomicReference<String> lastEventId) {
// Through the profile's target policy, not around it. This used to expand the template and go
// straight to the WebClient, so an SSE subscription reached its destination with no
// relative-only
// check, no host or port allowlist, no header policy and no credential the one long-lived
// connection type in the platform was also the least governed. The policy rejects an absolute
// template and an off-allowlist origin exactly as it does for an ordinary call.
HttpOperation subscribeOperation =
HttpOperation.get(
operation.operationName(), operation.uriTemplate(), operation.uriVariables());
PreparedOperation prepared = runtime.targetPolicy().prepare(subscribeOperation);
java.net.URI uri = prepared.target().uri();
RequestCredentials credentials =
runtime
.credentialProvider()
.resolve(
new CredentialRequest(
runtime.name(),
operation.operationName(),
runtime.profile().authentication(),
uri,
java.util.Optional.empty(),
runtime.profile().tls().keyMaterialReference(),
false))
.block(operation.setupDeadline());
var request = runtime.webClient().get().uri(uri).accept(MediaType.TEXT_EVENT_STREAM);
for (Map.Entry<String, List<String>> header : prepared.headers().entrySet()) {
for (String value : header.getValue()) {
request = request.header(header.getKey(), value);
}
}
if (credentials != null) {
for (Map.Entry<String, String> credential : credentials.headers().entrySet()) {
request = request.header(credential.getKey(), credential.getValue());
}
}
if (operation.reconnectPolicy().sendLastEventId() && lastEventId.get() != null) {
request = request.header("Last-Event-ID", lastEventId.get());
}
return request
.retrieve()
.bodyToFlux(serverSentEventType(eventType))
.timeout(
operation.setupDeadline(),
Flux.error(
new SseIdleTimeoutException(
operation.operationName(), HttpFailureMetadata.startup(runtime.name()))))
.onErrorResume(
java.util.concurrent.TimeoutException.class,
failure ->
Flux.error(
new SseIdleTimeoutException(
operation.operationName(), HttpFailureMetadata.startup(runtime.name()))));
}
private <T> ParameterizedTypeReference<ServerSentEvent<T>> serverSentEventType(
ResponseType<T> eventType) {
java.lang.reflect.Type eventElementType =
eventType instanceof ClassResponseType<T> classType
? classType.rawType()
: eventType.type();
java.lang.reflect.Type sseType =
org.springframework.core.ResolvableType.forClassWithGenerics(
ServerSentEvent.class,
org.springframework.core.ResolvableType.forType(eventElementType))
.getType();
return ParameterizedTypeReference.<ServerSentEvent<T>>forType(sseType);
}
private <T> void rememberEventId(ServerSentEvent<T> event, AtomicReference<String> lastEventId) {
if (event.id() != null) {
lastEventId.set(event.id());
}
}
private Retry reconnectSpec(ReactiveClientRuntime runtime, SseOperation operation) {
SseReconnectPolicy policy = operation.reconnectPolicy();
Duration backoff =
policy.reconnectBackoff().isZero() ? Duration.ofMillis(50) : policy.reconnectBackoff();
return Retry.fixedDelay(policy.maxReconnects(), backoff)
.filter(failure -> runtime.retryBudget().tryConsume())
.transientErrors(true);
}
}
@@ -0,0 +1,184 @@
package dev.caskeleton.adapter.outbound.httpclient.webclient;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingAttemptExecutor;
import dev.caskeleton.adapter.outbound.httpclient.restclient.RestClientResponseReader;
import dev.caskeleton.adapter.outbound.httpclient.restclient.StatusHandlingPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
/**
* Executes one physical reactive attempt (design §26.2).
*
* <p>Result and error mapping are shared with the blocking path so both produce identical stable
* exceptions and metadata. Discarded buffers are released explicitly: a cancelled or errored
* reactive pipeline drops elements silently, and a dropped {@code DataBuffer} is leaked memory.
*/
public final class ReactiveAttemptExecutor {
private final WebClientBodyWriter bodyWriter = new WebClientBodyWriter();
private final WebClientResponseMapper responseMapper = new WebClientResponseMapper();
public <T> Mono<AttemptOutcome<T>> execute(
ReactiveClientRuntime runtime,
PreparedOperation prepared,
Optional<ReactiveBodySource> reactiveBody,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy,
RequestCredentials credentials,
int attemptNumber,
Instant startedAt,
HttpFailureMetadata baseMetadata) {
Objects.requireNonNull(runtime, "runtime");
return Mono.defer(
() -> {
// Query-parameter credentials are applied here, as they are on the blocking path.
// An API_KEY_QUERY profile resolved its credential, the reactive executor read only
// the header map, and the request went out with the key missing an authentication
// mechanism that worked on one API and silently did not on the other.
WebClient.RequestBodySpec spec =
runtime
.webClient()
.method(
org.springframework.http.HttpMethod.valueOf(
prepared.operation().method().name()))
.uri(withCredentialQuery(prepared.target().uri(), credentials));
headers(prepared, credentials)
.forEach((name, values) -> spec.header(name, values.toArray(String[]::new)));
WebClient.RequestHeadersSpec<?> request =
bodyWriter.write(
spec,
prepared.operation().body(),
reactiveBody,
runtime.bodyLimitPolicy(),
baseMetadata);
return request.exchangeToMono(
response ->
responseMapper.readBounded(
response, Math.toIntExact(prepared.maxResponseWireBytes())));
})
.map(
response ->
mapResponse(
runtime,
responseType,
statusHandlingPolicy,
attemptNumber,
startedAt,
baseMetadata,
response))
.onErrorResume(failure -> Mono.just(mapFailure(runtime, baseMetadata, failure)))
.doOnDiscard(DataBuffer.class, DataBufferUtils::release);
}
private <T> AttemptOutcome<T> mapResponse(
ReactiveClientRuntime runtime,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy,
int attemptNumber,
Instant startedAt,
HttpFailureMetadata baseMetadata,
RestClientResponseReader.RawResponse response) {
Duration elapsed = Duration.between(startedAt, runtime.support().clock().instant());
try {
HttpCallResult<T> result =
runtime
.support()
.responseMapper()
.map(
response,
responseType,
runtime.profile().response(),
statusHandlingPolicy,
attemptNumber,
elapsed,
baseMetadata);
return AttemptOutcome.succeeded(result);
} catch (HttpRemoteErrorException remoteError) {
return AttemptOutcome.failed(
remoteError,
FailureCategory.REMOTE_STATUS,
BlockingAttemptExecutor.retryAfter(response),
false);
} catch (HttpClientException stable) {
return AttemptOutcome.failed(stable, categoryOf(stable), Optional.empty(), false);
}
}
private <T> AttemptOutcome<T> mapFailure(
ReactiveClientRuntime runtime, HttpFailureMetadata baseMetadata, Throwable failure) {
if (failure instanceof HttpClientException stable) {
return AttemptOutcome.failed(stable, categoryOf(stable), Optional.empty(), false);
}
TransportFailure classified =
runtime.failureClassifier().classify(failure, baseMetadata.stage());
HttpFailureMetadata metadata =
baseMetadata.withEvidence(classified.evidence()).withStage(classified.stage());
HttpClientException mapped =
runtime.support().exceptionMapper().map(classified, metadata, failure);
return AttemptOutcome.failed(mapped, classified.category(), Optional.empty(), false);
}
private Map<String, List<String>> headers(
PreparedOperation prepared, RequestCredentials credentials) {
Map<String, List<String>> merged = new LinkedHashMap<>(prepared.headers());
credentials.headers().forEach((name, value) -> merged.put(name, List.of(value)));
return merged;
}
private FailureCategory categoryOf(HttpClientException failure) {
return switch (failure.getClass().getSimpleName()) {
case "HttpDnsException" -> FailureCategory.DNS;
case "HttpPoolAcquireTimeoutException" -> FailureCategory.POOL_ACQUIRE_TIMEOUT;
case "HttpConnectException" -> FailureCategory.CONNECT;
case "HttpProxyException" -> FailureCategory.PROXY;
case "HttpTlsException" -> FailureCategory.TLS_PERMANENT;
case "HttpRequestWriteException" -> FailureCategory.REQUEST_WRITE;
case "HttpResponseTimeoutException" -> FailureCategory.RESPONSE_TIMEOUT;
case "HttpResponseTruncatedException" -> FailureCategory.RESPONSE_TRUNCATED;
case "HttpResponseTooLargeException" -> FailureCategory.RESPONSE_TOO_LARGE;
case "HttpSerializationException" -> FailureCategory.SERIALIZATION;
case "HttpTargetRejectedException" -> FailureCategory.TARGET_REJECTED;
case "HttpRedirectRejectedException" -> FailureCategory.REDIRECT_REJECTED;
case "HttpAuthenticationException" -> FailureCategory.AUTHENTICATION;
case "HttpDeadlineExceededException" -> FailureCategory.DEADLINE_EXCEEDED;
case "HttpConfigurationException" -> FailureCategory.CONFIGURATION;
default -> FailureCategory.UNKNOWN;
};
}
/**
* Appends query-parameter credentials to the target.
*
* <p>The blocking executor has always done this; the reactive one read only the header map, so an
* {@code API_KEY_QUERY} profile authenticated on one API surface and not on the other.
*/
private java.net.URI withCredentialQuery(java.net.URI uri, RequestCredentials credentials) {
if (credentials.queryParameters().isEmpty()) {
return uri;
}
org.springframework.web.util.UriComponentsBuilder builder =
org.springframework.web.util.UriComponentsBuilder.fromUri(uri);
credentials.queryParameters().forEach(builder::queryParam);
return builder.build(true).toUri();
}
}
@@ -0,0 +1,111 @@
package dev.caskeleton.adapter.outbound.httpclient.webclient;
import dev.caskeleton.adapter.outbound.httpclient.auth.ReactiveRequestCredentialProvider;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy;
import dev.caskeleton.adapter.outbound.httpclient.resilience.LogicalAdmissionLimiter;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget;
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingExecutionSupport;
import dev.caskeleton.adapter.outbound.httpclient.security.BodyLimitPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.TrustedTargetPolicy;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
import java.util.Objects;
import java.util.function.Supplier;
import org.springframework.web.reactive.function.client.WebClient;
/**
* One immutable reactive generation (design §7.2, §26.2).
*
* <p>Reactive runtimes deliberately do not expose an attempt bulkhead built on a thread pool:
* design §18.2 requires semaphore-style concurrency here, because wrapping an event loop in a
* thread pool destroys the property that makes it useful.
*/
public final class ReactiveClientRuntime extends ClientRuntime {
private final WebClient webClient;
private final TransportId transportId;
private final TransportFailureClassifier failureClassifier;
private final TrustedTargetPolicy targetPolicy;
private final BodyLimitPolicy bodyLimitPolicy;
private final LogicalAdmissionLimiter admissionLimiter;
private final RetryBudget retryBudget;
private final Supplier<BackoffStrategy> backoffFactory;
private final ReactiveRequestCredentialProvider credentialProvider;
private final BlockingExecutionSupport support;
public ReactiveClientRuntime(
ClientProfile profile,
RuntimeGeneration generation,
Runnable resourceCloser,
WebClient webClient,
TransportId transportId,
TransportFailureClassifier failureClassifier,
LogicalAdmissionLimiter admissionLimiter,
RetryBudget retryBudget,
Supplier<BackoffStrategy> backoffFactory,
ReactiveRequestCredentialProvider credentialProvider,
BlockingExecutionSupport support) {
super(profile, generation, resourceCloser);
this.webClient = Objects.requireNonNull(webClient, "web client");
this.transportId = Objects.requireNonNull(transportId, "transport id");
this.failureClassifier = Objects.requireNonNull(failureClassifier, "failure classifier");
this.admissionLimiter = Objects.requireNonNull(admissionLimiter, "admission limiter");
this.retryBudget = Objects.requireNonNull(retryBudget, "retry budget");
this.backoffFactory = Objects.requireNonNull(backoffFactory, "backoff factory");
this.credentialProvider = Objects.requireNonNull(credentialProvider, "credential provider");
this.support = Objects.requireNonNull(support, "execution support");
this.targetPolicy = new TrustedTargetPolicy(profile);
this.bodyLimitPolicy = BodyLimitPolicy.maxRequestBytes(profile.request().maxBodyBytes());
}
/**
* The engine client, visible only inside this package.
*
* <p>Public exposure let a caller bypass every platform guarantee, which is what the reactive
* typed registry did. See {@code BlockingClientRuntime#restClient()} for the full reasoning.
*
* @return the profile's immutable {@code WebClient}
*/
WebClient webClient() {
return webClient;
}
public TransportId transportId() {
return transportId;
}
public TransportFailureClassifier failureClassifier() {
return failureClassifier;
}
public TrustedTargetPolicy targetPolicy() {
return targetPolicy;
}
public BodyLimitPolicy bodyLimitPolicy() {
return bodyLimitPolicy;
}
public LogicalAdmissionLimiter admissionLimiter() {
return admissionLimiter;
}
public RetryBudget retryBudget() {
return retryBudget;
}
public BackoffStrategy newBackoff() {
return backoffFactory.get();
}
public ReactiveRequestCredentialProvider credentialProvider() {
return credentialProvider;
}
public BlockingExecutionSupport support() {
return support;
}
}
@@ -0,0 +1,107 @@
package dev.caskeleton.adapter.outbound.httpclient.webclient;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.restclient.ResponseSizeLimiter;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import java.util.Objects;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* Reactive streaming download (design §23.2, §23.3, D-12).
*
* <p>The status is checked before the body is exposed, the byte budget is enforced as buffers flow,
* and the first delivered buffer permanently disables transparent retry for the call.
*/
public final class ReactiveStreamingGateway {
private final ClientRuntimeRegistry runtimes;
public ReactiveStreamingGateway(ClientRuntimeRegistry runtimes) {
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
}
public Flux<DataBuffer> download(ClientProfileName profileName, HttpOperation operation) {
return download(profileName, operation, new FirstByteDeliveryGuard());
}
public Flux<DataBuffer> download(
ClientProfileName profileName, HttpOperation operation, FirstByteDeliveryGuard guard) {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(operation, "operation");
Objects.requireNonNull(guard, "first byte guard");
return Flux.usingWhen(
Mono.fromSupplier(() -> runtimes.acquire(profileName)),
lease -> stream(lease, operation, guard),
lease -> Mono.fromRunnable(lease::close),
(lease, failure) -> Mono.fromRunnable(lease::close),
lease -> Mono.fromRunnable(lease::close));
}
private Flux<DataBuffer> stream(
ClientRuntimeLease lease, HttpOperation operation, FirstByteDeliveryGuard guard) {
if (!(lease.runtime() instanceof ReactiveClientRuntime runtime)) {
return Flux.error(
new IllegalStateException(
"profile "
+ lease.runtime().name().value()
+ " is not configured for the reactive api"));
}
HttpFailureMetadata metadata =
HttpFailureMetadata.validation(
runtime.name(),
operation.operationName(),
operation.method(),
operation.uriTemplate(),
operation.body().replayability());
PreparedOperation prepared = runtime.targetPolicy().prepare(operation);
ResponseSizeLimiter limiter =
new ResponseSizeLimiter(
prepared.maxResponseWireBytes(), prepared.maxResponseDecodedBytes(), metadata);
// The prepared headers are actually sent. They were computed and then dropped: the request was
// built from the URI alone, so the header policy ran, produced an approved set, and the wire
// saw
// none of it no content negotiation, no correlation header, and no credential.
var request =
runtime
.webClient()
.method(org.springframework.http.HttpMethod.valueOf(operation.method().name()))
.uri(prepared.target().uri());
for (var header : prepared.headers().entrySet()) {
for (String value : header.getValue()) {
request = request.header(header.getKey(), value);
}
}
return request
.exchangeToFlux(
response -> {
int status = response.statusCode().value();
if (status < 200 || status >= 300) {
return response
.releaseBody()
.thenMany(
Flux.error(
new HttpRemoteErrorException(
"streaming download returned an error status",
metadata
.withStatus(new HttpStatus(status))
.withEvidence(ExecutionEvidence.RESPONSE_RECEIVED))));
}
return BoundedDataBufferFlux.bound(
response.bodyToFlux(DataBuffer.class), limiter, guard);
})
.doOnDiscard(DataBuffer.class, DataBufferUtils::release);
}
}
@@ -0,0 +1,98 @@
package dev.caskeleton.adapter.outbound.httpclient.webclient;
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
import dev.caskeleton.adapter.outbound.httpclient.api.body.ByteArrayBody;
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
import dev.caskeleton.adapter.outbound.httpclient.api.body.ObjectBody;
import dev.caskeleton.adapter.outbound.httpclient.api.body.OneShotStreamBody;
import dev.caskeleton.adapter.outbound.httpclient.api.body.ReopenableStreamBody;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRequestWriteException;
import dev.caskeleton.adapter.outbound.httpclient.security.BodyLimitPolicy;
import java.io.IOException;
import java.util.Optional;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
/**
* Writes a body onto a WebClient request (design §10.2, §23.1).
*
* <p>A reopenable body is opened per attempt so a retry sends the same bytes rather than an
* already-drained stream.
*/
public final class WebClientBodyWriter {
public WebClient.RequestHeadersSpec<?> write(
WebClient.RequestBodySpec spec,
BodySource body,
Optional<ReactiveBodySource> reactiveBody,
BodyLimitPolicy bodyLimitPolicy,
HttpFailureMetadata metadata) {
if (reactiveBody.isPresent()) {
ReactiveBodySource reactive = reactiveBody.get();
// A reactive body used to return here before any limit was applied, so the whole
// request-size policy was opt-out: publish the body as a Flux and the profile's
// max-body-bytes stopped existing. The known length is checked up front when the source
// declares one, and the emitted bytes are counted as they go when it does not an
// unbounded publisher is exactly the case a byte ceiling is for.
reactive
.knownLength()
.ifPresent(
declared -> {
if (declared > bodyLimitPolicy.limit()) {
throw new HttpRequestWriteException(
"reactive request body declares "
+ declared
+ " bytes, over the profile limit of "
+ bodyLimitPolicy.limit(),
metadata);
}
});
spec.contentType(reactive.mediaType());
java.util.concurrent.atomic.AtomicLong written = new java.util.concurrent.atomic.AtomicLong();
return spec.body(
BodyInserters.fromDataBuffers(
reactor.core.publisher.Flux.from(reactive.publisherFactory().get())
.cast(DataBuffer.class)
.doOnNext(
buffer ->
bodyLimitPolicy.recordWrittenBytes(
written.addAndGet(buffer.readableByteCount()), metadata))));
}
bodyLimitPolicy.validate(body, metadata);
if (body instanceof EmptyBody) {
return spec;
}
spec.contentType(mediaType(body));
if (body instanceof ObjectBody objectBody) {
return spec.bodyValue(objectBody.value());
}
if (body instanceof ByteArrayBody byteArrayBody) {
return spec.bodyValue(byteArrayBody.bytes());
}
if (body instanceof ReopenableStreamBody reopenable) {
try {
return spec.body(
BodyInserters.fromResource(new InputStreamResource(reopenable.opener().get())));
} catch (IOException failure) {
throw new HttpRequestWriteException("request body could not be opened", metadata, failure);
}
}
if (body instanceof OneShotStreamBody oneShot) {
return spec.body(BodyInserters.fromResource(new InputStreamResource(oneShot.stream())));
}
throw new IllegalStateException("unsupported body source: " + body.getClass().getName());
}
private MediaType mediaType(BodySource body) {
String declared = body.mediaType();
return declared.isBlank()
? MediaType.APPLICATION_OCTET_STREAM
: MediaType.parseMediaType(declared);
}
}
@@ -0,0 +1,123 @@
package dev.caskeleton.adapter.outbound.httpclient.webclient;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.auth.ReactiveRequestCredentialProvider;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeFactory;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType;
import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ExponentialFullJitterBackoff;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRegistry;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget;
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingExecutionSupport;
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportProvider;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportCapabilityValidator;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
import java.time.Duration;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
import java.util.random.RandomGenerator;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
/**
* Builds a reactive runtime generation for a profile (design §26.2).
*
* <p>The codec in-memory limit is derived from the profile's decoded-byte budget rather than left
* at the framework default, so an oversized response is rejected by the same number the profile
* declares.
*/
public final class WebClientRuntimeFactory implements ClientRuntimeFactory {
private final Map<TransportType, ReactiveTransportProvider> providers =
new EnumMap<>(TransportType.class);
private final TransportCapabilityValidator capabilityValidator =
new TransportCapabilityValidator();
private final ResilienceRegistry resilienceRegistry;
private final ReactiveRequestCredentialProvider credentialProvider;
private final BlockingExecutionSupport support;
private final TransportLifecycleListener lifecycleListener;
private final RandomGenerator random;
public WebClientRuntimeFactory(
Map<TransportType, ReactiveTransportProvider> providers,
ResilienceRegistry resilienceRegistry,
ReactiveRequestCredentialProvider credentialProvider,
BlockingExecutionSupport support,
TransportLifecycleListener lifecycleListener,
RandomGenerator random) {
Objects.requireNonNull(providers, "reactive transport providers").forEach(this.providers::put);
this.resilienceRegistry = Objects.requireNonNull(resilienceRegistry, "resilience registry");
this.credentialProvider = Objects.requireNonNull(credentialProvider, "credential provider");
this.support = Objects.requireNonNull(support, "execution support");
this.lifecycleListener = Objects.requireNonNull(lifecycleListener, "lifecycle listener");
this.random = Objects.requireNonNull(random, "random generator");
}
@Override
public ClientRuntime create(ClientProfile profile, RuntimeGeneration generation) {
Objects.requireNonNull(profile, "profile");
ReactiveTransportProvider provider = providers.get(profile.transport());
if (provider == null) {
throw new HttpConfigurationException(
"no reactive transport provider is registered for " + profile.transport(),
HttpFailureMetadata.startup(profile.name()));
}
capabilityValidator.validate(profile, provider.capabilities());
ClientHttpConnector connector = provider.create(profile, generation, lifecycleListener);
WebClient webClient =
WebClient.builder()
.clientConnector(connector)
.baseUrl(profile.baseUrl().toString())
.codecs(
configurer ->
configurer
.defaultCodecs()
.maxInMemorySize(
Math.toIntExact(
Math.min(profile.response().maxDecodedBytes(), Integer.MAX_VALUE))))
.build();
RetryBudget retryBudget =
profile
.retry()
.budget()
.map(
name ->
resilienceRegistry.retryBudget(
name,
Math.max(1L, profile.pool().maxTotalConnections() / 10L),
Duration.ofMinutes(1)))
.orElseGet(RetryBudget::unlimited);
Supplier<BackoffStrategy> backoffFactory =
() ->
new ExponentialFullJitterBackoff(
profile.retry().baseBackoff(),
profile.retry().maxBackoff(),
profile.retry().jitter(),
profile.retry().retryAfter(),
random);
return new ReactiveClientRuntime(
profile,
generation,
() -> provider.close(profile, generation),
webClient,
provider.id(),
provider.failureClassifier(),
resilienceRegistry.admission(
profile.name(),
profile.pool().maxPendingAcquires() + profile.pool().maxTotalConnections()),
retryBudget,
backoffFactory,
credentialProvider,
support);
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.adapter.outbound.httpclient.apache;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
import dev.caskeleton.adapter.outbound.httpclient.testkit.NoopLifecycleListener;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
class ApacheBlockingTransportProviderTest {
@Test
void sendsRequestThroughConfiguredFactory() throws Exception {
try (MockHttpServer server = MockHttpServer.start()) {
server.enqueueJson(200, "{\"value\":1}");
ClientProfile profile = ClientProfiles.apache(server.uri("/"));
ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider();
try {
ClientHttpRequestFactory factory =
provider.create(
profile,
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
NoopLifecycleListener.INSTANCE);
RestClient client = RestClient.builder().requestFactory(factory).build();
String body = client.get().uri(server.uri("/value")).retrieve().body(String.class);
assertThat(body).contains("value");
assertThat(server.takeRequest(Duration.ofSeconds(2)).path()).isEqualTo("/value");
assertThat(provider.leasedConnections(profile.name())).isZero();
} finally {
provider.close(
profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1));
}
}
}
@Test
void neverFollowsRedirectsAtTheEngineLevel() throws Exception {
try (MockHttpServer server = MockHttpServer.start()) {
server.enqueueRedirect(302, "/moved");
ClientProfile profile = ClientProfiles.apache(server.uri("/"));
ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider();
try {
RestClient client =
RestClient.builder()
.requestFactory(
provider.create(
profile,
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
NoopLifecycleListener.INSTANCE))
.build();
int status =
client
.get()
.uri(server.uri("/start"))
.exchange((request, response) -> response.getStatusCode().value());
assertThat(status).isEqualTo(302);
assertThat(server.requestCount()).isEqualTo(1);
} finally {
provider.close(
profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1));
}
}
}
@Test
void declaresRouteScopedPoolAndDynamicTargetCapability() {
assertThat(new ApacheBlockingTransportProvider().capabilities().routeScopedPool()).isTrue();
assertThat(new ApacheBlockingTransportProvider().capabilities().dynamicTargetStable()).isTrue();
assertThat(new ApacheBlockingTransportProvider().id().value()).isEqualTo("apache");
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.adapter.outbound.httpclient.apache;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.PoolSettings;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
import dev.caskeleton.adapter.outbound.httpclient.testkit.NoopLifecycleListener;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.springframework.web.client.RestClient;
class ApachePoolSaturationTest {
@Test
void poolAcquireTimeoutIsClassifiedAsNotSent() throws Exception {
ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider();
ExecutorService executor = Executors.newFixedThreadPool(2);
try (MockHttpServer server = MockHttpServer.start()) {
server.enqueueDelayedBody(200, "{\"slow\":true}", Duration.ofSeconds(2));
server.enqueueJson(200, "{\"fast\":true}");
ClientProfile profile = singleConnectionProfile(server.uri("/"));
RestClient client =
RestClient.builder()
.requestFactory(
provider.create(
profile,
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
NoopLifecycleListener.INSTANCE))
.build();
CountDownLatch firstStarted = new CountDownLatch(1);
executor.execute(
() -> {
firstStarted.countDown();
try {
client.get().uri(server.uri("/slow")).retrieve().body(String.class);
} catch (RuntimeException ignored) {
// The holding request is only needed to occupy the single pooled connection.
}
});
assertThat(firstStarted.await(2, TimeUnit.SECONDS)).isTrue();
Thread.sleep(200);
Throwable captured = null;
try {
client.get().uri(server.uri("/fast")).retrieve().body(String.class);
} catch (RuntimeException saturated) {
captured = saturated;
}
assertThat(captured).isNotNull();
TransportFailure failure =
new ApacheFailureClassifier().classify(captured, AttemptStage.POOL_ACQUIRE);
assertThat(failure.stage()).isEqualTo(AttemptStage.POOL_ACQUIRE);
assertThat(failure.evidence()).isEqualTo(ExecutionEvidence.NOT_SENT);
provider.close(
profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1));
} finally {
executor.shutdownNow();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
private static ClientProfile singleConnectionProfile(java.net.URI baseUrl) {
return ClientProfiles.builder("saturated")
.baseUrl(baseUrl)
.pool(
new PoolSettings(
1,
1,
1,
Duration.ofMillis(100),
Duration.ofSeconds(30),
Duration.ofMinutes(5),
Duration.ofSeconds(5),
Duration.ofSeconds(15),
Duration.ofSeconds(5),
false,
false))
.build();
}
}

Some files were not shown because too many files have changed in this diff Show More