feat(httpclient): close the platform review's P0/P1/P2 findings
The review found one defect shape repeated across the platform: surfaces that were declared, bound, and documented, but that nothing read. An operator configuring fullUrlRecording, bodyLogging, retry.policy, validatedDnsPinning, timeout.dns, or any of ten declared metric names got a guarantee the code never delivered. Every such surface is now in exactly one of three states -- wired for real, rejected at startup, or registered in a test-enforced gap list with its reason. No silent no-ops remain. P0: - Activate the platform from bootstrap behind app.httpclient.enabled, with a single auto-configuration importing the nine child configurations. - Give the platform a strict, repository-level ENV contract: 74 leaf fields derived from the settings record tree, unknown APP_HTTPCLIENT_* rejected. - Route typed HTTP service clients through the call kernel via KernelHttpExchangeAdapter, so they stop bypassing platform policy. - Pin dynamic-target DNS resolution to the socket for the life of a call, closing the resolve-then-connect TOCTOU / rebinding window. - Actually transmit the idempotency key, and make retry eligibility depend on transmission rather than on merely holding one. - Reject reactive authentication and reactive redirect at startup instead of declaring support that does not function. - Fix the Reactor-only Stable contract row so the lane stops failing. - Stop advertising HTTP/3 on a transport that negotiates HTTP/1. P1 covers execution and retry accounting, redirect security (per-hop target guarding, sensitive-header stripping, 303 body handling), runtime rotation and transport resource ownership keyed by generation, dynamic-target hardening (subdomain matching, global-unicast classification, strict CIDR parsing), protocol intent, pool and timeout wiring, streaming and body limits, observability parity, and OAuth single-flight refresh on a bounded pool with a bounded wait. P2 covers configuration and documentation drift, the Gradle check wiring for the four hermetic lanes, and the CI gate matrix. Two test-quality defects surfaced while closing these: the HTTP/2 stream saturation test ran against cleartext HTTP/1.1 while asserting nothing about the protocol, and an OAuth contention test slept on a latch that could fire before the callers it meant to observe. Both now assert what their names claim. Verification run: :adapter:outbound:httpclient:check and :app-bootstrap:check (checkstyle, spotless, spotbugs, and the four hermetic lanes), verifyCleanArchitectureDependencies, verifyEnvKeys, verifyOneTypePerFile, verifyDependencyLocks, the documentation and gate-matrix verifiers, and the performance lane against a real TLS+ALPN HTTP/2 server. Not executed, and tracked rather than claimed: Docker/Toxiproxy fault injection, JMH, a real QUIC/HTTP3 server, a real Spring Framework 6.2 distribution (now a delegated-pending gate), live OAuth/TLS/proxy/DNS integration, and a whole-repository check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5f10b791d3
commit
0cd959a494
@@ -24,6 +24,13 @@ gates:
|
|||||||
workflow: ci-quality-gates.yml
|
workflow: ci-quality-gates.yml
|
||||||
job: quality-gates
|
job: quality-gates
|
||||||
execution: check
|
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
|
- id: clean-architecture-dependencies
|
||||||
release_blocking: true
|
release_blocking: true
|
||||||
mechanism: gradle-custom-task
|
mechanism: gradle-custom-task
|
||||||
@@ -101,12 +108,12 @@ gates:
|
|||||||
workflow: ci-quality-gates.yml
|
workflow: ci-quality-gates.yml
|
||||||
job: gate-matrix-lint
|
job: gate-matrix-lint
|
||||||
execution: job
|
execution: job
|
||||||
- id: redis-standalone
|
- id: redis-sdk
|
||||||
release_blocking: true
|
release_blocking: true
|
||||||
mechanism: workflow-job
|
mechanism: workflow-job
|
||||||
ref: redis-standalone
|
ref: redis-sdk
|
||||||
workflow: ci-quality-gates.yml
|
workflow: ci-quality-gates.yml
|
||||||
job: redis-standalone
|
job: redis-sdk
|
||||||
execution: job
|
execution: job
|
||||||
- id: jpa-candidate-evidence
|
- id: jpa-candidate-evidence
|
||||||
release_blocking: true
|
release_blocking: true
|
||||||
@@ -171,7 +178,7 @@ gates:
|
|||||||
workflow: object-storage-qualification.yml
|
workflow: object-storage-qualification.yml
|
||||||
job: minio-managed-contract
|
job: minio-managed-contract
|
||||||
execution: explicit
|
execution: explicit
|
||||||
- id: poster-image-v7-migration
|
- id: poster-image-migration
|
||||||
release_blocking: true
|
release_blocking: true
|
||||||
mechanism: gradle-custom-task
|
mechanism: gradle-custom-task
|
||||||
ref: posterImageMigrationTest
|
ref: posterImageMigrationTest
|
||||||
@@ -192,3 +199,91 @@ gates:
|
|||||||
workflow: object-storage-qualification.yml
|
workflow: object-storage-qualification.yml
|
||||||
job: aws-managed-common-subset
|
job: aws-managed-common-subset
|
||||||
execution: job
|
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
|
||||||
|
|||||||
@@ -2,15 +2,34 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
readonly REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel)"
|
if (( $# > 1 )); then
|
||||||
readonly EXPECTED_SCRIPT_DIR="$(cd -- "${REPO_ROOT}/.github/scripts" && pwd -P)"
|
printf '::error::gate-matrix-lint: expected zero arguments or one repository root\n' >&2
|
||||||
readonly MATRIX="${REPO_ROOT}/.github/ci-gate-matrix.yml"
|
exit 2
|
||||||
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
|
|
||||||
fi
|
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
|
if [[ ! -f "${MATRIX}" ]]; then
|
||||||
printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2
|
printf '::error::gate-matrix-lint: missing %s\n' "${MATRIX}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -80,6 +99,146 @@ job_body() {
|
|||||||
' "${workflow_file}"
|
' "${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
|
while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
|
||||||
[[ -z "${id}" ]] && continue
|
[[ -z "${id}" ]] && continue
|
||||||
total=$((total + 1))
|
total=$((total + 1))
|
||||||
@@ -114,8 +273,11 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
|
|||||||
|
|
||||||
case "${mechanism}" in
|
case "${mechanism}" in
|
||||||
gradle-custom-task)
|
gradle-custom-task)
|
||||||
if ! grep -RqsE -- "tasks\\.register\\(['\"]${ref}['\"]" "${REPO_ROOT}/src" \
|
if [[ ! "${ref}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
|
||||||
--include='build.gradle'; 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}'")
|
failures+=("gate '${id}' references unregistered Gradle task '${ref}'")
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
@@ -123,12 +285,13 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
|
|||||||
gradle-plugin-task)
|
gradle-plugin-task)
|
||||||
plugin="${ref%@*}"
|
plugin="${ref%@*}"
|
||||||
task="${ref#*@}"
|
task="${ref#*@}"
|
||||||
if [[ "${plugin}" == "${ref}" || -z "${task}" ]]; then
|
if [[ "${plugin}" == "${ref}" \
|
||||||
failures+=("gate '${id}' must use plugin@task for gradle-plugin-task")
|
|| ! "${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
|
continue
|
||||||
fi
|
fi
|
||||||
if ! grep -RqsE -- "(id|apply plugin:)[[:space:]]+['\"]${plugin}['\"]" "${REPO_ROOT}/src" \
|
if ! gradle_plugin_is_applied "${plugin}"; then
|
||||||
--include='build.gradle'; then
|
|
||||||
failures+=("gate '${id}' references unapplied Gradle plugin '${plugin}'")
|
failures+=("gate '${id}' references unapplied Gradle plugin '${plugin}'")
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
@@ -158,7 +321,7 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
|
|||||||
|
|
||||||
case "${execution}" in
|
case "${execution}" in
|
||||||
check)
|
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}'")
|
failures+=("gate '${id}' expects Gradle check in job '${job}'")
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
@@ -170,7 +333,7 @@ while IFS=$'\t' read -r id blocking mechanism ref workflow job execution; do
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
explicit)
|
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}'")
|
failures+=("gate '${id}' task '${ref}' is not explicit in job '${job}'")
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|||||||
Executable
+740
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 27010–27012 | host network |
|
||||||
|
| cluster | nodes 7100–7105, bus 17100–17105 | 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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+135
@@ -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())
|
||||||
@@ -15,16 +15,13 @@ APP_MIGRATION_ON_STARTUP=true
|
|||||||
APP_RATE_LIMIT_ENABLED=false
|
APP_RATE_LIMIT_ENABLED=false
|
||||||
APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only
|
APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only
|
||||||
APP_RATE_LIMIT_PROVIDER=disabled
|
APP_RATE_LIMIT_PROVIDER=disabled
|
||||||
APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET=
|
|
||||||
APP_IDEMPOTENCY_TTL=24h
|
APP_IDEMPOTENCY_TTL=24h
|
||||||
APP_IDEMPOTENCY_PROVIDER=jdbc
|
APP_IDEMPOTENCY_PROVIDER=jdbc
|
||||||
APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET=
|
APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET=
|
||||||
APP_IDEMPOTENCY_REDIS_NAMESPACE_ENVIRONMENT=local
|
|
||||||
APP_IDEMPOTENCY_PROCESSING_LEASE=30s
|
APP_IDEMPOTENCY_PROCESSING_LEASE=30s
|
||||||
APP_IDEMPOTENCY_FAILURE_RETENTION=24h
|
APP_IDEMPOTENCY_FAILURE_RETENTION=24h
|
||||||
APP_LEASE_PROVIDER=disabled
|
APP_LEASE_PROVIDER=disabled
|
||||||
APP_LEASE_REDIS_KEY_HMAC_SECRET=
|
APP_LEASE_REDIS_KEY_HMAC_SECRET=
|
||||||
APP_LEASE_REDIS_NAMESPACE_ENVIRONMENT=local
|
|
||||||
APP_LEASE_REDIS_DRIFT_BUDGET=10ms
|
APP_LEASE_REDIS_DRIFT_BUDGET=10ms
|
||||||
|
|
||||||
# ----- Async executor -----
|
# ----- Async executor -----
|
||||||
@@ -34,33 +31,10 @@ APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200
|
|||||||
|
|
||||||
# ----- Optional integration adapters (default: all disabled) -----
|
# ----- Optional integration adapters (default: all disabled) -----
|
||||||
APP_CACHE_CANONICAL_DEFAULT_PROVIDER=disabled
|
APP_CACHE_CANONICAL_DEFAULT_PROVIDER=disabled
|
||||||
# Sentinel primary revalidation cadence for canonically active Sentinel roles.
|
# The single global Redis switch. False means no Redis settings, secrets, client, threads or
|
||||||
APP_REDIS_SENTINEL_DISCOVERY_REFRESH_PERIOD=30s
|
# health contributor exist. Role selectors (cache/session/idempotency/lease/rate-limit) choose
|
||||||
# Canonical role semantic readiness: refresh no more often than this interval.
|
# which capabilities compose once Redis is on; none of them turns Redis on.
|
||||||
APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL=5s
|
APP_REDIS_ENABLED=false
|
||||||
# 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
|
|
||||||
APP_MESSAGING_BROKER=
|
APP_MESSAGING_BROKER=
|
||||||
APP_MESSAGING_KAFKA_BROKERS=
|
APP_MESSAGING_KAFKA_BROKERS=
|
||||||
APP_NOTIFICATION_SLACK_PROVIDER=
|
APP_NOTIFICATION_SLACK_PROVIDER=
|
||||||
@@ -149,15 +123,6 @@ APP_SESSION_COOKIE_SAME_SITE=Lax
|
|||||||
APP_SESSION_COOKIE_PATH=/
|
APP_SESSION_COOKIE_PATH=/
|
||||||
APP_SESSION_CSRF_COOKIE_NAME=XSRF-TOKEN
|
APP_SESSION_CSRF_COOKIE_NAME=XSRF-TOKEN
|
||||||
APP_SESSION_CSRF_HEADER_NAME=X-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 -----
|
# ----- CORS -----
|
||||||
APP_SECURITY_CORS_ENABLED=true
|
APP_SECURITY_CORS_ENABLED=true
|
||||||
@@ -186,3 +151,83 @@ APP_DATASOURCE_POOL_MAX_LIFETIME=1800000
|
|||||||
|
|
||||||
# ----- Management / Actuator -----
|
# ----- Management / Actuator -----
|
||||||
MANAGEMENT_SERVER_PORT=9001
|
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
@@ -17,6 +17,7 @@
|
|||||||
| 게이트 | 하는 일 |
|
| 게이트 | 하는 일 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 |
|
| `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 |
|
||||||
|
| `verifyRuntimeModuleMembership` | registry의 두 composition root membership과 실제 main project dependency가 정확히 일치하는지 검사 |
|
||||||
| `verifyEnvKeys` | `env-keys.yaml` ↔ `application.yml` ↔ `src/.env` 가 어긋나지 않는지 검사 |
|
| `verifyEnvKeys` | `env-keys.yaml` ↔ `application.yml` ↔ `src/.env` 가 어긋나지 않는지 검사 |
|
||||||
| `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 |
|
| `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 |
|
||||||
| `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 |
|
| `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 |
|
||||||
@@ -84,6 +85,24 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
|
|||||||
registry와 ArchUnit 규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. settings와 gate는
|
registry와 ArchUnit 규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. settings와 gate는
|
||||||
같은 registry를 읽고, 등록되지 않은 leaf나 허용되지 않은 edge를 fail-closed로 거부합니다.
|
같은 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)
|
### `verifyOneTypePerFile` (code-conventions I6)
|
||||||
|
|
||||||
- **하는 일.** `src/main/java` 의 모든 `.java` 파일이 public 최상위 타입을 1개만 갖고, 그 타입 이름이
|
- **하는 일.** `src/main/java` 의 모든 `.java` 파일이 public 최상위 타입을 1개만 갖고, 그 타입 이름이
|
||||||
@@ -119,9 +138,9 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
|
|||||||
경로를 **제외한** 모든 요청은 인증을 요구합니다(`src/.env` → `SecuritySettings.publicPaths()` →
|
경로를 **제외한** 모든 요청은 인증을 요구합니다(`src/.env` → `SecuritySettings.publicPaths()` →
|
||||||
`SecurityConfig`). 이 public 표면이 바뀌는 순간이 곧 보호되던 엔드포인트가 조용히 공개로 노출되는
|
`SecurityConfig`). 이 public 표면이 바뀌는 순간이 곧 보호되던 엔드포인트가 조용히 공개로 노출되는
|
||||||
지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다.
|
지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다.
|
||||||
- **승인 방법.** reviewer 가 `./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange` 로
|
- **승인 방법.** `verifyPublicPathSnapshot` 은 항상 읽기 전용입니다. reviewer 가 변경을 승인한 뒤
|
||||||
snapshot 을 의도적으로 다시 생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 수동 승인 후
|
`./gradlew updatePublicPathSnapshot -PapprovePublicPathChange` 로 snapshot 을 명시적으로 다시
|
||||||
재생성된 snapshot 을 함께 커밋합니다.
|
생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 재생성된 snapshot 을 함께 커밋합니다.
|
||||||
- **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시
|
- **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시
|
||||||
`SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection
|
`SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection
|
||||||
은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가
|
은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가
|
||||||
@@ -130,8 +149,8 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
|
|||||||
변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다.
|
변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다.
|
||||||
- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. 이 파일은 커밋된 필수 보안
|
- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. 이 파일은 커밋된 필수 보안
|
||||||
baseline 입니다. CI 는 Gradle 실행 전에 파일이 비어 있지 않고 Git에 추적되는지 검사하므로 fresh
|
baseline 입니다. CI 는 Gradle 실행 전에 파일이 비어 있지 않고 Git에 추적되는지 검사하므로 fresh
|
||||||
checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 위 명령으로 재생성한 뒤
|
checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 update task로 재생성한
|
||||||
보안 리뷰와 함께 커밋합니다.
|
뒤 보안 리뷰와 함께 커밋합니다.
|
||||||
|
|
||||||
### `verifyTrivyignore`
|
### `verifyTrivyignore`
|
||||||
|
|
||||||
@@ -260,9 +279,13 @@ ca-skeleton:
|
|||||||
- `ACTIVE`는 exact destination/provider/operation-catalog binding을 요구합니다. 현재 유일한
|
- `ACTIVE`는 exact destination/provider/operation-catalog binding을 요구합니다. 현재 유일한
|
||||||
buffered-classic readiness card가 `NOT_IMPLEMENTED`이므로 provider resource 생성 전에
|
buffered-classic readiness card가 `NOT_IMPLEMENTED`이므로 provider resource 생성 전에
|
||||||
fail-closed합니다. 아직 운영 HTTP provider를 활성화할 수 있다는 뜻이 아닙니다.
|
fail-closed합니다. 아직 운영 HTTP provider를 활성화할 수 있다는 뜻이 아닙니다.
|
||||||
- 기존 `APP_OUTBOUND_HTTP_*`와 `app.outbound.http.*`는 canonical 설정이 아닙니다. `.env`,
|
- 기존 `APP_OUTBOUND_HTTP_*`와 `app.outbound.http.*`는 canonical 설정이 아닙니다. 루트 `src/.env`,
|
||||||
application YAML과 env-key registry에서 제거됐으며 canonical composition에 입력하면 상태와
|
`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 밖에서
|
- legacy JDK facade가 필요한 fork만 canonical composition 밖에서
|
||||||
`OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다.
|
`OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다.
|
||||||
timeout/retry/CB/response-size 설정은 그 migration API 내부 계약일 뿐 canonical provider
|
timeout/retry/CB/response-size 설정은 그 migration API 내부 계약일 뿐 canonical provider
|
||||||
|
|||||||
@@ -21,6 +21,63 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
|
|||||||
connection/admission, private keys and versioned atomic programs.
|
connection/admission, private keys and versioned atomic programs.
|
||||||
- Keep the legacy cache router isolated while consumers migrate to semantic ports.
|
- Keep the legacy cache router isolated while consumers migrate to semantic ports.
|
||||||
- Reuse `adapter:outbound:support` for shared outbound concerns.
|
- 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
|
## 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
|
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.
|
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
|
`redisTopologyTest` is the only real-server lane. It is opt-in and fail-closed in seven ways: the
|
||||||
missing; the default unit task excludes its `redis-service` tag.
|
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`.
|
||||||
|
|||||||
+306
@@ -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) {}
|
||||||
|
}
|
||||||
+203
@@ -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) {}
|
||||||
|
}
|
||||||
+280
@@ -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) {}
|
||||||
|
}
|
||||||
+337
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
@@ -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);
|
||||||
|
}
|
||||||
+397
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+943
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1406
File diff suppressed because it is too large
Load Diff
+321
@@ -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() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+244
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+176
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+437
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# adapter:outbound:httpclient — resilient HTTP client adapter
|
# adapter:outbound:httpclient — HTTP Client Platform
|
||||||
|
|
||||||
## Registered identity
|
## Registered identity
|
||||||
|
|
||||||
@@ -10,38 +10,67 @@
|
|||||||
|
|
||||||
Package root: `dev.caskeleton.adapter.outbound.httpclient`.
|
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
|
## Responsibility
|
||||||
|
|
||||||
- Own typed destination/operation catalogs, safe target construction, outbound engine construction,
|
- Own the public call surfaces: H1 typed service clients (default), H2 generic exchange, H3 dynamic
|
||||||
deadlines/cancellation, resilience, request/response bounds, egress security, diagnostics, and
|
target. H4 native engine access stays internal to `apache`, `jdk`, `reactor`, and `http3`.
|
||||||
lifecycle.
|
- 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.
|
- Adapt external HTTP calls behind application/domain ports.
|
||||||
- Reuse `adapter:outbound:support` for shared outbound concerns.
|
- 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
|
## Boundaries
|
||||||
|
|
||||||
- Allowed dependency edges come only from the module's
|
- Allowed dependency edges come only from this module's `src/config/architecture/modules.json` entry.
|
||||||
`src/config/architecture/modules.json` entry.
|
|
||||||
- No inbound controller/DTO, persistence, bootstrap, or sample dependency.
|
- No inbound controller/DTO, persistence, bootstrap, or sample dependency.
|
||||||
- Retry and circuit-breaker code is technical resilience; business compensation and use-case
|
- Retry and circuit-breaker code is technical resilience; business compensation and use-case
|
||||||
sequencing stay in application/domain layers.
|
sequencing stay in application/domain layers.
|
||||||
- Application/domain code must not import this module's generic HTTP client, operation descriptor,
|
- Application/domain code must not import this module's gateways, operation descriptors, URI types,
|
||||||
URI, Spring HTTP, JDK/Apache client, retry, or wire DTO types.
|
Spring HTTP types, engine clients, retry types, or wire DTOs.
|
||||||
- Normal calls use registered fixed destinations and relative operation routes; arbitrary absolute
|
- Normal calls use a registered profile and a profile-relative template. Absolute URLs are H3 only.
|
||||||
URL/header/credential APIs are forbidden.
|
- Composition is owned by `app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.httpclient`):
|
||||||
- The legacy JDK facade, connect/read timeout, and response-size interceptor are not evidence of an
|
profile binding, startup validation, transport registration, and the actuator endpoint live there.
|
||||||
Apache pool bound, egress security, wire hard-cancellation, or R2 readiness. Its active monotonic
|
That package is excluded from the composition root's component scan and reached only through
|
||||||
logical-call deadline is R1 evidence only.
|
`HttpClientPlatformAutoConfiguration`, which is gated on `app.httpclient.enabled=true`. While the
|
||||||
- Canonical activation is owned by `app-bootstrap`: default `DISABLED` must resolve to
|
switch is off the capability has no beans at all — not an empty registry, nothing.
|
||||||
`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.
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
Focused tests may use loopback servers and collaborator fakes. R2 promotion requires explicit
|
Default lane: `./gradlew :adapter:outbound:httpclient:test`. Additional lanes, all fail-closed:
|
||||||
real-network/TLS/pool/cancellation/security lanes and no selected lane may silently skip.
|
|
||||||
|
| 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.
|
||||||
|
|||||||
@@ -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 {
|
dependencies {
|
||||||
implementation project(':application-core')
|
implementation project(':application-core')
|
||||||
implementation project(':shared-contract')
|
implementation project(':shared-contract')
|
||||||
implementation project(':adapter:outbound:support')
|
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.boot:spring-boot-autoconfigure'
|
||||||
implementation 'org.springframework:spring-web'
|
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-retry:2.2.0'
|
||||||
implementation 'io.github.resilience4j:resilience4j-circuitbreaker: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 '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'
|
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' }
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,166 +1,251 @@
|
|||||||
# This is a Gradle generated file for dependency locking.
|
# This is a Gradle generated file for dependency locking.
|
||||||
# Manual edits can break the build and are not advised.
|
# Manual edits can break the build and are not advised.
|
||||||
# This file is expected to be part of source control.
|
# This file is expected to be part of source control.
|
||||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
|
||||||
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
|
ch.qos.logback:logback-classic:1.5.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
|
ch.qos.logback:logback-core:1.5.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
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.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.spotbugs:spotbugs:4.10.2=spotbugs
|
||||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=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,testAnnotationProcessor
|
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
com.google.auto:auto-common:1.2.2=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
|
com.google.code.findbugs:jsr305:3.0.2=checkstyle,httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,spotbugs,testCompileClasspath
|
||||||
com.google.code.gson:gson:2.13.2=spotbugs
|
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,testAnnotationProcessor
|
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
|
com.google.errorprone:error_prone_annotations:2.41.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
|
||||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
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_annotations:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,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,testAnnotationProcessor
|
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,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:guava:33.6.0-jre=checkstyle
|
||||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,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,testAnnotationProcessor
|
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
|
||||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
|
||||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
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.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-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-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-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
|
info.picocli:picocli:4.7.7=checkstyle
|
||||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=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,testAnnotationProcessor
|
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
|
||||||
io.github.resilience4j:resilience4j-bulkhead:2.2.0=runtimeClasspath,testRuntimeClasspath
|
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,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,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,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=runtimeClasspath,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,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=runtimeClasspath,testRuntimeClasspath
|
io.github.resilience4j:resilience4j-timelimiter:2.2.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||||
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
|
io.micrometer:micrometer-commons:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.micrometer:micrometer-core:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.micrometer:micrometer-observation:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
io.netty:netty-buffer:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
io.netty:netty-codec-base:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
|
io.netty:netty-codec-classes-quic:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
io.netty:netty-codec-compression:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
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
|
jaxen:jaxen:2.0.0=spotbugs
|
||||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
junit:junit:4.13.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
net.bytebuddy:byte-buddy-agent:1.17.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
net.bytebuddy:byte-buddy:1.17.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
net.minidev:json-smart:2.6.0=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
|
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
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.15.0=spotbugs
|
||||||
org.apache.commons:commons-text:1.3=checkstyle
|
org.apache.commons:commons-text:1.3=checkstyle
|
||||||
org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
|
org.apache.httpcomponents.client5:httpclient5:5.5.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.apache.groovy:groovy:5.0.2=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:httpclient:4.5.13=checkstyle
|
||||||
org.apache.httpcomponents:httpcore:4.4.16=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-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-core:1.12.0=checkstyle
|
||||||
org.apache.maven.doxia:doxia-logging-api: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-module-xdoc:1.12.0=checkstyle
|
||||||
org.apache.maven.doxia:doxia-sink-api: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-core:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=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=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.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
org.apiguardian:apiguardian-api:1.1.2=compileClasspath,httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
|
||||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
org.assertj:assertj-core:3.27.6=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.awaitility:awaitility:4.3.0=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-classworlds:2.6.0=checkstyle
|
||||||
org.codehaus.plexus:plexus-component-annotations:2.1.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-container-default:2.1.0=checkstyle
|
||||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||||
org.dom4j:dom4j:2.2.0=spotbugs
|
org.dom4j:dom4j:2.2.0=spotbugs
|
||||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
org.eclipse.jetty.compression:jetty-compression-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,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.javassist:javassist:3.28.0-GA=checkstyle
|
||||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
org.jetbrains:annotations:17.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,httpClientPerformanceTestAnnotationProcessor,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath
|
org.junit.jupiter:junit-jupiter-api:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
org.junit.jupiter:junit-jupiter-engine:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath
|
||||||
org.junit:junit-bom:6.0.1=testCompileClasspath,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.junit:junit-bom:6.1.0=spotbugs
|
||||||
org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath
|
org.latencyutils:LatencyUtils:2.0.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
|
||||||
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
|
org.mockito:mockito-core:5.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,mockitoAgent,testCompileClasspath,testRuntimeClasspath
|
||||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
org.mockito:mockito-junit-jupiter:5.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
org.objenesis:objenesis:3.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath
|
||||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
org.openjdk.jmh:jmh-core:1.37=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath
|
||||||
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
|
org.openjdk.jmh:jmh-generator-annprocess:1.37=jmhAnnotationProcessor
|
||||||
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
|
org.opentest4j:opentest4j:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
|
org.osgi:org.osgi.annotation.bundle:2.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
|
||||||
org.osgi:org.osgi.service.serviceloader:1.0.0=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-analysis:9.10.1=spotbugs
|
||||||
org.ow2.asm:asm-commons: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-tree:9.10.1=spotbugs
|
||||||
org.ow2.asm:asm-util: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.10.1=spotbugs
|
||||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
org.ow2.asm:asm:9.7.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
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.reflections:reflections:0.10.2=checkstyle
|
||||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
org.rnorth.duct-tape:duct-tape:1.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
|
org.skyscreamer:jsonassert:1.5.3=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,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.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||||
org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-http-client:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-http-converter:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-jackson:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-restclient:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-resttestclient:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-servlet:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-jackson:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-logging:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=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-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=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-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-starter:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-tomcat:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-web-server:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-webmvc-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot-webmvc:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.boot:spring-boot:4.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
org.springframework.security:spring-security-core:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework.security:spring-security-web:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-aop:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-beans:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-context:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
org.springframework:spring-core:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.springframework:spring-webmvc:7.0.1=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.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
org.xmlunit:xmlunit-core:2.10.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
|
org.yaml:snakeyaml:2.5=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
tools.jackson.core:jackson-core:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
tools.jackson.core:jackson-databind:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
tools.jackson:jackson-bom:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||||
empty=
|
empty=
|
||||||
|
|||||||
+104
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+152
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+148
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+228
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+215
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+121
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+190
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+120
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+291
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+187
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
@@ -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};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+269
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+169
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+304
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+206
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+176
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+211
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+272
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -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);
|
||||||
|
}
|
||||||
+28
@@ -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);
|
||||||
|
}
|
||||||
+90
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+330
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+183
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
@@ -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
Reference in New Issue
Block a user