feat(httpclient): close the platform review's P0/P1/P2 findings

The review found one defect shape repeated across the platform: surfaces
that were declared, bound, and documented, but that nothing read. An
operator configuring fullUrlRecording, bodyLogging, retry.policy,
validatedDnsPinning, timeout.dns, or any of ten declared metric names got a
guarantee the code never delivered. Every such surface is now in exactly one
of three states -- wired for real, rejected at startup, or registered in a
test-enforced gap list with its reason. No silent no-ops remain.

P0:
- Activate the platform from bootstrap behind app.httpclient.enabled, with a
  single auto-configuration importing the nine child configurations.
- Give the platform a strict, repository-level ENV contract: 74 leaf fields
  derived from the settings record tree, unknown APP_HTTPCLIENT_* rejected.
- Route typed HTTP service clients through the call kernel via
  KernelHttpExchangeAdapter, so they stop bypassing platform policy.
- Pin dynamic-target DNS resolution to the socket for the life of a call,
  closing the resolve-then-connect TOCTOU / rebinding window.
- Actually transmit the idempotency key, and make retry eligibility depend on
  transmission rather than on merely holding one.
- Reject reactive authentication and reactive redirect at startup instead of
  declaring support that does not function.
- Fix the Reactor-only Stable contract row so the lane stops failing.
- Stop advertising HTTP/3 on a transport that negotiates HTTP/1.

P1 covers execution and retry accounting, redirect security (per-hop target
guarding, sensitive-header stripping, 303 body handling), runtime rotation
and transport resource ownership keyed by generation, dynamic-target
hardening (subdomain matching, global-unicast classification, strict CIDR
parsing), protocol intent, pool and timeout wiring, streaming and body
limits, observability parity, and OAuth single-flight refresh on a bounded
pool with a bounded wait.

P2 covers configuration and documentation drift, the Gradle check wiring for
the four hermetic lanes, and the CI gate matrix.

Two test-quality defects surfaced while closing these: the HTTP/2 stream
saturation test ran against cleartext HTTP/1.1 while asserting nothing about
the protocol, and an OAuth contention test slept on a latch that could fire
before the callers it meant to observe. Both now assert what their names
claim.

Verification run: :adapter:outbound:httpclient:check and :app-bootstrap:check
(checkstyle, spotless, spotbugs, and the four hermetic lanes),
verifyCleanArchitectureDependencies, verifyEnvKeys, verifyOneTypePerFile,
verifyDependencyLocks, the documentation and gate-matrix verifiers, and the
performance lane against a real TLS+ALPN HTTP/2 server.

Not executed, and tracked rather than claimed: Docker/Toxiproxy fault
injection, JMH, a real QUIC/HTTP3 server, a real Spring Framework 6.2
distribution (now a delegated-pending gate), live OAuth/TLS/proxy/DNS
integration, and a whole-repository check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-11 16:49:31 +09:00
co-authored by Claude Opus 5
parent 5f10b791d3
commit 0cd959a494
148 changed files with 26812 additions and 2368 deletions
+84 -39
View File
@@ -15,16 +15,13 @@ APP_MIGRATION_ON_STARTUP=true
APP_RATE_LIMIT_ENABLED=false
APP_RATE_LIMIT_CLIENT_IP_MODE=remote-addr-only
APP_RATE_LIMIT_PROVIDER=disabled
APP_RATE_LIMIT_REDIS_KEY_HMAC_SECRET=
APP_IDEMPOTENCY_TTL=24h
APP_IDEMPOTENCY_PROVIDER=jdbc
APP_IDEMPOTENCY_REDIS_KEY_HMAC_SECRET=
APP_IDEMPOTENCY_REDIS_NAMESPACE_ENVIRONMENT=local
APP_IDEMPOTENCY_PROCESSING_LEASE=30s
APP_IDEMPOTENCY_FAILURE_RETENTION=24h
APP_LEASE_PROVIDER=disabled
APP_LEASE_REDIS_KEY_HMAC_SECRET=
APP_LEASE_REDIS_NAMESPACE_ENVIRONMENT=local
APP_LEASE_REDIS_DRIFT_BUDGET=10ms
# ----- Async executor -----
@@ -34,33 +31,10 @@ APP_ASYNC_EXECUTOR_QUEUE_CAPACITY=200
# ----- Optional integration adapters (default: all disabled) -----
APP_CACHE_CANONICAL_DEFAULT_PROVIDER=disabled
# Sentinel primary revalidation cadence for canonically active Sentinel roles.
APP_REDIS_SENTINEL_DISCOVERY_REFRESH_PERIOD=30s
# Canonical role semantic readiness: refresh no more often than this interval.
APP_REDIS_SEMANTIC_PROBE_MINIMUM_INTERVAL=5s
# Fail closed when the last completed semantic observation is older than this bound.
APP_REDIS_SEMANTIC_PROBE_MAXIMUM_STALENESS=15s
APP_CACHE_REDIS_ENABLED=false
APP_CACHE_REDIS_CLIENT_MODE=managed
APP_CACHE_REDIS_HOST=localhost
APP_CACHE_REDIS_PORT=6379
APP_CACHE_REDIS_PASSWORD=
APP_CACHE_REDIS_KEY_HMAC_SECRET=
APP_CACHE_REDIS_COMMAND_TIMEOUT=2s
APP_CACHE_REDIS_MAXIMUM_QUEUED_COMMANDS=8
APP_CACHE_REDIS_MAXIMUM_IN_FLIGHT_BYTES=16777216
APP_CACHE_REDIS_NAMESPACE_ENVIRONMENT=local
APP_CACHE_REDIS_SEMANTIC_REGION=default
APP_CACHE_REDIS_MAXIMUM_VALUE_BYTES=1048576
APP_CACHE_REDIS_L1_ENABLED=false
APP_CACHE_REDIS_L1_MAXIMUM_ENTRIES=10000
APP_CACHE_REDIS_L1_MAXIMUM_WEIGHT_BYTES=67108864
APP_CACHE_REDIS_L1_MAXIMUM_ENTRY_WEIGHT_BYTES=1048576
APP_CACHE_REDIS_L1_TTL=30s
APP_CACHE_REDIS_L1_GENERATION_RECHECK_INTERVAL=5s
APP_CACHE_REDIS_L1_INVALIDATION_QUEUE_CAPACITY=1024
APP_CACHE_DEFAULT_TTL=300s
APP_CACHE_NEGATIVE_TTL=60s
# The single global Redis switch. False means no Redis settings, secrets, client, threads or
# health contributor exist. Role selectors (cache/session/idempotency/lease/rate-limit) choose
# which capabilities compose once Redis is on; none of them turns Redis on.
APP_REDIS_ENABLED=false
APP_MESSAGING_BROKER=
APP_MESSAGING_KAFKA_BROKERS=
APP_NOTIFICATION_SLACK_PROVIDER=
@@ -149,15 +123,6 @@ APP_SESSION_COOKIE_SAME_SITE=Lax
APP_SESSION_COOKIE_PATH=/
APP_SESSION_CSRF_COOKIE_NAME=XSRF-TOKEN
APP_SESSION_CSRF_HEADER_NAME=X-XSRF-TOKEN
APP_SESSION_REDIS_KEY_HMAC_SECRET=
APP_SESSION_REDIS_NAMESPACE_ENVIRONMENT=local
APP_SESSION_IDLE_TIMEOUT=30m
APP_SESSION_ABSOLUTE_LIFETIME=8h
APP_SESSION_TOUCH_INTERVAL=1m
APP_SESSION_TOMBSTONE_TTL=5m
APP_SESSION_MAXIMUM_ENVELOPE_BYTES=32768
APP_SESSION_MAXIMUM_ATTRIBUTES=64
APP_SESSION_MAXIMUM_SCALAR_BYTES=8192
# ----- CORS -----
APP_SECURITY_CORS_ENABLED=true
@@ -186,3 +151,83 @@ APP_DATASOURCE_POOL_MAX_LIFETIME=1800000
# ----- Management / Actuator -----
MANAGEMENT_SERVER_PORT=9001
# ----- Fileserver HTTP platform (app.fileserver-platform.*) -----
# Off by default. While false nothing below is bound: the platform auto-configuration binds this
# block itself and is not processed until the master switch is true.
APP_FILESERVER_PLATFORM_ENABLED=false
APP_FILESERVER_PLATFORM_INSTANCE_ID=local-node
APP_FILESERVER_PLATFORM_DEFAULT_NAMESPACE=default
# Storage root must be an absolute path on its own volume, never under a web or config root.
APP_FILESERVER_PLATFORM_STORAGE_ROOT=/var/lib/backend/files
APP_FILESERVER_PLATFORM_STORAGE_PUBLISH_MODE=atomic-move-preferred
APP_FILESERVER_PLATFORM_STORAGE_BUFFER_SIZE=128KB
APP_FILESERVER_PLATFORM_STORAGE_FORBIDDEN_ROOT_ANCESTORS=/app,/etc,/usr/share/nginx/html
# Shared with spring.servlet.multipart.* so the container and the policy cannot disagree.
APP_FILESERVER_PLATFORM_UPLOAD_MAX_FILE_SIZE=100MB
APP_FILESERVER_PLATFORM_UPLOAD_MAX_REQUEST_SIZE=110MB
APP_FILESERVER_PLATFORM_UPLOAD_INITIAL_RESERVATION=8MB
APP_FILESERVER_PLATFORM_UPLOAD_MAX_PARTS=16
APP_FILESERVER_PLATFORM_UPLOAD_TTL=1h
APP_FILESERVER_PLATFORM_UPLOAD_RESERVATION_TTL=24h
APP_FILESERVER_PLATFORM_UPLOAD_LEASE_DURATION=30s
APP_FILESERVER_PLATFORM_UPLOAD_REQUIRE_CONTENT_LENGTH=false
APP_FILESERVER_PLATFORM_DOWNLOAD_CACHE_CONTROL=private, no-store
APP_FILESERVER_PLATFORM_DOWNLOAD_INLINE_ALLOWED=false
APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGES=1
APP_FILESERVER_PLATFORM_DOWNLOAD_MAX_RANGE_BYTES=100MB
APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_ENABLED=true
APP_FILESERVER_PLATFORM_DOWNLOAD_ZERO_COPY_MINIMUM_BYTES=16MB
APP_FILESERVER_PLATFORM_TRANSFER_CORE_SIZE=8
APP_FILESERVER_PLATFORM_TRANSFER_MAX_SIZE=32
APP_FILESERVER_PLATFORM_TRANSFER_QUEUE_CAPACITY=64
APP_FILESERVER_PLATFORM_TRANSFER_AWAIT_SECONDS=300
# required | role-based | unenforced (unenforced is refused under a production profile).
APP_FILESERVER_PLATFORM_SECURITY_ACCESS_POLICY=required
APP_FILESERVER_PLATFORM_SECURITY_READ_ROLES=ROLE_FILE_READ
APP_FILESERVER_PLATFORM_SECURITY_WRITE_ROLES=ROLE_FILE_WRITE
APP_FILESERVER_PLATFORM_SECURITY_ADMIN_ROLES=ROLE_FILE_ADMIN
APP_FILESERVER_PLATFORM_VERIFICATION_TIMEOUT=5s
APP_FILESERVER_PLATFORM_VERIFICATION_REQUIRE_MEDIA_TYPE_VERDICT=false
APP_FILESERVER_PLATFORM_VERIFICATION_INLINE_SAFE_PROFILE=false
APP_FILESERVER_PLATFORM_QUOTA_INSTANCE_UPLOAD_PERMITS=16
APP_FILESERVER_PLATFORM_QUOTA_SCOPE_UPLOAD_PERMITS=4
APP_FILESERVER_PLATFORM_QUOTA_DIRECT_DOWNLOAD_PERMITS=64
APP_FILESERVER_PLATFORM_QUOTA_SOFT_HIGH_WATER=0.70
APP_FILESERVER_PLATFORM_QUOTA_HARD_HIGH_WATER=0.85
APP_FILESERVER_PLATFORM_ADMIN_ENABLED=false
APP_FILESERVER_PLATFORM_ADMIN_ORPHAN_MINIMUM_AGE=1h
APP_FILESERVER_PLATFORM_CLEANUP_ENABLED=false
APP_FILESERVER_PLATFORM_CLEANUP_INTERVAL=60s
APP_FILESERVER_PLATFORM_CLEANUP_MAX_ITEMS=100
APP_FILESERVER_PLATFORM_CLEANUP_MAX_BYTES=1GB
APP_FILESERVER_PLATFORM_CLEANUP_RETRY_BACKOFF=5m
APP_FILESERVER_PLATFORM_TUS_ENABLED=false
APP_FILESERVER_PLATFORM_HTTPBIS_DRAFT12_ENABLED=false
APP_FILESERVER_PLATFORM_NGINX_ENABLED=false
APP_FILESERVER_PLATFORM_NGINX_INTERNAL_PREFIX=/__files/
APP_FILESERVER_PLATFORM_NGINX_OBJECT_SUFFIX=.bin
APP_FILESERVER_PLATFORM_NGINX_MINIMUM_SIZE=16MB
APP_FILESERVER_PLATFORM_OBSERVABILITY_METRICS_ENABLED=true
# Secret. Required while metrics are enabled; an unkeyed digest of an enumerable id is reversible.
APP_FILESERVER_PLATFORM_OBSERVABILITY_FINGERPRINT_KEY=
# ----- HTTP Client platform (app.httpclient.*) -----
# The single switch for outbound HTTP. False means no HTTP client property is bound, and no
# transport provider, connection pool, TLS context, credential, thread or gateway is created.
# The per-client surface is indexed and per-deployment, so it is set directly in the environment
# rather than declared here; docs/httpclient/env-fields.yaml is its registry, and an
# APP_HTTPCLIENT_ variable that is not in that registry fails startup.
APP_HTTPCLIENT_ENABLED=false
+31 -8
View File
@@ -17,6 +17,7 @@
| 게이트 | 하는 일 |
| --- | --- |
| `verifyCleanArchitectureDependencies` | 모듈 간 의존 방향이 허용된 범위 안에 있는지 검사 |
| `verifyRuntimeModuleMembership` | registry의 두 composition root membership과 실제 main project dependency가 정확히 일치하는지 검사 |
| `verifyEnvKeys` | `env-keys.yaml``application.yml``src/.env` 가 어긋나지 않는지 검사 |
| `verifyOneTypePerFile` | 파일당 public 최상위 타입 1개, 파일명 == 타입명인지 검사 |
| `verifyTrivyignore` | `.trivyignore.yaml` 의 Trivy suppression 이 사유·만료일을 갖추고 만료/기한초과가 아닌지 검사 |
@@ -84,6 +85,24 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
registry와 ArchUnit 규칙(`CleanArchitectureTest`)을 함께 갱신해야 합니다. settings와 gate는
같은 registry를 읽고, 등록되지 않은 leaf나 허용되지 않은 edge를 fail-closed로 거부합니다.
### `verifyRuntimeModuleMembership`
- **하는 일.** 같은 registry의 `runtime_compositions`와 각 leaf의 `runtime_memberships`를 읽어
`app-bootstrap`/`sample-portfolio`의 실제 `api`/`implementation`/`compileOnly`/`runtimeOnly`
project dependency와 정확히 대조합니다.
- **opt-in의 의미.** membership이 빈 GraphQL/gRPC/WebSocket/Mongo leaf는 독립 빌드 대상이지만 두
shipped runtime에는 없습니다. app-bootstrap의 `conditionalTransportTest` test-only classpath는
실제 채택 전에 세 inbound transport를 함께 qualification하기 위한 evidence composition입니다.
- **변경 규칙.** production edge를 추가하거나 제거할 때 `allowed_dependencies`,
`runtime_memberships`, 실제 Gradle dependency를 같은 변경에서 갱신하지 않으면 `check`가 실패합니다.
세 opt-in inbound transport의 test-only composition, 실제 wire 경계, positive-count/zero-skip 증거는
다음 release-blocking aggregate로 실행합니다.
```bash
./gradlew conditionalTransportQualification
```
### `verifyOneTypePerFile` (code-conventions I6)
- **하는 일.** `src/main/java` 의 모든 `.java` 파일이 public 최상위 타입을 1개만 갖고, 그 타입 이름이
@@ -119,9 +138,9 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
경로를 **제외한** 모든 요청은 인증을 요구합니다(`src/.env``SecuritySettings.publicPaths()`
`SecurityConfig`). 이 public 표면이 바뀌는 순간이 곧 보호되던 엔드포인트가 조용히 공개로 노출되는
지점입니다. 그래서 그 표면을 snapshot 으로 떠 두고, 미승인 변경에 빌드를 실패시킵니다.
- **승인 방법.** reviewer 가 `./gradlew verifyPublicPathSnapshot -PapprovePublicPathChange`
snapshot 을 의도적으로 다시 생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 수동 승인 후
재생성된 snapshot 을 함께 커밋합니다.
- **승인 방법.** `verifyPublicPathSnapshot` 은 항상 읽기 전용입니다. reviewer 가 변경을 승인한 뒤
`./gradlew updatePublicPathSnapshot -PapprovePublicPathChange` snapshot 을 명시적으로 다시
생성합니다. 공개 경로 변경은 보안 리뷰 대상으로 보고 재생성된 snapshot 을 함께 커밋합니다.
- **결정 — 무엇을 snapshot 했나 (프로젝트 선택).** 초기안은 기동 시
`SecurityFilterChain.getFilters()` 를 introspection 하는 방식이었습니다. 하지만 그 reflection
은 Spring 버전마다 깨지기 쉽습니다(`permitAll` matcher 가
@@ -130,8 +149,8 @@ vendor/build나 container base image까지 byte-for-byte 같음을 주장하지
변경은 무조건 게이트를 실패시킨다)는 같고, 메커니즘은 더 견고합니다.
- **snapshot 위치.** `docs/security/public-paths-snapshot.txt`. 이 파일은 커밋된 필수 보안
baseline 입니다. CI 는 Gradle 실행 전에 파일이 비어 있지 않고 Git에 추적되는지 검사하므로 fresh
checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 위 명령으로 재생성한
보안 리뷰와 함께 커밋합니다.
checkout 에서 누락되거나 untracked 상태면 즉시 실패합니다. 승인된 변경만 update task로 재생성한
보안 리뷰와 함께 커밋합니다.
### `verifyTrivyignore`
@@ -260,9 +279,13 @@ ca-skeleton:
- `ACTIVE`는 exact destination/provider/operation-catalog binding을 요구합니다. 현재 유일한
buffered-classic readiness card가 `NOT_IMPLEMENTED`이므로 provider resource 생성 전에
fail-closed합니다. 아직 운영 HTTP provider를 활성화할 수 있다는 뜻이 아닙니다.
- 기존 `APP_OUTBOUND_HTTP_*``app.outbound.http.*`는 canonical 설정이 아닙니다. `.env`,
application YAML env-key registry에서 제거됐으며 canonical composition에 입력하면 상태와
무관하게 기동을 거부합니다.
- 기존 `APP_OUTBOUND_HTTP_*``app.outbound.http.*`는 canonical 설정이 아닙니다. 루트 `src/.env`,
`app-bootstrap` application YAML, env-key registry에서 제거됐으며 canonical composition에
입력하면 상태와 무관하게 기동을 거부합니다.
- 다만 `sample-portfolio`의 application YAML에는 legacy facade를 시연하기 위해 15개 키가 남아
있습니다. 이 모듈은 fixture/reference consumer이고 production 의존성이 아니며, 그 YAML은
`verifyEnvKeys`가 검사하는 세 파일에 포함되지 않습니다. "제거됐다"는 문장이 저장소 전체를
가리킨다고 읽히지 않도록 범위를 명시합니다.
- legacy JDK facade가 필요한 fork만 canonical composition 밖에서
`OutboundHttpSettings.bindLegacy(Binder)`와 legacy configuration을 명시적으로 import합니다.
timeout/retry/CB/response-size 설정은 그 migration API 내부 계약일 뿐 canonical provider
+97 -2
View File
@@ -21,6 +21,63 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
connection/admission, private keys and versioned atomic programs.
- Keep the legacy cache router isolated while consumers migrate to semantic ports.
- Reuse `adapter:outbound:support` for shared outbound concerns.
- Host the general-purpose Redis SDK under `…cache.redis.sdk` (see below). The SDK is a separate
concern from the semantic cache ports and must not be reached from `application-core`.
## Redis SDK (`…cache.redis.sdk`)
The Redis wrapper and typed API described in
`docs/superpowers/specs/2026-08-07-redis-wrapper-typed-api-design.md` lives inside this leaf. Its
design models the SDK as twelve Gradle modules; this repository's 19-leaf fail-closed registry
outranks that layout, so each designed module is a package instead. Delivery status and the full
adaptation rationale are in
`docs/superpowers/plans/2026-08-07-redis-wrapper-typed-api-status.md`.
- `sdk.api..` is the public contract. It must never import Spring, Lettuce, Micrometer, or any SDK
implementation package; Reactor is confined to `sdk.api.reactive`.
- `sdk.lettuce..` implements the contract; `sdk.config` owns properties, the capability probe, and
the permit authority.
- `src/main/resources/redis-sdk/redis-command-policy.yml` is the command policy SSOT. A command that
is not classified there is refused, so adding a command means editing that file, not the code.
- `sdk.lettuce.operations` implements the typed operations. Everything there goes through
`RedisCommandGateway`, the only seam that reaches Lettuce, and every call is admitted by
`CommandPolicyGuard` before it runs. Never call the driver from an operation directly.
- `sdk.cluster` owns client-side slot arithmetic and cluster observation. It depends on `sdk.api`
only and must never import Lettuce: the slot is computed before a command is built, which is what
lets `CommandPolicyGuard` refuse a cross-slot request instead of learning about it from a server
redirect.
- `sdk.programmability` owns transactions, registered Lua scripts, and deployed function calls. A
script body is never accepted at call time: `EVAL` is blocked in the catalog and only `EVALSHA` of
a `RedisScriptRegistry` digest is reachable. `FUNCTION LOAD` is admin-plane, never application.
- Transactions are `WATCH`/`MULTI`/`EXEC` and **never** roll back. `TransactionResult` reports only
"executed" or "a watched key changed, so nothing ran", and no type in this package offers a word
that suggests otherwise. A queued command returns a `QueuedReply` that throws when read before the
commit, because inside the window the server has answered `+QUEUED` and nothing else. Queued
commands pass the same `CommandPolicyGuard` admission as ordinary ones — a transaction is not a
way around the guard — and the window is closed on every exit path, including a callback that
threw, because a connection abandoned in `MULTI` state silently queues the next caller's command.
The queue is write-only on purpose: a read inside the window cannot be branched on, so the reads a
transaction depends on belong before it, under `WATCH`.
- `sdk.raw` is the approved raw command gateway. A command is reachable only when the catalog marks
it `RAW_ONLY` *and* the deployment registered an `ApprovedRawCommand` for it; keys are parsed back
out of the arguments and namespace-checked before anything is sent. Never add a method here that
takes a command name as a string.
- `sdk.admin` is the read-only diagnostic plane. It takes its own gateway (admin ACL account, own
connection), refuses any command the catalog does not classify `ADMIN_ONLY` and read-only, and
projects replies so a slow log or client listing never carries arguments, peer addresses, or
connection names.
- `sdk.extensions.*` holds the Redis 8 modules (JSON, Search, Time Series, Probabilistic). Every
bean is created through `ifSupported(...)` — the capability probe decides, the catalog minimum is
only a pre-filter — and all of them build commands through `ExtensionCommandRunner` so the guard
sees their keys. Search is the exception that proves it: an index is not a key, so its name is
namespaced by `LettuceRedisSearchOperations` itself.
- `RedisSdkModuleBoundaryTest` enforces the package graph, driver containment, the absence of any
arbitrary string command surface, and the list of designed-but-unimplemented modules. Update
`NOT_YET_IMPLEMENTED_MODULES` when a module lands.
- Decisions that must not be changed without revisiting the design: no unbounded `entries`,
`members`, `rangeAll`, or `keys`; no optional R2 permit or budget; no arbitrary command string
overload; no automatic retry of a non-idempotent write after a timeout; no real key in a metric or
trace tag.
## Boundaries
@@ -44,5 +101,43 @@ Package root: `dev.caskeleton.adapter.outbound.cache`.
Focused tests use fakes for contract, key, catalog, and typed-facade behavior. R1/R2 promotion
requires a separate real Redis service lane; it may never be silently skipped when selected.
`redisServiceTest` is the explicit standalone lane. It fails when its host/port properties are
missing; the default unit task excludes its `redis-service` tag.
`redisTopologyTest` is the only real-server lane. It is opt-in and fail-closed in seven ways: the
lane must be one of `standalone`, `sentinel`, `cluster`, `tls`; the endpoint properties must be
present (`sentinel` additionally needs `redis.topology.master`, `tls` needs
`redis.topology.trust-material`); a test class carrying the lane's tag must exist; a run that
executes zero tests fails; the classes the lane exists to run must actually have run; the executed
count must reach the lane's declared floor; and a skipped test fails the run rather than counting
as executed. `tls` is a lane, not a deployment mode — its shape is standalone and the task maps it
so, because what it qualifies is the transport.
## Composition
`RedisSdkAutoConfiguration` is the only place Redis settings and Redis runtime come into existence,
and it exists only while `app.redis.enabled` (env `APP_REDIS_ENABLED`) is true. It builds the
client, the connection owner and the health contributors; `RedisCapabilityConfig` in `app-bootstrap`
composes the semantic ports on top, one per role selector. "Redis is on" therefore means the
capabilities that need Redis exist, not merely that Redis is reachable.
Every capability renders its keys under the one namespace from `app.redis.namespace`
`{environment}:{service}:{domain}` — through `CapabilityKeyspace`. Never give a capability its own
prefix tokens: four capabilities each joining two free-form strings produced four different key
shapes, and the deployment's ACL pattern matched none of them.
Lanes authenticate as different accounts. `RedisConnectionKind.credentialRole()` maps the lane to a
`RedisCredentialRole`, and the topology factory builds one client per *configured* role — so a
single-account deployment still gets exactly one client and one event loop. The `SCRIPT` lane is
the reason it exists: `SCRIPT LOAD` and `EVALSHA` belong to the advanced account, so the account
that reads a cache entry cannot execute a script.
A Cluster transaction runs on one node, so `RedisTransactionRunner` derives a routing key from the
watched keys (or an explicit `RedisSlotTag`) and pins the `TRANSACTION` lane to the node that owns
that slot. Routed leases are never pooled: a pooled connection is pinned to the previous caller's
node.
`RedisSdkSettings`
must never carry a class-level `@ConfigurationProperties`: the bootstrap's application-wide
`@ConfigurationPropertiesScan` would then register it in every deployment, so a service that runs
no Redis would bind Redis configuration. `RedisOptionalityContractTest` in `app-bootstrap` enforces
that. Role selectors (cache binding, session auth-mode, idempotency/lease/rate-limit provider)
choose which capabilities compose; none of them activates Redis, and selecting one while the global
switch is off is refused by `RedisActivationValidator`.
@@ -0,0 +1,306 @@
package dev.caskeleton.adapter.outbound.cache.redis.idempotency;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
/**
* The idempotency record's state machine, one atomic program per transition.
*
* <p>Each program re-reads the record, checks the owner <em>and</em> the state revision, and only
* then mutates. Both halves are necessary. The owner alone would let a holder whose lease expired —
* and whose claim was taken over — write over the new holder's work. The revision alone would let a
* different owner at the same revision do it. Together they are an optimistic compare-and-set, and
* every successful transition bumps the revision so a stale handle can never be reused.
*
* <p>The record is a hash, not a string, because the transitions touch different fields and a
* read-modify-write of a serialized blob would reintroduce exactly the race the programs remove.
*
* <p>Nothing here interprets the stored response. It is an opaque payload the application encoded;
* this adapter stores and returns bytes, so a codec change is a concern of whoever wrote them.
*/
public final class IdempotencyScripts {
/**
* Claim: create, replay, take over an expired lease, or report why not.
*
* <p>The fingerprint is compared before anything else. Two different requests that hash to the
* same idempotency scope are a client error, and treating the second as a replay of the first
* would return somebody else's response.
*/
private static final String CLAIM =
"""
local state = redis.call('HGET', KEYS[1], 'state')
local nowMillis = tonumber(ARGV[6])
if state == false then
redis.call('HSET', KEYS[1],
'state', 'CLAIMED', 'owner', ARGV[1], 'attempt', 1, 'rev', 1,
'op', ARGV[2], 'fp', ARGV[3], 'codec', ARGV[4], 'policy', ARGV[5],
'leaseUntil', nowMillis + tonumber(ARGV[7]))
redis.call('PEXPIRE', KEYS[1], ARGV[8])
return {'ACQUIRED', 1, 1, ARGV[1], '', tostring(nowMillis + tonumber(ARGV[7]))}
end
local fingerprint = redis.call('HGET', KEYS[1], 'fp')
if fingerprint ~= ARGV[3] then
return {'FINGERPRINT_MISMATCH', 0, 0, '', '', ''}
end
local owner = redis.call('HGET', KEYS[1], 'owner')
local op = redis.call('HGET', KEYS[1], 'op')
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
if state == 'COMPLETED' then
return {'COMPLETED_REPLAY', attempt, rev, owner, redis.call('HGET', KEYS[1], 'resp'),
tostring(redis.call('PTTL', KEYS[1]))}
end
if state == 'ABANDONED' then
return {'RECOVERY_REQUIRED', attempt, rev, owner, '', ''}
end
if owner == ARGV[1] then
if op ~= ARGV[2] then
return {'OWNER_OPERATION_CONFLICT', attempt, rev, owner, '', ''}
end
-- Same owner, same operation: a retry whose first reply was lost.
return {'REPLAYED_ACQUIRE', attempt, rev, owner, '',
redis.call('HGET', KEYS[1], 'leaseUntil')}
end
local leaseUntil = tonumber(redis.call('HGET', KEYS[1], 'leaseUntil'))
if state == 'FAILED_RETRYABLE' or (leaseUntil ~= nil and leaseUntil <= nowMillis) then
-- The previous holder's processing lease expired, or they marked the attempt retryable.
-- Taking over bumps the attempt so the new holder can tell it is not the first.
redis.call('HSET', KEYS[1],
'state', 'CLAIMED', 'owner', ARGV[1], 'attempt', attempt + 1, 'rev', rev + 1,
'op', ARGV[2], 'leaseUntil', nowMillis + tonumber(ARGV[7]))
redis.call('PEXPIRE', KEYS[1], ARGV[8])
return {'TAKEN_OVER', attempt + 1, rev + 1, ARGV[1], '',
tostring(nowMillis + tonumber(ARGV[7]))}
end
return {'IN_PROGRESS', attempt, rev, owner, '', tostring(leaseUntil - nowMillis)}
""";
/** A generic owner+revision compare-and-set transition. */
private static final String TRANSITION =
"""
local state = redis.call('HGET', KEYS[1], 'state')
if state == false then
return {'ABSENT', 0, 0, '', '', ''}
end
local owner = redis.call('HGET', KEYS[1], 'owner')
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
local op = redis.call('HGET', KEYS[1], 'op')
if owner ~= ARGV[1] then
return {'NOT_OWNER', attempt, rev, owner, '', ''}
end
if op ~= ARGV[3] then
return {'OPERATION_CONFLICT', attempt, rev, owner, '', ''}
end
if state == ARGV[5] then
-- Already in the target state, under the same owner and the same operation: a retry whose
-- first reply was lost, not a second transition. This is checked BEFORE the revision,
-- deliberately. The caller's handle necessarily carries the pre-transition revision — they
-- never received the reply that would have replaced it — so a revision check first would
-- turn every lost reply into NOT_OWNER and make the idempotent call non-idempotent. The
-- owner and operation already prove the record was moved by this caller and nobody else.
return {'ALREADY', attempt, rev, owner, redis.call('HGET', KEYS[1], 'resp') or '', ''}
end
if rev ~= tonumber(ARGV[2]) then
-- Stale handle: somebody moved the record on after this owner read it, and the target
-- state is not where they left it.
return {'NOT_OWNER', attempt, rev, owner, '', ''}
end
if state ~= ARGV[4] then
return {'WRONG_STATE', attempt, rev, owner, state, ''}
end
redis.call('HSET', KEYS[1], 'state', ARGV[5], 'rev', rev + 1)
if ARGV[6] ~= '' then
redis.call('HSET', KEYS[1], 'resp', ARGV[6])
end
if ARGV[7] ~= '' then
redis.call('HSET', KEYS[1], 'leaseUntil', ARGV[7])
end
if ARGV[8] ~= '' then
redis.call('PEXPIRE', KEYS[1], ARGV[8])
end
return {'APPLIED', attempt, rev + 1, owner, '', ''}
""";
/** Release before execution: only from CLAIMED, and only by the owner that holds it. */
private static final String RELEASE =
"""
local state = redis.call('HGET', KEYS[1], 'state')
if state == false then
return {'ABSENT', 0, 0, '', '', ''}
end
local owner = redis.call('HGET', KEYS[1], 'owner')
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
local op = redis.call('HGET', KEYS[1], 'op')
if owner ~= ARGV[1] then
return {'NOT_OWNER', attempt, rev, owner, '', ''}
end
if op ~= ARGV[3] then
return {'OPERATION_CONFLICT', attempt, rev, owner, '', ''}
end
if state ~= 'CLAIMED' then
return {'WRONG_STATE', attempt, rev, owner, state, ''}
end
redis.call('DEL', KEYS[1])
return {'APPLIED', attempt, rev, owner, '', ''}
""";
/** Inspect: read the record without touching it. */
private static final String INSPECT =
"""
local state = redis.call('HGET', KEYS[1], 'state')
if state == false then
return {'ABSENT', 0, 0, '', '', ''}
end
local fingerprint = redis.call('HGET', KEYS[1], 'fp')
if fingerprint ~= ARGV[2] then
return {'FINGERPRINT_MISMATCH', 0, 0, '', '', ''}
end
local owner = redis.call('HGET', KEYS[1], 'owner')
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
local op = redis.call('HGET', KEYS[1], 'op')
local mine = 'OTHER'
if owner == ARGV[1] then
if op == ARGV[3] then
mine = 'MINE'
else
mine = 'OPERATION_CONFLICT'
end
end
return {state, attempt, rev, owner,
redis.call('HGET', KEYS[1], 'resp') or '',
mine .. '|' .. tostring(redis.call('PTTL', KEYS[1]))}
""";
private final Map<String, AtomicReference<String>> digests = new LinkedHashMap<>();
CompletionStage<Reply> claim(RedisCommandGateway gateway, byte[] key, List<String> arguments) {
return run(gateway, "claim", CLAIM, key, arguments);
}
CompletionStage<Reply> transition(
RedisCommandGateway gateway, byte[] key, List<String> arguments) {
return run(gateway, "transition", TRANSITION, key, arguments);
}
CompletionStage<Reply> release(RedisCommandGateway gateway, byte[] key, List<String> arguments) {
return run(gateway, "release", RELEASE, key, arguments);
}
CompletionStage<Reply> inspect(RedisCommandGateway gateway, byte[] key, List<String> arguments) {
return run(gateway, "inspect", INSPECT, key, arguments);
}
private CompletionStage<Reply> run(
RedisCommandGateway gateway, String name, String source, byte[] key, List<String> arguments) {
AtomicReference<String> cache =
digests.computeIfAbsent(name, unused -> new AtomicReference<>());
List<byte[]> encoded = new ArrayList<>(arguments.size());
for (String argument : arguments) {
encoded.add(argument.getBytes(StandardCharsets.UTF_8));
}
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, encoded))
.handle(
(reply, failure) ->
failure == null
? CompletableFuture.completedFuture(reply)
: reload(gateway, source, cache, key, encoded, failure))
.thenCompose(stage -> stage)
.thenApply(IdempotencyScripts::replyOf);
}
private CompletionStage<List<Object>> reload(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments,
Throwable failure) {
if (!scriptMissing(failure)) {
return CompletableFuture.failedFuture(failure);
}
cache.set(null);
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments));
}
private static CompletionStage<String> digest(
RedisCommandGateway gateway, String source, AtomicReference<String> cache) {
String cached = cache.get();
if (cached != null) {
return CompletableFuture.completedFuture(cached);
}
return gateway
.loadScript(source.getBytes(StandardCharsets.UTF_8))
.thenApply(
loaded -> {
cache.set(loaded);
return loaded;
});
}
private static boolean scriptMissing(Throwable failure) {
Throwable cause = failure;
while ((cause instanceof CompletionException || cause instanceof ExecutionException)
&& cause.getCause() != null) {
cause = cause.getCause();
}
String message = cause.getMessage();
return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT");
}
private static Reply replyOf(List<Object> reply) {
if (reply == null || reply.size() < 6) {
throw new IllegalStateException("the idempotency program answered with an unexpected shape");
}
return new Reply(
text(reply.get(0)),
number(reply.get(1)),
number(reply.get(2)),
text(reply.get(3)),
text(reply.get(4)),
text(reply.get(5)));
}
private static long number(Object value) {
if (value instanceof Number n) {
return n.longValue();
}
String text = text(value);
return text.isBlank() ? 0L : Long.parseLong(text.strip());
}
private static String text(Object value) {
if (value instanceof byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
return value == null ? "" : String.valueOf(value);
}
/**
* One program's answer.
*
* @param status the transition verdict
* @param attempt the record's attempt counter
* @param revision the record's state revision after the call
* @param owner the stored owner token
* @param payload the stored response, when the verdict carries one
* @param detail verdict-specific detail: a lease deadline, a TTL, or the observed state
*/
record Reply(
String status, long attempt, long revision, String owner, String payload, String detail) {}
}
@@ -0,0 +1,203 @@
package dev.caskeleton.adapter.outbound.cache.redis.lease;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
/**
* The four owner-checked lease programs.
*
* <p>Every one of them compares the stored owner before it mutates, inside the same server
* execution. That is the entire safety property of this adapter: a renew or release that reads the
* owner and then writes would let a holder whose lease expired in between extend or delete a lease
* that now belongs to somebody else. "Check then act" is not a lease.
*
* <p>The stored value is {@code ownerToken:operationId}. Both, because the same owner retrying a
* different operation is a different claim — a caller that re-acquires under a new operation id has
* lost the old one's guarantee and must be told, rather than silently inheriting it.
*/
public final class LeaseScripts {
/** Acquire: set if absent, and report the existing holder when present. */
private static final String ACQUIRE =
"""
local existing = redis.call('GET', KEYS[1])
if existing == false then
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
return {1, redis.call('PTTL', KEYS[1]), ''}
end
if existing == ARGV[1] then
-- The same owner and the same operation. This is a retry of a call whose reply was lost,
-- not a second claim, so it is answered with the lease rather than with contention.
return {2, redis.call('PTTL', KEYS[1]), existing}
end
return {0, redis.call('PTTL', KEYS[1]), existing}
""";
/** Renew: extend only while this exact owner and operation still hold it. */
private static final String RENEW =
"""
local existing = redis.call('GET', KEYS[1])
if existing == false then
return {0, 0, ''}
end
if existing ~= ARGV[1] then
return {-1, redis.call('PTTL', KEYS[1]), existing}
end
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return {1, redis.call('PTTL', KEYS[1]), existing}
""";
/** Release: delete only while this exact owner and operation still hold it. */
private static final String RELEASE =
"""
local existing = redis.call('GET', KEYS[1])
if existing == false then
return {0, 0, ''}
end
if existing ~= ARGV[1] then
return {-1, redis.call('PTTL', KEYS[1]), existing}
end
redis.call('DEL', KEYS[1])
return {1, 0, existing}
""";
/** Inspect: read without mutating, so a caller can ask without taking. */
private static final String INSPECT =
"""
local existing = redis.call('GET', KEYS[1])
if existing == false then
return {0, 0, ''}
end
if existing ~= ARGV[1] then
return {-1, redis.call('PTTL', KEYS[1]), existing}
end
return {1, redis.call('PTTL', KEYS[1]), existing}
""";
private final AtomicReference<String> acquireDigest = new AtomicReference<>();
private final AtomicReference<String> renewDigest = new AtomicReference<>();
private final AtomicReference<String> releaseDigest = new AtomicReference<>();
private final AtomicReference<String> inspectDigest = new AtomicReference<>();
CompletionStage<Reply> acquire(
RedisCommandGateway gateway, byte[] key, String ownership, long ttlMillis) {
return run(gateway, ACQUIRE, acquireDigest, key, args(ownership, ttlMillis));
}
CompletionStage<Reply> renew(
RedisCommandGateway gateway, byte[] key, String ownership, long ttlMillis) {
return run(gateway, RENEW, renewDigest, key, args(ownership, ttlMillis));
}
CompletionStage<Reply> release(RedisCommandGateway gateway, byte[] key, String ownership) {
return run(gateway, RELEASE, releaseDigest, key, args(ownership, 0));
}
CompletionStage<Reply> inspect(RedisCommandGateway gateway, byte[] key, String ownership) {
return run(gateway, INSPECT, inspectDigest, key, args(ownership, 0));
}
private CompletionStage<Reply> run(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments) {
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments))
.handle(
(reply, failure) ->
failure == null
? CompletableFuture.completedFuture(reply)
: reload(gateway, source, cache, key, arguments, failure))
.thenCompose(stage -> stage)
.thenApply(LeaseScripts::replyOf);
}
private CompletionStage<List<Object>> reload(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments,
Throwable failure) {
if (!scriptMissing(failure)) {
return CompletableFuture.failedFuture(failure);
}
cache.set(null);
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments));
}
private static CompletionStage<String> digest(
RedisCommandGateway gateway, String source, AtomicReference<String> cache) {
String cached = cache.get();
if (cached != null) {
return CompletableFuture.completedFuture(cached);
}
return gateway
.loadScript(source.getBytes(StandardCharsets.UTF_8))
.thenApply(
loaded -> {
cache.set(loaded);
return loaded;
});
}
private static boolean scriptMissing(Throwable failure) {
Throwable cause = failure;
while ((cause instanceof CompletionException || cause instanceof ExecutionException)
&& cause.getCause() != null) {
cause = cause.getCause();
}
String message = cause.getMessage();
return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT");
}
private static Reply replyOf(List<Object> reply) {
if (reply == null || reply.size() < 3) {
throw new IllegalStateException("the lease program answered with an unexpected shape");
}
return new Reply(asLong(reply.get(0)), asLong(reply.get(1)), asText(reply.get(2)));
}
private static long asLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof byte[] bytes) {
return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip());
}
throw new IllegalStateException("the lease program answered with an unexpected value type");
}
private static String asText(Object value) {
if (value instanceof byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
return value == null ? "" : String.valueOf(value);
}
private static List<byte[]> args(String ownership, long ttlMillis) {
return List.of(
ownership.getBytes(StandardCharsets.UTF_8),
Long.toString(ttlMillis).getBytes(StandardCharsets.UTF_8));
}
/**
* One program's answer.
*
* @param status {@code 1} applied, {@code 2} replay of the same claim, {@code 0} absent, {@code
* -1} held by somebody else
* @param remainingMillis the server's remaining TTL, diagnostic only
* @param holder the stored ownership string, empty when absent
*/
record Reply(long status, long remainingMillis, String holder) {}
}
@@ -0,0 +1,280 @@
package dev.caskeleton.adapter.outbound.cache.redis.ratelimit;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
import dev.caskeleton.shared.ratelimit.RateLimitPolicy;
import dev.caskeleton.shared.ratelimit.RateParameters;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
/**
* The atomic programs that make one rate-limit decision one round trip.
*
* <p>Every algorithm here reads state, decides, mutates, and sets an expiry inside a single server
* execution. Splitting that into commands is not a performance question: two concurrent requests
* that both read "49 used of 50" would both be allowed, and the limit would be exceeded by exactly
* the concurrency. The whole decision has to be indivisible or it is not a limit.
*
* <p>Time comes from the caller, not from the server's {@code TIME}. Two reasons: a script that
* calls {@code TIME} is non-deterministic, and the decision has to be measured against the clock
* the caller's deadline is measured against. The caller's clock going backwards is handled by the
* policy's clock-regression bound rather than by trusting it blindly.
*
* <p>Loaded once and called by digest. A {@code NOSCRIPT} means the server rejected the call before
* running anything, so reloading and retrying once is safe — it is not a retry of an ambiguous
* mutation.
*/
public final class RateLimitScripts {
/**
* Fixed window: one counter per window, expiring with it.
*
* <p>Returns {@code {allowed, remaining, resetAfterMillis}}. The expiry is set from the window
* rather than refreshed per hit, so a subject cannot hold a counter alive indefinitely.
*/
private static final String FIXED_WINDOW =
"""
local limit = tonumber(ARGV[1])
local windowMillis = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local nowMillis = tonumber(ARGV[4])
local windowStart = nowMillis - (nowMillis % windowMillis)
local resetAfter = (windowStart + windowMillis) - nowMillis
local bucket = tostring(windowStart)
local current = tonumber(redis.call('HGET', KEYS[1], bucket)) or 0
if current + cost > limit then
return {0, limit - current, resetAfter}
end
redis.call('HSET', KEYS[1], bucket, current + cost)
redis.call('PEXPIRE', KEYS[1], windowMillis * 2)
return {1, limit - (current + cost), resetAfter}
""";
/**
* Sliding counter: the current window plus a weighted share of the previous one.
*
* <p>Approximate by construction, and the port says so. An exact sliding window needs one sorted
* set entry per request, which costs memory proportional to the traffic it is limiting — the
* failure mode of an exact limiter is that it becomes the outage.
*/
private static final String SLIDING_COUNTER =
"""
local limit = tonumber(ARGV[1])
local windowMillis = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local nowMillis = tonumber(ARGV[4])
local windowStart = nowMillis - (nowMillis % windowMillis)
local elapsed = nowMillis - windowStart
local resetAfter = windowMillis - elapsed
local current = tonumber(redis.call('HGET', KEYS[1], tostring(windowStart))) or 0
local previous = tonumber(redis.call('HGET', KEYS[1], tostring(windowStart - windowMillis))) or 0
local weight = (windowMillis - elapsed) / windowMillis
local estimated = current + math.floor(previous * weight)
if estimated + cost > limit then
return {0, math.max(0, limit - estimated), resetAfter}
end
redis.call('HSET', KEYS[1], tostring(windowStart), current + cost)
redis.call('HDEL', KEYS[1], tostring(windowStart - (windowMillis * 2)))
redis.call('PEXPIRE', KEYS[1], windowMillis * 3)
return {1, math.max(0, limit - (estimated + cost)), resetAfter}
""";
/**
* Token bucket: refill by elapsed time, then spend.
*
* <p>The stored timestamp is advanced by whole refill periods only. Advancing it to "now" would
* discard the fraction of a period that had already accrued, so a caller polling faster than the
* refill period would never accumulate a token.
*/
private static final String TOKEN_BUCKET =
"""
local capacity = tonumber(ARGV[1])
local refillTokens = tonumber(ARGV[2])
local refillPeriodMillis = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local nowMillis = tonumber(ARGV[5])
local state = redis.call('HMGET', KEYS[1], 'tokens', 'updatedAt')
local tokens = tonumber(state[1])
local updatedAt = tonumber(state[2])
if tokens == nil or updatedAt == nil then
tokens = capacity
updatedAt = nowMillis
end
if updatedAt > nowMillis then
-- The caller's clock went backwards. Refilling on a negative elapsed time would remove
-- tokens; holding the state still is the conservative reading.
updatedAt = nowMillis
end
local periods = math.floor((nowMillis - updatedAt) / refillPeriodMillis)
if periods > 0 then
tokens = math.min(capacity, tokens + (periods * refillTokens))
updatedAt = updatedAt + (periods * refillPeriodMillis)
end
local resetAfter = refillPeriodMillis - ((nowMillis - updatedAt) % refillPeriodMillis)
if tokens < cost then
redis.call('HSET', KEYS[1], 'tokens', tokens, 'updatedAt', updatedAt)
redis.call('PEXPIRE', KEYS[1], refillPeriodMillis * (capacity / math.max(1, refillTokens)) + refillPeriodMillis)
return {0, math.floor(tokens), resetAfter}
end
tokens = tokens - cost
redis.call('HSET', KEYS[1], 'tokens', tokens, 'updatedAt', updatedAt)
redis.call('PEXPIRE', KEYS[1], refillPeriodMillis * (capacity / math.max(1, refillTokens)) + refillPeriodMillis)
return {1, math.floor(tokens), resetAfter}
""";
private final AtomicReference<String> fixedWindowDigest = new AtomicReference<>();
private final AtomicReference<String> slidingCounterDigest = new AtomicReference<>();
private final AtomicReference<String> tokenBucketDigest = new AtomicReference<>();
/**
* Evaluates one request atomically.
*
* @param gateway the driver seam of a borrowed lease
* @param key the rendered counter key
* @param policy the policy to apply
* @param cost the request's cost
* @param now the caller's clock reading
* @return the evaluation
*/
public CompletionStage<Evaluation> evaluate(
RedisCommandGateway gateway, byte[] key, RateLimitPolicy policy, long cost, Instant now) {
Objects.requireNonNull(gateway, "gateway must be non-null");
Objects.requireNonNull(policy, "policy must be non-null");
Objects.requireNonNull(now, "now must be non-null");
long nowMillis = now.toEpochMilli();
return switch (policy.parameters()) {
case RateParameters.FixedWindow window ->
run(
gateway,
FIXED_WINDOW,
fixedWindowDigest,
key,
arguments(window.limit(), window.window().toMillis(), cost, nowMillis));
case RateParameters.SlidingCounter sliding ->
run(
gateway,
SLIDING_COUNTER,
slidingCounterDigest,
key,
arguments(sliding.limit(), sliding.window().toMillis(), cost, nowMillis));
case RateParameters.TokenBucket bucket ->
run(
gateway,
TOKEN_BUCKET,
tokenBucketDigest,
key,
arguments(
bucket.capacity(),
bucket.refillTokens(),
bucket.refillPeriod().toMillis(),
cost,
nowMillis));
default ->
CompletableFuture.failedFuture(
new IllegalStateException("unsupported rate parameters: " + policy.parameters()));
};
}
private CompletionStage<Evaluation> run(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments) {
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments))
.handle(
(reply, failure) ->
failure == null
? CompletableFuture.completedFuture(reply)
: reload(gateway, source, cache, key, arguments, failure))
.thenCompose(stage -> stage)
.thenApply(RateLimitScripts::evaluationOf);
}
private CompletionStage<List<Object>> reload(
RedisCommandGateway gateway,
String source,
AtomicReference<String> cache,
byte[] key,
List<byte[]> arguments,
Throwable failure) {
if (!scriptMissing(failure)) {
return CompletableFuture.failedFuture(failure);
}
cache.set(null);
return digest(gateway, source, cache)
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments));
}
private static CompletionStage<String> digest(
RedisCommandGateway gateway, String source, AtomicReference<String> cache) {
String cached = cache.get();
if (cached != null) {
return CompletableFuture.completedFuture(cached);
}
return gateway
.loadScript(source.getBytes(StandardCharsets.UTF_8))
.thenApply(
loaded -> {
cache.set(loaded);
return loaded;
});
}
private static boolean scriptMissing(Throwable failure) {
Throwable cause = failure;
while ((cause instanceof CompletionException || cause instanceof ExecutionException)
&& cause.getCause() != null) {
cause = cause.getCause();
}
String message = cause.getMessage();
return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT");
}
private static Evaluation evaluationOf(List<Object> reply) {
if (reply == null || reply.size() < 3) {
throw new IllegalStateException(
"the rate limit program answered with "
+ (reply == null ? "nothing" : reply.size())
+ " values; three were expected");
}
return new Evaluation(asLong(reply.get(0)) == 1L, asLong(reply.get(1)), asLong(reply.get(2)));
}
private static long asLong(Object value) {
if (value instanceof Number number) {
return number.longValue();
}
if (value instanceof byte[] bytes) {
return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip());
}
throw new IllegalStateException(
"the rate limit program answered with an unexpected value type: "
+ (value == null ? "null" : value.getClass().getName()));
}
private static List<byte[]> arguments(long... values) {
return java.util.Arrays.stream(values)
.mapToObj(value -> Long.toString(value).getBytes(StandardCharsets.UTF_8))
.toList();
}
/**
* One evaluation's result.
*
* @param allowed whether the request may proceed
* @param remaining the remaining budget after this request
* @param resetAfterMillis how long until the budget changes
*/
public record Evaluation(boolean allowed, long remaining, long resetAfterMillis) {}
}
@@ -0,0 +1,337 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.admin;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandSupport;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.OperationBudget;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.QualifiedRedisKey;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.CommandRequest;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.RedisCommandCatalog;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.RedisCommandPolicy;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.SyncRedisCommandExecutor;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisOperationContext;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* The admin plane, on its own connection and its own ACL account.
*
* <p>It takes its own gateway for the same reason the blocking operations do: a diagnostic that
* walks the keyspace or serializes a large {@code INFO} must not compete with request traffic, and
* the account it authenticates with should be able to read diagnostics and nothing else. Binding
* that to a separate gateway instance is how the separation is expressed structurally instead of
* being left to a deployment note.
*
* <p>Every command is checked against the catalog before it is built: not classified {@code
* ADMIN_ONLY}, or not read-only, and it does not get sent. That check is what keeps a future
* addition to this class from quietly becoming a write.
*/
public final class LettuceRedisAdminOperations implements RedisAdminOperations {
private static final String FAMILY = "ADMIN";
private static final int MAX_PROJECTED = 1_000;
private final RedisCommandCatalog catalog;
private final RedisCommandGateway adminGateway;
private final RedisOperationContext context;
private final SyncRedisCommandExecutor executor;
private final Duration timeout;
/**
* Creates the admin plane.
*
* @param catalog the command policy catalog
* @param adminGateway the driver seam, bound to the admin account's own connection
* @param context the shared rendering and budget rules
* @param executor the guarded blocking executor
* @param timeout the bound on one diagnostic
*/
public LettuceRedisAdminOperations(
RedisCommandCatalog catalog,
RedisCommandGateway adminGateway,
RedisOperationContext context,
SyncRedisCommandExecutor executor,
Duration timeout) {
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
this.adminGateway = Objects.requireNonNull(adminGateway, "admin gateway must be non-null");
this.context = Objects.requireNonNull(context, "operation context must be non-null");
this.executor = Objects.requireNonNull(executor, "executor must be non-null");
this.timeout = Objects.requireNonNull(timeout, "timeout must be non-null");
if (timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("the admin timeout must be positive");
}
}
@Override
public Map<String, String> serverInfo(String section) {
Objects.requireNonNull(section, "section must be non-null");
return fields(text(run(CommandId.parse("INFO"), List.of(), utf8(section))));
}
@Override
public long databaseSize() {
return number(run(CommandId.parse("DBSIZE"), List.of()));
}
@Override
public long memoryUsage(QualifiedRedisKey key) {
Objects.requireNonNull(key, "key must be non-null");
byte[] rendered = context.renderKey(key);
List<Object> reply = run(CommandId.parse("MEMORY USAGE"), List.of(key), rendered);
return reply.isEmpty() || reply.get(0) == null ? -1L : number(reply);
}
@Override
public List<SlowLogEntry> slowLog(int count) {
requireBounded(count, "a slow log read");
List<Object> reply =
run(CommandId.parse("SLOWLOG GET"), List.of(), utf8(Integer.toString(count)));
List<SlowLogEntry> entries = new ArrayList<>();
for (Object element : reply) {
List<Object> row = nested(element);
if (row.size() < 4) {
continue;
}
entries.add(
new SlowLogEntry(
(Long) row.get(0),
Instant.ofEpochSecond((Long) row.get(1)),
Duration.ofNanos(Duration.ofMillis((Long) row.get(2)).toNanos() / 1_000L),
family(nested(row.get(3)))));
}
return List.copyOf(entries);
}
@Override
public Map<String, Duration> latencyLatest() {
List<Object> reply = run(CommandId.parse("LATENCY LATEST"), List.of());
Map<String, Duration> latest = new LinkedHashMap<>();
for (Object element : reply) {
List<Object> row = nested(element);
if (row.size() < 3) {
continue;
}
latest.put(text(List.of(row.get(0))), Duration.ofMillis((Long) row.get(2)));
}
return Map.copyOf(latest);
}
@Override
public List<ClientSummary> clients(int limit) {
requireBounded(limit, "a client projection");
String listing = text(run(CommandId.parse("CLIENT LIST"), List.of()));
List<ClientSummary> clients = new ArrayList<>();
for (String line : listing.lines().toList()) {
if (line.isBlank() || clients.size() == limit) {
break;
}
Map<String, String> attributes = attributes(line);
clients.add(
new ClientSummary(
Long.parseLong(attributes.getOrDefault("id", "0")),
Duration.ofSeconds(Long.parseLong(attributes.getOrDefault("age", "0"))),
Duration.ofSeconds(Long.parseLong(attributes.getOrDefault("idle", "0"))),
attributes.getOrDefault("cmd", "unknown").toUpperCase(Locale.ROOT)));
}
return List.copyOf(clients);
}
@Override
public Map<String, String> clusterInfo() {
return fields(text(run(CommandId.parse("CLUSTER INFO"), List.of())));
}
/**
* The only configuration parameters this plane will read.
*
* <p>Chosen for what an operator diagnosing a Redis problem actually needs — memory ceiling and
* eviction, persistence, replication durability, connection lifetime, topology — and nothing
* else. Adding a parameter is an edit here, which is the point: the set is reviewable, whereas a
* glob is not.
*/
private static final List<String> DIAGNOSTIC_PARAMETERS =
List.of(
"maxmemory",
"maxmemory-policy",
"maxmemory-samples",
"appendonly",
"appendfsync",
"save",
"min-replicas-to-write",
"min-replicas-max-lag",
"timeout",
"tcp-keepalive",
"databases",
"cluster-enabled",
"cluster-require-full-coverage",
"lazyfree-lazy-eviction",
"lazyfree-lazy-expire",
"notify-keyspace-events",
"slowlog-log-slower-than",
"slowlog-max-len");
/** Substrings that mark a parameter as carrying credential material. */
private static final List<String> SECRET_MARKERS =
List.of("pass", "auth", "secret", "key-file", "keyfile", "user");
/** Replacement for a value that must never leave the server. */
static final String REDACTED = "[redacted]";
@Override
public Map<String, String> configuration() {
List<byte[]> arguments =
DIAGNOSTIC_PARAMETERS.stream().map(LettuceRedisAdminOperations::utf8).toList();
List<Object> reply = run(CommandId.parse("CONFIG GET"), List.of(), arguments);
Map<String, String> parameters = new LinkedHashMap<>();
for (int index = 0; index + 1 < reply.size(); index += 2) {
String name = text(List.of(reply.get(index)));
// Filtered again on the way out. The request already named only allowlisted parameters, but
// a server-side alias or a future glob-expanding change must not be able to widen the
// projection, and a parameter that slipped through must not carry its value with it.
if (!DIAGNOSTIC_PARAMETERS.contains(name)) {
continue;
}
String value = text(List.of(reply.get(index + 1)));
parameters.put(name, isSecretShaped(name) ? REDACTED : value);
}
return Map.copyOf(parameters);
}
private static boolean isSecretShaped(String parameterName) {
String lower = parameterName.toLowerCase(Locale.ROOT);
return SECRET_MARKERS.stream().anyMatch(lower::contains);
}
@Override
public Optional<String> aclDryRun(String username, CommandId commandId) {
Objects.requireNonNull(username, "username must be non-null");
Objects.requireNonNull(commandId, "command id must be non-null");
List<byte[]> arguments = new ArrayList<>();
arguments.add(utf8(username));
arguments.add(utf8(commandId.family()));
commandId.subcommand().map(LettuceRedisAdminOperations::utf8).ifPresent(arguments::add);
String answer = text(run(CommandId.parse("ACL DRYRUN"), List.of(), arguments));
return "OK".equals(answer) ? Optional.empty() : Optional.of(answer);
}
private List<Object> run(CommandId commandId, List<QualifiedRedisKey> keys, byte[]... arguments) {
return run(commandId, keys, List.of(arguments));
}
private List<Object> run(
CommandId commandId, List<QualifiedRedisKey> keys, List<byte[]> arguments) {
RedisCommandPolicy policy = catalog.require(commandId);
if (policy.support() != CommandSupport.ADMIN_ONLY || !policy.readOnly()) {
throw context.reject(
FAMILY, true, "the admin plane only sends read-only diagnostics the catalog approved");
}
long requestBytes = 1L;
for (byte[] argument : arguments) {
requestBytes += argument.length;
}
OperationBudget budget =
new OperationBudget(
Math.max(1, keys.size()),
requestBytes,
context.limits().maxReplyBytesPerElement(),
timeout);
return executor.execute(
new CommandRequest<>(
commandId,
keys,
requestBytes,
0L,
Optional.empty(),
Optional.empty(),
Optional.of(budget),
Optional.empty(),
() -> adminGateway.sendAdminDiagnostic(commandId, arguments)));
}
private void requireBounded(int count, String description) {
if (count < 1) {
throw context.reject(FAMILY, true, description + " must declare a positive bound");
}
if (count > MAX_PROJECTED) {
throw context.reject(
FAMILY, true, description + " may not exceed " + MAX_PROJECTED + " entries");
}
}
private static String family(List<Object> commandWords) {
return commandWords.isEmpty()
? "UNKNOWN"
: text(List.of(commandWords.get(0))).toUpperCase(Locale.ROOT);
}
private static Map<String, String> fields(String body) {
Map<String, String> parsed = new LinkedHashMap<>();
for (String line : body.lines().toList()) {
String trimmed = line.strip();
int separator = trimmed.indexOf(':');
if (trimmed.isEmpty() || trimmed.startsWith("#") || separator < 1) {
continue;
}
parsed.put(trimmed.substring(0, separator), trimmed.substring(separator + 1));
}
return Map.copyOf(parsed);
}
private static Map<String, String> attributes(String line) {
Map<String, String> parsed = new LinkedHashMap<>();
// Parsed by hand rather than by splitting: a CLIENT LIST line is space-separated key=value
// pairs, and the values can themselves contain characters a naive split would mangle.
String remainder = line.strip();
while (!remainder.isEmpty()) {
int space = remainder.indexOf(' ');
String pair = space < 0 ? remainder : remainder.substring(0, space);
remainder = space < 0 ? "" : remainder.substring(space + 1);
int separator = pair.indexOf('=');
if (separator > 0) {
parsed.put(pair.substring(0, separator), pair.substring(separator + 1));
}
}
return parsed;
}
private static long number(List<Object> reply) {
Object first = reply.isEmpty() ? null : reply.get(0);
if (first instanceof Long value) {
return value;
}
if (first instanceof byte[] bytes) {
return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip());
}
throw new IllegalStateException("the diagnostic did not answer with a number");
}
private static String text(List<Object> reply) {
Object first = reply.isEmpty() ? null : reply.get(0);
if (first instanceof byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
return first == null ? "" : String.valueOf(first);
}
@SuppressWarnings("unchecked")
private static List<Object> nested(Object element) {
return element instanceof List ? (List<Object>) element : List.of();
}
private static byte[] utf8(String text) {
return text.getBytes(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.admin;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.QualifiedRedisKey;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* Read-only server diagnostics, separated from the application request path.
*
* <p>Everything here is read-only by construction: the command policy catalog classifies each of
* these {@code ADMIN_ONLY} and read-only, and the implementation refuses to send anything that is
* not. The destructive counterparts an operator might reach for — {@code FLUSHDB}, {@code
* FLUSHALL}, {@code SHUTDOWN}, {@code DEBUG}, {@code CONFIG SET}, {@code CLIENT KILL}, {@code ACL
* SETUSER}, {@code SLOWLOG RESET}, {@code LATENCY RESET} — are all {@code BLOCKED} in the catalog
* and have no method here or anywhere else in the SDK.
*
* <p>The plane is expected to run on its own connection factory and its own ACL account. That is
* not something this interface can enforce, which is exactly why the catalog blocks the dangerous
* commands outright rather than trusting the deployment to have separated the credentials.
*/
public interface RedisAdminOperations {
/**
* Reads one {@code INFO} section.
*
* @param section the section name, for example {@code memory} or {@code replication}
* @return the section's fields
*/
Map<String, String> serverInfo(String section);
/**
* Reads the key count of the current database.
*
* @return the key count
*/
long databaseSize();
/**
* Reads the memory one key occupies.
*
* @param key the key, which is namespace-checked like any other
* @return the size in bytes, or {@code -1} when the key is absent
*/
long memoryUsage(QualifiedRedisKey key);
/**
* Reads the most recent slow log entries.
*
* @param count the strictly positive bound on returned entries
* @return the entries, newest first
*/
List<SlowLogEntry> slowLog(int count);
/**
* Reads the latest latency spike per monitored event.
*
* @return the latest spike per event name
*/
Map<String, Duration> latencyLatest();
/**
* Reads a bounded projection of the connected clients.
*
* @param limit the strictly positive bound on returned clients
* @return the projected clients
*/
List<ClientSummary> clients(int limit);
/**
* Reads the cluster state.
*
* @return the {@code CLUSTER INFO} fields
*/
Map<String, String> clusterInfo();
/**
* Reads the fixed diagnostic configuration projection.
*
* <p>There is deliberately no pattern parameter. {@code CONFIG GET} with a caller-supplied glob
* is an arbitrary read of the server's configuration: {@code *} returns everything the account
* can see, including {@code requirepass}, {@code masterauth}, {@code masteruser} and the TLS key
* passwords. An admin plane whose whole purpose is bounded, payload-free diagnostics cannot own a
* method that returns whatever the caller asks for, so the parameter set is fixed here and
* anything outside it is unreachable.
*
* @return the allowlisted diagnostic parameters, with any secret-shaped value redacted
*/
Map<String, String> configuration();
/**
* Asks the server whether a user would be allowed to run a command.
*
* <p>This is how an ACL account is verified against what the SDK actually sends, rather than
* against what someone believed it sends.
*
* @param username the ACL user
* @param commandId the command to test
* @return empty when the command would be allowed, otherwise the server's refusal reason
*/
java.util.Optional<String> aclDryRun(String username, CommandId commandId);
}
@@ -0,0 +1,397 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.config;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisCredentialRole;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeClient;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisTopologyClientFactory;
import java.io.IOException;
import java.io.InputStream;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.boot.health.contributor.Status;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.context.annotation.Conditional;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.type.AnnotatedTypeMetadata;
/**
* The Redis composition root, and the only place Redis settings come into existence.
*
* <p>{@code app.redis.enabled} is the whole switch. While it is false this class contributes
* nothing, and because {@link RedisSdkSettings} is registered here rather than by the
* application-wide {@code @ConfigurationPropertiesScan}, "contributes nothing" is literal: the
* properties are not bound, the cross-field rules are not run, no credential is resolved, and no
* policy resource, TLS material, client, connection or thread is created. A deployment that does
* not use Redis carries no Redis configuration, and a deployment with malformed Redis configuration
* it never enabled is not punished for it.
*
* <p>While it is true the order is fixed and entirely local: bind, validate, then build. Validation
* runs at context refresh, before anything can reach the network, which is what makes {@link
* RedisSdkSettings#validate()} the fail-fast its own documentation claims — until this class
* existed the method had no production caller at all.
*
* <p>Roles — cache, session, idempotency, rate limiting, leases — select <em>which</em> Redis
* capabilities compose on top of this. None of them is a second master switch; {@code
* RedisActivationValidator} in the bootstrap refuses the contradiction of a role that selects Redis
* while this switch is off.
*/
@AutoConfiguration
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true")
// Registers the binding post-processor that populates the @ConfigurationProperties @Bean below.
// Without it the bean is created and silently left at its defaults, which is worse than not
// binding at all: validation would pass on settings nobody configured.
@EnableConfigurationProperties
public class RedisSdkAutoConfiguration {
private static final Logger LOG = LoggerFactory.getLogger(RedisSdkAutoConfiguration.class);
/**
* The status an optional Redis reports when it is unreachable.
*
* <p>Not {@code DOWN}. A cache outage is a real degradation and belongs in the health detail, but
* a status the readiness group understands as failure would remove a healthy pod from service —
* shrinking capacity during the exact incident that needs it most.
*/
private static final Status DEGRADED =
new Status("DEGRADED", "Redis is unreachable; the cache is bypassed");
/**
* Binds and validates the Redis settings.
*
* <p>Validation happens in the factory method rather than in an {@code @PostConstruct} or a
* listener so that a configuration error is reported as a failure to create this bean, with the
* offending rule in the message, and so that nothing downstream can obtain an unvalidated
* settings instance.
*
* @return the validated settings
*/
@Bean
@ConfigurationProperties(prefix = "app.redis")
public RedisSdkSettings redisSdkSettings() {
return new RedisSdkSettings();
}
/**
* Runs the cross-field rules once the binder has populated the settings.
*
* <p>Spring binds {@code @ConfigurationProperties} after the factory method returns, so the
* validation cannot live inside {@link #redisSdkSettings()}. A {@code
* ConfigurationPropertiesBindHandlerAdvisor}-free way to get the same fail-fast is a bean that
* depends on the settings: it is created during refresh, before any Redis client would be, and an
* exception here stops the context.
*
* @param settings the bound settings
* @return the validation outcome, kept as a bean so warnings are inspectable in tests
*/
@Bean
public RedisSdkSettingsValidation redisSdkSettingsValidation(
RedisSdkSettings settings, ResourceLoader resourceLoader) {
List<String> warnings = settings.validate();
requireRawPolicyResource(settings, resourceLoader);
warnings.forEach(warning -> LOG.warn("Redis SDK configuration warning: {}", warning));
return new RedisSdkSettingsValidation(warnings);
}
/**
* Proves the raw command allowlist exists before anything can reach Redis.
*
* <p>{@code validate()} only checks that the setting is non-blank, and the default points at
* {@code classpath:redis-sdk/raw-command-allowlist.yml} — a resource this module does not ship.
* So enabling the raw gateway passed configuration validation and then failed at the first raw
* command, from inside a request, against a live connection. The allowlist is the entire
* authorisation model for that gateway; not being able to read it is a startup failure.
*/
private static void requireRawPolicyResource(
RedisSdkSettings settings, ResourceLoader resourceLoader) {
if (!settings.getRaw().isEnabled()) {
return;
}
String location = settings.getRaw().getPolicyResource();
Resource resource = resourceLoader.getResource(location);
if (!resource.exists() || !resource.isReadable()) {
throw new IllegalStateException(
"the raw gateway is enabled but its allowlist resource '"
+ location
+ "' does not exist or cannot be read. The allowlist is the only thing that decides"
+ " which raw commands are reachable, so an unreadable one is a startup failure"
+ " rather than a per-command surprise. Point"
+ " app.redis.raw.policy-resource at a readable resource, or set"
+ " app.redis.raw.enabled=false.");
}
try (InputStream ignored = resource.getInputStream()) {
LOG.info("Redis raw command allowlist loaded from {}", location);
} catch (IOException failure) {
throw new IllegalStateException(
"the raw gateway allowlist resource '" + location + "' could not be opened", failure);
}
}
/**
* Resolves every credential reference the selected configuration actually needs.
*
* <p>Before any client exists. A reference that does not resolve is a configuration error, and
* the only place it can be reported as one is here — after that the failure is an authentication
* error on somebody's first command.
*
* @param settings the validated settings
* @param secretSource resolves a secret name to its value
* @param validation ordered after validation so a malformed setting is reported first
* @return the resolved credentials
*/
@Bean
public RedisResolvedCredentials redisResolvedCredentials(
RedisSdkSettings settings,
ObjectProvider<RedisSecretSource> secretSource,
RedisSdkSettingsValidation validation) {
RedisCredentialResolver resolver =
new RedisCredentialResolver(
name -> secretSource.getIfAvailable(() -> environmentSecretSource()).resolve(name));
Map<RedisCredentialRole, RedisCredentialResolver.RedisCredentials> accounts =
new EnumMap<>(RedisCredentialRole.class);
// Every configured role is resolved, not only the application one. A deployment that named an
// advanced or pub/sub account and got a client that silently authenticated as the application
// account has the privilege separation it configured on paper and nowhere else.
put(
accounts,
RedisCredentialRole.APPLICATION,
resolver.resolve("application", settings.getAuthentication().getCredentialReference()));
put(
accounts,
RedisCredentialRole.ADVANCED,
resolver.resolve(
"advanced", settings.getAuthentication().getAdvancedCredentialReference()));
put(
accounts,
RedisCredentialRole.PUBSUB,
resolver.resolve("pub/sub", settings.getAuthentication().getPubsubCredentialReference()));
if (settings.getAdmin().isEnabled()) {
put(
accounts,
RedisCredentialRole.ADMIN,
resolver.resolve("admin", settings.getAdmin().getCredentialReference()));
}
if (settings.getRaw().isEnabled()) {
put(
accounts,
RedisCredentialRole.RAW,
resolver.resolve("raw gateway", settings.getRaw().getCredentialReference()));
}
Optional<RedisCredentialResolver.RedisCredentials> sentinel =
settings.getMode()
== dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode.SENTINEL
? resolver.resolve("sentinel", settings.getSentinel().getCredentialReference())
: Optional.empty();
LOG.info(
"Redis accounts resolved for roles {}{}",
accounts.keySet(),
sentinel.isPresent() ? " plus the Sentinel control account" : "");
return new RedisResolvedCredentials(accounts, sentinel);
}
private static void put(
Map<RedisCredentialRole, RedisCredentialResolver.RedisCredentials> accounts,
RedisCredentialRole role,
Optional<RedisCredentialResolver.RedisCredentials> resolved) {
resolved.ifPresent(credentials -> accounts.put(role, credentials));
}
private static RedisSecretSource environmentSecretSource() {
// The default reads the process environment, which is where a mounted secret lands. A
// deployment with a secret manager contributes its own RedisSecretSource bean.
return name -> Optional.ofNullable(System.getenv(name)).filter(value -> !value.isBlank());
}
/**
* Builds the one client the configured topology calls for.
*
* @param settings the validated settings
* @param credentials the resolved credentials
* @return the runtime client
*/
@Bean
public RedisRuntimeClient redisRuntimeClient(
RedisSdkSettings settings,
RedisResolvedCredentials credentials,
ResourceLoader resourceLoader) {
return new RedisTopologyClientFactory(
settings,
credentials.accounts(),
credentials.sentinel(),
location -> tlsMaterial(resourceLoader, location).getInputStream())
.create();
}
/**
* Resolves a TLS material location, whether it names a resource or a file.
*
* <p>Both spellings are ordinary. A CA bundled with the application is {@code
* classpath:redis/ca.pem}; a CA mounted by the platform is {@code /etc/ssl/redis/ca.pem}, and the
* mounted one is the more common of the two. Resolving everything as a file broke the first;
* handing everything to the resource loader breaks the second, because a location with no prefix
* is a <em>classpath</em> location to Spring — so {@code /etc/ssl/redis/ca.pem} would be looked
* up on the classpath and reported missing while sitting on disk.
*
* @param resourceLoader the context's resource loader
* @param location the configured location
* @return the resolved resource
*/
private static Resource tlsMaterial(ResourceLoader resourceLoader, String location) {
boolean prefixed =
location.startsWith(ResourceLoader.CLASSPATH_URL_PREFIX)
|| location.contains("://")
|| location.startsWith("file:");
return prefixed
? resourceLoader.getResource(location)
: new org.springframework.core.io.FileSystemResource(location);
}
/**
* Owns every connection and the order they are torn down in.
*
* <p>Destroyed by Spring, and destroyed before the client bean it wraps because it depends on it
* — which is the order shutdown needs: connections drain and close, then the event loop stops.
*
* @param client the runtime client
* @param settings the validated settings
* @return the lifecycle owner
*/
@Bean(destroyMethod = "close")
public RedisRuntimeOwner redisRuntimeOwner(RedisRuntimeClient client, RedisSdkSettings settings) {
Map<RedisConnectionKind, Integer> limits = new EnumMap<>(RedisConnectionKind.class);
limits.put(RedisConnectionKind.REGULAR, settings.getCapacity().getMaximumInFlightCommands());
limits.put(RedisConnectionKind.BLOCKING, settings.getBlocking().getMaxConnections());
limits.put(RedisConnectionKind.TRANSACTION, settings.getTransaction().getMaxConnections());
limits.put(RedisConnectionKind.SCRIPT, settings.getCapacity().getMaximumInFlightCommands());
limits.put(
RedisConnectionKind.PUBSUB, Math.max(1, settings.getPubsub().getBufferCapacity() / 64));
limits.put(RedisConnectionKind.ADMIN, settings.getAdmin().isEnabled() ? 2 : 1);
return new RedisRuntimeOwner(client, limits, settings.getLifecycle().getDrainTimeout());
}
/**
* The optional-Redis health contributor: a cache outage is detail, never unreadiness.
*
* <p>Bean name {@code redisOptional}, and deliberately outside the readiness group. Turning a pod
* unready because its cache is down removes capacity from a system that is already slower than
* usual, which is the opposite of what the outage needs.
*
* @param owner the runtime owner
* @param settings the validated settings
* @return the contributor
*/
@Bean(RedisCorrectnessRoles.OPTIONAL_HEALTH_CONTRIBUTOR)
public HealthIndicator redisOptional(RedisRuntimeOwner owner, RedisSdkSettings settings) {
RedisHealthContributor contributor =
new RedisHealthContributor(owner, settings.getTimeout().getFast());
return () -> {
RedisHealthContributor.RedisHealth health = contributor.probe();
return Health.status(health.reachable() ? Status.UP : DEGRADED)
.withDetails(health.detail())
.build();
};
}
/**
* The required-Redis health contributor, present only when a correctness role is bound.
*
* <p>Bean name {@code redisRequired}, and the readiness group names it. Session, idempotency,
* rate-limit and lease all depend on Redis for correctness rather than speed: serving traffic
* without them is worse than not serving it, so this one does flip readiness.
*
* <p>Conditional on a role actually selecting Redis. A deployment that runs Redis purely as a
* cache has no correctness role to gate on, and a required contributor there would make a cache
* outage an outage.
*
* @param owner the runtime owner
* @param settings the validated settings
* @return the contributor
*/
@Bean(RedisCorrectnessRoles.REQUIRED_HEALTH_CONTRIBUTOR)
@Conditional(RedisCorrectnessRoleBound.class)
public HealthIndicator redisRequired(RedisRuntimeOwner owner, RedisSdkSettings settings) {
RedisHealthContributor contributor =
new RedisHealthContributor(owner, settings.getTimeout().getFast());
return () -> {
RedisHealthContributor.RedisHealth health = contributor.probe();
return Health.status(health.reachable() ? Status.UP : Status.DOWN)
.withDetails(health.detail())
.build();
};
}
/**
* Present when a role that needs Redis for <em>correctness</em> selected it.
*
* <p>Derived from the role selectors rather than a separate flag, because a separate flag is a
* second thing to keep in sync — and the failure mode of forgetting it is a readiness probe that
* does not gate on a dependency the deployment cannot serve without.
*/
static final class RedisCorrectnessRoleBound implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
// The same predicate the readiness group's post-processor asks. Duplicating it here is what
// produced a group naming a contributor nothing could create.
return RedisCorrectnessRoles.anySelected(context.getEnvironment());
}
}
/**
* The account resolved for each role the configuration named.
*
* <p>A role that is absent from {@code accounts} has no account of its own and runs on the
* application client. That is a deployment decision, taken by not configuring one, rather than a
* default the SDK picks.
*
* @param accounts the resolved account per configured role
* @param sentinel the Sentinel control account, on a Sentinel deployment
*/
public record RedisResolvedCredentials(
Map<RedisCredentialRole, RedisCredentialResolver.RedisCredentials> accounts,
Optional<RedisCredentialResolver.RedisCredentials> sentinel) {
public RedisResolvedCredentials {
accounts = Map.copyOf(accounts);
}
}
/** Where a credential reference's value comes from. */
@FunctionalInterface
public interface RedisSecretSource {
/**
* Resolves a secret by name.
*
* @param name the secret name
* @return the value, or empty when the source does not have it
*/
Optional<String> resolve(String name);
}
/**
* The result of validating the Redis settings at startup.
*
* @param warnings settings that are within the guardrails but worth an operator's attention
*/
public record RedisSdkSettingsValidation(List<String> warnings) {
public RedisSdkSettingsValidation {
warnings = List.copyOf(warnings);
}
}
}
@@ -0,0 +1,943 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.config;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisKeyRules;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Typed configuration for the Redis SDK, bound from {@code app.redis.*}.
*
* <p>Defaults are the skeleton guardrails from the design. Validation is fail-closed and runs at
* startup: a setting that would make a guardrail meaningless — a non-zero database on Cluster, an
* unbounded block, a raw gateway without an allowlist — stops the context rather than degrading
* quietly at the first request.
*
* <p>This class carries no {@code @ConfigurationProperties} annotation on purpose. It is registered
* and bound only by {@link RedisSdkAutoConfiguration}, which exists only while {@code
* app.redis.enabled} is true. Annotating the class would put it back inside the application-wide
* {@code @ConfigurationPropertiesScan}, and a Redis-free deployment would once again bind Redis
* configuration — the exact defect this arrangement removes.
*/
public class RedisSdkSettings {
/** Hard ceiling above which a configured timeout is a startup failure. */
public static final Duration MAXIMUM_TIMEOUT = Duration.ofSeconds(30);
/** Timeout above which a configured fast profile produces a startup warning. */
public static final Duration FAST_TIMEOUT_WARNING_THRESHOLD = Duration.ofSeconds(5);
private boolean enabled;
private RedisDeploymentMode mode = RedisDeploymentMode.STANDALONE;
private List<String> nodes = new ArrayList<>(List.of("localhost:6379"));
private int database;
private boolean acknowledgedWriteLossAccepted;
private final Namespace namespace = new Namespace();
private final Timeouts timeout = new Timeouts();
private final Limits limits = new Limits();
private final Blocking blocking = new Blocking();
private final Transaction transaction = new Transaction();
private final Advanced advanced = new Advanced();
private final Authentication authentication = new Authentication();
private final Sentinel sentinel = new Sentinel();
private final Tls tls = new Tls();
private final Lifecycle lifecycle = new Lifecycle();
private final Cluster cluster = new Cluster();
private final Capacity capacity = new Capacity();
private final PubSub pubsub = new PubSub();
private final Raw raw = new Raw();
private final Admin admin = new Admin();
/**
* Validates every cross-field rule.
*
* @return non-fatal warnings; an empty list means the configuration is entirely within guardrails
* @throws IllegalStateException when a setting would disable a guardrail
*/
public List<String> validate() {
List<String> warnings = new ArrayList<>();
if (mode == RedisDeploymentMode.CLUSTER && database != 0) {
throw new IllegalStateException("Cluster supports database 0 only");
}
if (database < 0) {
throw new IllegalStateException("database index must not be negative");
}
if (nodes == null || nodes.isEmpty()) {
throw new IllegalStateException("at least one Redis node must be configured");
}
namespace.validate();
limits.validate();
validateTimeout("fast", timeout.getFast(), warnings);
validateTimeout("collection", timeout.getCollection(), warnings);
validateTimeout("script", timeout.getScript(), warnings);
validateTimeout("batch", timeout.getBatch(), warnings);
validateTimeout("admin", timeout.getAdmin(), warnings);
if (blocking.getMaxBlock().isZero() || blocking.getMaxBlock().isNegative()) {
throw new IllegalStateException("blocking commands must not be unbounded");
}
if (blocking.getMaxConnections() < 1 || transaction.getMaxConnections() < 1) {
throw new IllegalStateException("dedicated connection lanes need a positive ceiling");
}
if (mode == RedisDeploymentMode.SENTINEL
&& (sentinel.getMasterName() == null || sentinel.getMasterName().isBlank())) {
throw new IllegalStateException(
"a Sentinel deployment must name the monitored primary; without it the client cannot"
+ " resolve a primary at all, let alone follow a promotion");
}
if (tls.isEnabled() && !tls.isHostnameVerification()) {
warnings.add(
"TLS is enabled with hostname verification disabled, which accepts any certificate the"
+ " trust material signs, for any host");
}
lifecycle.validate();
capacity.validate();
cluster.validate();
pubsub.validate();
if (raw.isEnabled() && (raw.getPolicyResource() == null || raw.getPolicyResource().isBlank())) {
throw new IllegalStateException("the raw gateway requires an allowlist resource");
}
if (raw.isEnabled()
&& (raw.getCredentialReference() == null || raw.getCredentialReference().isBlank())) {
throw new IllegalStateException("the raw gateway requires its own credential reference");
}
if (admin.isEnabled()
&& (admin.getCredentialReference() == null || admin.getCredentialReference().isBlank())) {
throw new IllegalStateException("the admin plane requires its own credential reference");
}
if (!advanced.isEnabled() && !advanced.getPolicies().isEmpty()) {
throw new IllegalStateException(
"advanced permit policies are configured while advanced operations are disabled");
}
// Last, deliberately. A deployment with both a structural mistake and a missing credential
// should be told about the structural one first: it is the cheaper thing to be wrong about,
// and reporting "no credential" for a configuration that could never have connected anyway
// sends the operator to the wrong file.
authentication.validate(warnings);
return List.copyOf(warnings);
}
private static void validateTimeout(String name, Duration value, List<String> warnings) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalStateException(name + " timeout must be positive");
}
if (value.compareTo(MAXIMUM_TIMEOUT) > 0) {
throw new IllegalStateException(
name + " timeout must not exceed " + MAXIMUM_TIMEOUT.toSeconds() + "s");
}
if ("fast".equals(name) && value.compareTo(FAST_TIMEOUT_WARNING_THRESHOLD) > 0) {
warnings.add(
"fast timeout of " + value + " is far above the 500ms guardrail for single-key commands");
}
}
/** Namespace tokens applied to every key this process writes. */
public static class Namespace {
private String environment = "local";
private String service = "sample-service";
private String domain = "shared";
void validate() {
RedisKeyRules.requireToken("environment", environment);
RedisKeyRules.requireToken("service", service);
RedisKeyRules.requireToken("domain", domain);
}
public String getEnvironment() {
return environment;
}
public void setEnvironment(String environment) {
this.environment = environment;
}
public String getService() {
return service;
}
public void setService(String service) {
this.service = service;
}
public String getDomain() {
return domain;
}
public void setDomain(String domain) {
this.domain = domain;
}
}
/** Per-profile timeout guardrails. */
public static class Timeouts {
private Duration fast = Duration.ofMillis(500);
private Duration collection = Duration.ofSeconds(2);
private Duration script = Duration.ofSeconds(1);
private Duration batch = Duration.ofSeconds(2);
private Duration admin = Duration.ofSeconds(3);
public Duration getFast() {
return fast;
}
public void setFast(Duration fast) {
this.fast = fast;
}
public Duration getCollection() {
return collection;
}
public void setCollection(Duration collection) {
this.collection = collection;
}
public Duration getScript() {
return script;
}
public void setScript(Duration script) {
this.script = script;
}
public Duration getBatch() {
return batch;
}
public void setBatch(Duration batch) {
this.batch = batch;
}
public Duration getAdmin() {
return admin;
}
public void setAdmin(Duration admin) {
this.admin = admin;
}
}
/** Size and count ceilings enforced before Redis is called. */
public static class Limits {
private int maxKeyBytes = 512;
private long maxValueBytes = 1_048_576L;
private long maxStreamPayloadBytes = 262_144L;
private long maxHashFieldValueBytes = 524_288L;
private int maxCollectionElements = 1_000;
private int maxScanCount = 500;
private int maxBatchCommands = 500;
private long maxBatchRequestBytes = 4L * 1024 * 1024;
private long maxBatchReplyBytes = 16L * 1024 * 1024;
private int offlineQueueCommands = 1_000;
private long maxBitmapOffset = 10_000_000L;
void validate() {
if (maxKeyBytes < 1 || maxKeyBytes > RedisKeyRules.MAX_KEY_BYTES) {
throw new IllegalStateException(
"max-key-bytes must be in 1.." + RedisKeyRules.MAX_KEY_BYTES);
}
if (maxValueBytes < 1
|| maxStreamPayloadBytes < 1
|| maxHashFieldValueBytes < 1
|| maxCollectionElements < 1
|| maxScanCount < 1
|| maxBatchCommands < 1
|| maxBatchRequestBytes < 1
|| maxBatchReplyBytes < 1
|| offlineQueueCommands < 1
|| maxBitmapOffset < 1) {
throw new IllegalStateException("every Redis SDK limit must be positive");
}
}
public int getMaxKeyBytes() {
return maxKeyBytes;
}
public void setMaxKeyBytes(int maxKeyBytes) {
this.maxKeyBytes = maxKeyBytes;
}
public long getMaxValueBytes() {
return maxValueBytes;
}
public void setMaxValueBytes(long maxValueBytes) {
this.maxValueBytes = maxValueBytes;
}
public long getMaxStreamPayloadBytes() {
return maxStreamPayloadBytes;
}
public void setMaxStreamPayloadBytes(long maxStreamPayloadBytes) {
this.maxStreamPayloadBytes = maxStreamPayloadBytes;
}
public long getMaxHashFieldValueBytes() {
return maxHashFieldValueBytes;
}
public void setMaxHashFieldValueBytes(long maxHashFieldValueBytes) {
this.maxHashFieldValueBytes = maxHashFieldValueBytes;
}
public int getMaxCollectionElements() {
return maxCollectionElements;
}
public void setMaxCollectionElements(int maxCollectionElements) {
this.maxCollectionElements = maxCollectionElements;
}
public int getMaxScanCount() {
return maxScanCount;
}
public void setMaxScanCount(int maxScanCount) {
this.maxScanCount = maxScanCount;
}
public int getMaxBatchCommands() {
return maxBatchCommands;
}
public void setMaxBatchCommands(int maxBatchCommands) {
this.maxBatchCommands = maxBatchCommands;
}
public long getMaxBatchRequestBytes() {
return maxBatchRequestBytes;
}
public void setMaxBatchRequestBytes(long maxBatchRequestBytes) {
this.maxBatchRequestBytes = maxBatchRequestBytes;
}
public long getMaxBatchReplyBytes() {
return maxBatchReplyBytes;
}
public void setMaxBatchReplyBytes(long maxBatchReplyBytes) {
this.maxBatchReplyBytes = maxBatchReplyBytes;
}
public int getOfflineQueueCommands() {
return offlineQueueCommands;
}
public void setOfflineQueueCommands(int offlineQueueCommands) {
this.offlineQueueCommands = offlineQueueCommands;
}
public long getMaxBitmapOffset() {
return maxBitmapOffset;
}
public void setMaxBitmapOffset(long maxBitmapOffset) {
this.maxBitmapOffset = maxBitmapOffset;
}
}
/** Blocking lane ceilings. */
public static class Blocking {
private int maxConnections = 32;
private Duration maxBlock = Duration.ofSeconds(30);
public int getMaxConnections() {
return maxConnections;
}
public void setMaxConnections(int maxConnections) {
this.maxConnections = maxConnections;
}
public Duration getMaxBlock() {
return maxBlock;
}
public void setMaxBlock(Duration maxBlock) {
this.maxBlock = maxBlock;
}
}
/** Transaction lane ceilings. */
public static class Transaction {
private int maxConnections = 16;
public int getMaxConnections() {
return maxConnections;
}
public void setMaxConnections(int maxConnections) {
this.maxConnections = maxConnections;
}
}
/** Application authentication material, carried as references rather than values. */
public static class Authentication {
private String credentialReference;
private String advancedCredentialReference;
private String pubsubCredentialReference;
private boolean anonymousAccessAccepted;
void validate(List<String> warnings) {
if (credentialReference == null || credentialReference.isBlank()) {
if (!anonymousAccessAccepted) {
throw new IllegalStateException(
"Redis is enabled but no application credential reference is configured. Booting"
+ " anyway builds an unauthenticated client, which on any deployment that"
+ " disabled the `default` ACL user cannot run a single command — the failure"
+ " simply moves from startup to the first request, where it looks like an"
+ " outage instead of a missing setting. Set"
+ " app.redis.authentication.credential-reference, or declare the trade with"
+ " app.redis.authentication.anonymous-access-accepted=true.");
}
warnings.add(
"Redis is running without credentials because"
+ " app.redis.authentication.anonymous-access-accepted is true; every command runs"
+ " as the `default` ACL user");
}
if (advancedCredentialReference == null || advancedCredentialReference.isBlank()) {
// Not a failure: one account is a legitimate deployment. But it is worth saying out loud,
// because it means the account that reads cache entries can also execute scripts.
warnings.add(
"no advanced credential reference is configured, so registered scripts run as the"
+ " application account — that account therefore needs SCRIPT LOAD and EVALSHA,"
+ " and every code path that reaches a regular connection has them too");
}
}
/**
* Reports whether this deployment declared that it accepts running Redis unauthenticated.
*
* <p>Leave this false. It exists so that a local single-container Redis is a one-line
* declaration rather than a reason to weaken the check for everybody, and so that the
* declaration is visible in the deployment's own configuration.
*
* @return {@code true} when anonymous access is accepted
*/
public boolean isAnonymousAccessAccepted() {
return anonymousAccessAccepted;
}
public void setAnonymousAccessAccepted(boolean anonymousAccessAccepted) {
this.anonymousAccessAccepted = anonymousAccessAccepted;
}
public String getCredentialReference() {
return credentialReference;
}
public void setCredentialReference(String credentialReference) {
this.credentialReference = credentialReference;
}
public String getAdvancedCredentialReference() {
return advancedCredentialReference;
}
public void setAdvancedCredentialReference(String advancedCredentialReference) {
this.advancedCredentialReference = advancedCredentialReference;
}
public String getPubsubCredentialReference() {
return pubsubCredentialReference;
}
public void setPubsubCredentialReference(String pubsubCredentialReference) {
this.pubsubCredentialReference = pubsubCredentialReference;
}
}
/** Sentinel discovery. */
public static class Sentinel {
private String masterName;
private List<String> nodes = new ArrayList<>();
private String credentialReference;
public String getMasterName() {
return masterName;
}
public void setMasterName(String masterName) {
this.masterName = masterName;
}
public List<String> getNodes() {
return nodes;
}
public void setNodes(List<String> nodes) {
this.nodes = nodes;
}
public String getCredentialReference() {
return credentialReference;
}
public void setCredentialReference(String credentialReference) {
this.credentialReference = credentialReference;
}
}
/** Transport security. */
public static class Tls {
private boolean enabled;
private boolean hostnameVerification = true;
private String trustMaterialResource;
private String clientCertificateResource;
private String clientKeyReference;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public boolean isHostnameVerification() {
return hostnameVerification;
}
public void setHostnameVerification(boolean hostnameVerification) {
this.hostnameVerification = hostnameVerification;
}
public String getTrustMaterialResource() {
return trustMaterialResource;
}
public void setTrustMaterialResource(String trustMaterialResource) {
this.trustMaterialResource = trustMaterialResource;
}
public String getClientCertificateResource() {
return clientCertificateResource;
}
public void setClientCertificateResource(String clientCertificateResource) {
this.clientCertificateResource = clientCertificateResource;
}
public String getClientKeyReference() {
return clientKeyReference;
}
public void setClientKeyReference(String clientKeyReference) {
this.clientKeyReference = clientKeyReference;
}
}
/** Client lifecycle timings. */
public static class Lifecycle {
private String clientName = "ca-skeleton";
private Duration connectTimeout = Duration.ofSeconds(2);
private Duration tlsHandshakeTimeout = Duration.ofSeconds(3);
private Duration acquireTimeout = Duration.ofSeconds(2);
private Duration shutdownQuietPeriod = Duration.ofMillis(100);
private Duration shutdownTimeout = Duration.ofSeconds(3);
private Duration drainTimeout = Duration.ofSeconds(6);
void validate() {
if (clientName == null || clientName.isBlank()) {
throw new IllegalStateException("the client name must not be blank");
}
requirePositive("connect", connectTimeout);
requirePositive("tls-handshake", tlsHandshakeTimeout);
requirePositive("acquire", acquireTimeout);
requirePositive("shutdown", shutdownTimeout);
requirePositive("drain", drainTimeout);
if (shutdownQuietPeriod == null || shutdownQuietPeriod.isNegative()) {
throw new IllegalStateException("the shutdown quiet period must not be negative");
}
if (shutdownQuietPeriod.compareTo(shutdownTimeout) > 0) {
throw new IllegalStateException(
"the shutdown quiet period must not exceed the shutdown timeout, or shutdown can never"
+ " complete within its own budget");
}
}
private static void requirePositive(String name, Duration value) {
if (value == null || value.isZero() || value.isNegative()) {
throw new IllegalStateException(name + " timeout must be positive");
}
}
public String getClientName() {
return clientName;
}
public void setClientName(String clientName) {
this.clientName = clientName;
}
public Duration getConnectTimeout() {
return connectTimeout;
}
public void setConnectTimeout(Duration connectTimeout) {
this.connectTimeout = connectTimeout;
}
public Duration getTlsHandshakeTimeout() {
return tlsHandshakeTimeout;
}
public void setTlsHandshakeTimeout(Duration tlsHandshakeTimeout) {
this.tlsHandshakeTimeout = tlsHandshakeTimeout;
}
public Duration getAcquireTimeout() {
return acquireTimeout;
}
public void setAcquireTimeout(Duration acquireTimeout) {
this.acquireTimeout = acquireTimeout;
}
public Duration getShutdownQuietPeriod() {
return shutdownQuietPeriod;
}
public void setShutdownQuietPeriod(Duration shutdownQuietPeriod) {
this.shutdownQuietPeriod = shutdownQuietPeriod;
}
public Duration getShutdownTimeout() {
return shutdownTimeout;
}
public void setShutdownTimeout(Duration shutdownTimeout) {
this.shutdownTimeout = shutdownTimeout;
}
public Duration getDrainTimeout() {
return drainTimeout;
}
public void setDrainTimeout(Duration drainTimeout) {
this.drainTimeout = drainTimeout;
}
}
/** Cluster routing. */
public static class Cluster {
private int maximumRedirects = 5;
private Duration topologyRefreshPeriod = Duration.ofSeconds(30);
void validate() {
if (maximumRedirects < 1) {
throw new IllegalStateException("cluster maximum redirects must be positive");
}
if (topologyRefreshPeriod == null
|| topologyRefreshPeriod.isZero()
|| topologyRefreshPeriod.isNegative()) {
throw new IllegalStateException("the cluster topology refresh period must be positive");
}
}
public int getMaximumRedirects() {
return maximumRedirects;
}
public void setMaximumRedirects(int maximumRedirects) {
this.maximumRedirects = maximumRedirects;
}
public Duration getTopologyRefreshPeriod() {
return topologyRefreshPeriod;
}
public void setTopologyRefreshPeriod(Duration topologyRefreshPeriod) {
this.topologyRefreshPeriod = topologyRefreshPeriod;
}
}
/** In-flight capacity ceilings. */
public static class Capacity {
private int maximumInFlightCommands = 64;
private long maximumInFlightBytes = 4L * 1024 * 1024;
private long maximumReplyBytes = 16L * 1024 * 1024;
private boolean rejectWhenDisconnected = true;
void validate() {
if (maximumInFlightCommands < 1 || maximumInFlightBytes < 1 || maximumReplyBytes < 1) {
throw new IllegalStateException("every Redis capacity ceiling must be positive");
}
}
public int getMaximumInFlightCommands() {
return maximumInFlightCommands;
}
public void setMaximumInFlightCommands(int maximumInFlightCommands) {
this.maximumInFlightCommands = maximumInFlightCommands;
}
public long getMaximumInFlightBytes() {
return maximumInFlightBytes;
}
public void setMaximumInFlightBytes(long maximumInFlightBytes) {
this.maximumInFlightBytes = maximumInFlightBytes;
}
public long getMaximumReplyBytes() {
return maximumReplyBytes;
}
public void setMaximumReplyBytes(long maximumReplyBytes) {
this.maximumReplyBytes = maximumReplyBytes;
}
/**
* Reports whether a command issued while the connection is down is refused rather than queued.
*
* <p>Leave this true. Lettuce's default is to hold commands in an offline queue and replay them
* on reconnect, which turns a five-second outage into a burst of writes whose ordering relative
* to everything that happened during the outage is arbitrary.
*
* @return {@code true} when a disconnected client refuses commands
*/
public boolean isRejectWhenDisconnected() {
return rejectWhenDisconnected;
}
public void setRejectWhenDisconnected(boolean rejectWhenDisconnected) {
this.rejectWhenDisconnected = rejectWhenDisconnected;
}
}
/** Subscription delivery. */
public static class PubSub {
private int bufferCapacity = 1_024;
private String overflowPolicy = "error";
void validate() {
if (bufferCapacity < 1) {
throw new IllegalStateException("the pub/sub buffer capacity must be positive");
}
if (!List.of("error", "drop-oldest", "drop-latest").contains(overflowPolicy)) {
throw new IllegalStateException(
"the pub/sub overflow policy must be error, drop-oldest, or drop-latest");
}
}
public int getBufferCapacity() {
return bufferCapacity;
}
public void setBufferCapacity(int bufferCapacity) {
this.bufferCapacity = bufferCapacity;
}
public String getOverflowPolicy() {
return overflowPolicy;
}
public void setOverflowPolicy(String overflowPolicy) {
this.overflowPolicy = overflowPolicy;
}
}
/** Advanced R2 exposure. */
public static class Advanced {
private boolean enabled;
private List<String> policies = new ArrayList<>();
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public List<String> getPolicies() {
return policies;
}
public void setPolicies(List<String> policies) {
this.policies = policies;
}
}
/** Approved raw gateway exposure. */
public static class Raw {
private boolean enabled;
private String policyResource = "classpath:redis-sdk/raw-command-allowlist.yml";
private String credentialReference;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getPolicyResource() {
return policyResource;
}
public void setPolicyResource(String policyResource) {
this.policyResource = policyResource;
}
public String getCredentialReference() {
return credentialReference;
}
public void setCredentialReference(String credentialReference) {
this.credentialReference = credentialReference;
}
}
/** Isolated admin plane exposure. */
public static class Admin {
private boolean enabled;
private String credentialReference;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getCredentialReference() {
return credentialReference;
}
public void setCredentialReference(String credentialReference) {
this.credentialReference = credentialReference;
}
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public RedisDeploymentMode getMode() {
return mode;
}
public void setMode(RedisDeploymentMode mode) {
this.mode = mode;
}
public List<String> getNodes() {
return nodes;
}
public void setNodes(List<String> nodes) {
this.nodes = nodes;
}
public int getDatabase() {
return database;
}
public void setDatabase(int database) {
this.database = database;
}
/**
* Reports whether this deployment has declared that it accepts losing acknowledged writes.
*
* <p>Leave this false unless the trade is deliberate. A replicated deployment without {@code
* min-replicas-to-write} discards writes it told the caller had succeeded, and no client-side
* signal exists for it; see {@link RedisCapabilityProbe#requireWriteDurability}.
*
* @return {@code true} when the loss is accepted
*/
public boolean isAcknowledgedWriteLossAccepted() {
return acknowledgedWriteLossAccepted;
}
public void setAcknowledgedWriteLossAccepted(boolean acknowledgedWriteLossAccepted) {
this.acknowledgedWriteLossAccepted = acknowledgedWriteLossAccepted;
}
public Namespace getNamespace() {
return namespace;
}
public Timeouts getTimeout() {
return timeout;
}
public Limits getLimits() {
return limits;
}
public Blocking getBlocking() {
return blocking;
}
public Transaction getTransaction() {
return transaction;
}
public Advanced getAdvanced() {
return advanced;
}
public Raw getRaw() {
return raw;
}
public Admin getAdmin() {
return admin;
}
public Authentication getAuthentication() {
return authentication;
}
public Sentinel getSentinel() {
return sentinel;
}
public Tls getTls() {
return tls;
}
public Lifecycle getLifecycle() {
return lifecycle;
}
public Cluster getCluster() {
return cluster;
}
public Capacity getCapacity() {
return capacity;
}
public PubSub getPubsub() {
return pubsub;
}
}
@@ -0,0 +1,142 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.config;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisCapabilities;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisCapability;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisVersion;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
/**
* Asks the server what it is, once, at startup.
*
* <p>Configuration says what the deployment intends; only the server says what is true. The version
* a managed Redis advertises does not imply the modules are present, and a replicated deployment's
* write durability is a server setting no client can compensate for. Both are cheap to ask and
* expensive to discover later — a missing capability found at the first request is an outage, found
* here it is a failed deploy.
*
* <p>This type holds no connection. It takes the three facts as inputs so the same logic is
* exercised by unit tests and by the real lanes, and so the caller decides which account asks —
* {@code INFO} and {@code CONFIG GET} are admin-plane, and the application account is denied them.
*/
public final class RedisStartupProbe {
private final RedisCapabilityProbe probe;
/**
* Creates the probe.
*
* @param probe the capability probe
*/
public RedisStartupProbe(RedisCapabilityProbe probe) {
this.probe = Objects.requireNonNull(probe, "capability probe must be non-null");
}
/**
* Confirms the server matches what the deployment declared.
*
* @param settings the validated settings
* @param serverFacts what the server reported
* @param requiredCapabilities capabilities the deployment explicitly enabled
* @return the confirmed capability snapshot
*/
public RedisCapabilities confirm(
RedisSdkSettings settings,
ServerFacts serverFacts,
Collection<RedisCapability> requiredCapabilities) {
Objects.requireNonNull(settings, "settings must be non-null");
Objects.requireNonNull(serverFacts, "server facts must be non-null");
RedisCapabilities capabilities =
probe.probe(
serverFacts.version(),
settings.getMode(),
settings.getDatabase(),
commandPresence(serverFacts.commands()),
requiredCapabilities);
probe.requireWriteDurability(
settings.getMode(),
serverFacts.minReplicasToWrite(),
serverFacts.minReplicasMaxLagSeconds(),
settings.isAcknowledgedWriteLossAccepted());
return capabilities;
}
private static Predicate<CommandId> commandPresence(Set<String> reported) {
// COMMAND INFO answers with the top-level command name; a subcommand's presence follows from
// its container. Matching on the family keeps "does the server have FT.SEARCH" answerable
// without asking the server about every subcommand the catalog knows.
return commandId -> reported.contains(commandId.family().toLowerCase(java.util.Locale.ROOT));
}
/**
* What the server reported at startup.
*
* @param version the version from {@code INFO server}
* @param commands the command names from {@code COMMAND LIST}, lowercased
* @param minReplicasToWrite the server's {@code min-replicas-to-write}
* @param minReplicasMaxLagSeconds the server's {@code min-replicas-max-lag}
*/
public record ServerFacts(
RedisVersion version,
Set<String> commands,
int minReplicasToWrite,
int minReplicasMaxLagSeconds) {
public ServerFacts {
Objects.requireNonNull(version, "version must be non-null");
commands = Set.copyOf(commands);
}
/**
* Reads the facts out of the raw replies, so parsing lives beside the contract it feeds.
*
* @param infoServer the {@code INFO server} payload
* @param commandNames the command names the server reports
* @param configuration the {@code CONFIG GET} projection
* @return the parsed facts
*/
public static ServerFacts from(
String infoServer, List<String> commandNames, Map<String, String> configuration) {
RedisVersion version = null;
for (String line : infoServer.lines().toList()) {
if (line.startsWith("redis_version:")) {
version = RedisVersion.parse(line.substring("redis_version:".length()).strip());
}
}
if (version == null) {
throw new IllegalStateException(
"the server did not report a version; the SDK will not guess one, because every"
+ " capability decision below depends on it");
}
return new ServerFacts(
version,
commandNames.stream()
.map(name -> name.toLowerCase(java.util.Locale.ROOT))
.collect(java.util.stream.Collectors.toUnmodifiableSet()),
intOf(configuration, "min-replicas-to-write"),
intOf(configuration, "min-replicas-max-lag"));
}
private static int intOf(Map<String, String> configuration, String key) {
String value = configuration.get(key);
if (value == null || value.isBlank()) {
// Absent is not zero. A deployment whose admin account cannot read the setting has not
// proven the guarantee, and treating "unknown" as "unset" would fail a correctly
// configured server while treating it as "set" would pass an incorrectly configured one.
// Failing is the safe direction: the message says exactly which grant is missing.
throw new IllegalStateException(
"the server did not report '"
+ key
+ "'. Write durability cannot be confirmed without it; grant the admin account"
+ " +config|get, or set app.redis.acknowledged-write-loss-accepted=true to record"
+ " that this deployment accepts losing acknowledged writes.");
}
return Integer.parseInt(value.strip());
}
}
}
@@ -0,0 +1,321 @@
package dev.caskeleton.adapter.outbound.cache.redis.ratelimit;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisNamespace;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeClient;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.InMemoryGatewayAccess;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
import dev.caskeleton.shared.ratelimit.RateLimitAlgorithm;
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
import dev.caskeleton.shared.ratelimit.RateLimitEvaluationDedupPolicy;
import dev.caskeleton.shared.ratelimit.RateLimitFailurePolicy;
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
import dev.caskeleton.shared.ratelimit.RateLimitPolicy;
import dev.caskeleton.shared.ratelimit.RateLimitRequest;
import dev.caskeleton.shared.ratelimit.RateParameters;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.EnumMap;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* The provider-neutral rate limit, on Redis, without a Redis in sight from the caller's side.
*
* <p>Two things are being asserted. The algorithms bound what they claim to bound — that is the
* feature. And every failure path is closed — that is the safety property, and the one a limiter
* gets wrong in the direction that matters: allowing traffic when the store is unreachable removes
* the bound at exactly the moment the bound is load-bearing.
*/
class RedisEdgeRateLimitAdapterTest {
private static final Instant T0 = Instant.parse("2026-08-10T12:00:00Z");
// The contract bounds both identifiers by pattern. The adapter must satisfy them, not relax them:
// a subject digest short enough to collide, or an evaluation id without a generation prefix, are
// exactly what those patterns exist to keep out of a shared keyspace.
private static final String SUBJECT_A = "aaaaaaaaaaaaaaaa0000";
private static final String SUBJECT_B = "bbbbbbbbbbbbbbbb1111";
private static final String EVALUATION_ID = "ev1:AAAAAAAAAAAAAAAAAAAAAA";
private final InMemoryGatewayAccess gateway = InMemoryGatewayAccess.create();
private final RateLimitKeys keys =
new RateLimitKeys(new RedisNamespace("prod", "ca-skeleton", "shared"), 1);
private RedisEdgeRateLimitAdapter adapter(RateLimitPolicy policy, Clock clock) {
return adapter(policy, clock, new StubClient(gateway.gateway()));
}
private RedisEdgeRateLimitAdapter adapter(
RateLimitPolicy policy, Clock clock, RedisRuntimeClient client) {
Map<RedisConnectionKind, Integer> limits = new EnumMap<>(RedisConnectionKind.class);
for (RedisConnectionKind kind : RedisConnectionKind.values()) {
limits.put(kind, 4);
}
return new RedisEdgeRateLimitAdapter(
new RedisRuntimeOwner(client, limits, Duration.ofSeconds(1)),
keys,
Map.of(policy.policyId(), policy),
new RateLimitScripts(),
clock,
Duration.ofSeconds(2),
Duration.ofMillis(100));
}
private static RateLimitPolicy policy(RateParameters parameters, RateLimitAlgorithm algorithm) {
return new RateLimitPolicy(
"api-default",
"v1",
algorithm,
parameters,
// The contract caps a single request's cost at the algorithm's own budget: a cost that can
// never be satisfied is a configuration error, not a permanently denied caller.
maximumCostOf(parameters),
Duration.ofSeconds(5),
Duration.ofMillis(250),
RateLimitFailurePolicy.FAIL_CLOSED,
new RateLimitEvaluationDedupPolicy(false, Duration.ZERO, 0, 0));
}
private static long maximumCostOf(RateParameters parameters) {
return switch (parameters) {
case RateParameters.FixedWindow window -> window.limit();
case RateParameters.SlidingCounter sliding -> sliding.limit();
case RateParameters.TokenBucket bucket -> bucket.capacity();
default -> throw new IllegalStateException("unsupported parameters");
};
}
private static RateLimitRequest request(long cost) {
return new RateLimitRequest(
"api-default", SUBJECT_A, cost, EVALUATION_ID, T0.plusSeconds(3600));
}
@Test
@DisplayName("a fixed window allows up to its limit and then denies with a positive wait")
void aFixedWindowBoundsItsWindow() {
RateLimitPolicy policy =
policy(
new RateParameters.FixedWindow(3, Duration.ofSeconds(60)),
RateLimitAlgorithm.FIXED_WINDOW);
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
for (int allowed = 0; allowed < 3; allowed++) {
assertThat(decision(adapter.evaluate(request(1))).allowed())
.as("request %s of the budget", allowed + 1)
.isTrue();
}
RateLimitDecision denied = decision(adapter.evaluate(request(1)));
assertThat(denied.allowed()).isFalse();
assertThat(denied.remaining()).isZero();
assertThat(denied.retryAfter()).isPositive();
assertThat(denied.source()).isEqualTo(RateLimitDecision.DecisionSource.GLOBAL_REDIS);
assertThat(denied.certainty()).isEqualTo(RateLimitDecision.DecisionCertainty.CERTAIN);
}
@Test
@DisplayName("a new window restores the budget")
void anewWindowRestoresTheBudget() {
RateLimitPolicy policy =
policy(
new RateParameters.FixedWindow(2, Duration.ofSeconds(60)),
RateLimitAlgorithm.FIXED_WINDOW);
RedisEdgeRateLimitAdapter spent = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
spent.evaluate(request(1));
spent.evaluate(request(1));
assertThat(decision(spent.evaluate(request(1))).allowed()).isFalse();
RedisEdgeRateLimitAdapter next =
adapter(policy, Clock.fixed(T0.plusSeconds(60), ZoneOffset.UTC));
assertThat(decision(next.evaluate(request(1))).allowed()).isTrue();
}
@Test
@DisplayName("a sliding counter reports itself as approximate")
void aSlidingCounterIsHonestAboutBeingApproximate() {
RateLimitPolicy policy =
policy(
new RateParameters.SlidingCounter(5, Duration.ofSeconds(60)),
RateLimitAlgorithm.SLIDING_COUNTER);
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
// Interpolating across two windows is a deliberate memory trade, and the caller is told, since
// "approximate" and "certain" are different things to build an abuse decision on.
assertThat(decision(adapter.evaluate(request(1))).certainty())
.isEqualTo(RateLimitDecision.DecisionCertainty.APPROXIMATE_ALGORITHM);
}
@Test
@DisplayName("a token bucket refills by elapsed periods, not by wall-clock jumps")
void aTokenBucketRefillsByPeriod() {
RateLimitPolicy policy =
policy(
new RateParameters.TokenBucket(2, 1, Duration.ofSeconds(10)),
RateLimitAlgorithm.TOKEN_BUCKET);
RedisEdgeRateLimitAdapter start = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
assertThat(decision(start.evaluate(request(1))).allowed()).isTrue();
assertThat(decision(start.evaluate(request(1))).allowed()).isTrue();
assertThat(decision(start.evaluate(request(1))).allowed()).isFalse();
// Half a period buys nothing; a whole one buys exactly one token.
RedisEdgeRateLimitAdapter halfway =
adapter(policy, Clock.fixed(T0.plusSeconds(5), ZoneOffset.UTC));
assertThat(decision(halfway.evaluate(request(1))).allowed()).isFalse();
RedisEdgeRateLimitAdapter refilled =
adapter(policy, Clock.fixed(T0.plusSeconds(10), ZoneOffset.UTC));
assertThat(decision(refilled.evaluate(request(1))).allowed()).isTrue();
assertThat(decision(refilled.evaluate(request(1))).allowed()).isFalse();
}
@Test
@DisplayName("an unreachable Redis denies rather than allows")
void anUnreachableRedisFailsClosed() {
RateLimitPolicy policy =
policy(
new RateParameters.FixedWindow(5, Duration.ofSeconds(60)),
RateLimitAlgorithm.FIXED_WINDOW);
RedisEdgeRateLimitAdapter adapter =
adapter(policy, Clock.fixed(T0, ZoneOffset.UTC), new BrokenClient());
RateLimitOutcome outcome = adapter.evaluate(request(1));
// Never Evaluated(allowed). A limiter that opens up during an outage is not a limiter, and the
// outage is exactly when the bound matters.
assertThat(outcome).isInstanceOf(RateLimitOutcome.Unavailable.class);
assertThat(((RateLimitOutcome.Unavailable) outcome).retryAfter()).isPositive();
}
@Test
@DisplayName("an unknown policy is a deployment error, not an allowance")
void anUnknownPolicyIsIncompatible() {
RateLimitPolicy policy =
policy(
new RateParameters.FixedWindow(5, Duration.ofSeconds(60)),
RateLimitAlgorithm.FIXED_WINDOW);
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
RateLimitOutcome outcome =
adapter.evaluate(
new RateLimitRequest(
"other-policy", SUBJECT_A, 1, EVALUATION_ID, T0.plusSeconds(3600)));
assertThat(outcome).isInstanceOf(RateLimitOutcome.Incompatible.class);
}
@Test
@DisplayName("a cost above the policy ceiling is refused rather than clamped")
void anOversizedCostIsRefused() {
RateLimitPolicy policy =
policy(
new RateParameters.FixedWindow(5, Duration.ofSeconds(60)),
RateLimitAlgorithm.FIXED_WINDOW);
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
assertThat(adapter.evaluate(request(6))).isInstanceOf(RateLimitOutcome.Incompatible.class);
}
@Test
@DisplayName("a caller whose deadline already passed is told now, not after a round trip")
void anExpiredCallerDeadlineIsRejectedImmediately() {
RateLimitPolicy policy =
policy(
new RateParameters.FixedWindow(5, Duration.ofSeconds(60)),
RateLimitAlgorithm.FIXED_WINDOW);
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
RateLimitOutcome outcome =
adapter.evaluate(
new RateLimitRequest("api-default", SUBJECT_A, 1, EVALUATION_ID, T0.minusMillis(1)));
assertThat(outcome).isInstanceOf(RateLimitOutcome.Unavailable.class);
assertThat(((RateLimitOutcome.Unavailable) outcome).category())
.isEqualTo(RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED);
}
@Test
@DisplayName("different subjects have separate budgets")
void subjectsAreIsolated() {
RateLimitPolicy policy =
policy(
new RateParameters.FixedWindow(1, Duration.ofSeconds(60)),
RateLimitAlgorithm.FIXED_WINDOW);
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
assertThat(decision(adapter.evaluate(request(1))).allowed()).isTrue();
assertThat(decision(adapter.evaluate(request(1))).allowed()).isFalse();
assertThat(
decision(
adapter.evaluate(
new RateLimitRequest(
"api-default", SUBJECT_B, 1, EVALUATION_ID, T0.plusSeconds(3600))))
.allowed())
.isTrue();
}
private static RateLimitDecision decision(RateLimitOutcome outcome) {
assertThat(outcome).isInstanceOf(RateLimitOutcome.Evaluated.class);
return ((RateLimitOutcome.Evaluated) outcome).decision();
}
/** Hands out the shared in-memory gateway; the owner's pooling makes it one logical server. */
private record StubClient(RedisCommandGateway gateway) implements RedisRuntimeClient {
@Override
public RedisDeploymentMode mode() {
return RedisDeploymentMode.STANDALONE;
}
@Override
public RedisLaneConnection openLane(
RedisConnectionKind kind, java.util.Optional<byte[]> routingKey) {
return new RedisLaneConnection() {
@Override
public RedisCommandGateway gateway() {
return gateway;
}
@Override
public boolean open() {
return true;
}
@Override
public void close() {}
};
}
@Override
public void close() {}
}
/** A server that cannot be reached at all. */
private static final class BrokenClient implements RedisRuntimeClient {
@Override
public RedisDeploymentMode mode() {
return RedisDeploymentMode.STANDALONE;
}
@Override
public RedisLaneConnection openLane(
RedisConnectionKind kind, java.util.Optional<byte[]> routingKey) {
throw new IllegalStateException("the server is unreachable");
}
@Override
public void close() {}
}
}
@@ -0,0 +1,244 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode;
import io.lettuce.core.RedisURI;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
/**
* The endpoint a topology lane was pointed at.
*
* <p>Resolution fails rather than returning a default. A topology test that quietly falls back to
* {@code localhost:6379} either tests the wrong thing or passes because nothing answered, and both
* are indistinguishable from success in a CI log.
*
* <p>The endpoint is not always the data node. On the Sentinel lane the declared address is a
* <em>sentinel</em>, and the primary has to be resolved from it — which is the whole point of the
* lane, because the address the client dials changes when the primary is promoted. {@link
* #dataUri()} is therefore the only supported way to reach data: a test that builds its own URI
* from {@link #host()} and {@link #port()} would connect to a sentinel and then assert against the
* wrong server.
*
* @param host the declared host
* @param port the declared port
* @param mode the deployment mode the lane represents
* @param masterId the monitored primary's name, present on the Sentinel lane only
* @param username the ACL account the data connection authenticates as
* @param password that account's password
* @param trustMaterial the CA the lane's server certificate is signed by, on the TLS lane only
*/
public record RedisTopologyEndpoint(
String host,
int port,
RedisDeploymentMode mode,
Optional<String> masterId,
String username,
String password,
Optional<String> trustMaterial) {
/** Canonical constructor. */
public RedisTopologyEndpoint {
Objects.requireNonNull(host, "host must be non-null");
Objects.requireNonNull(mode, "mode must be non-null");
Objects.requireNonNull(masterId, "master identifier must be non-null");
if (host.isBlank()) {
throw new IllegalArgumentException("the topology host must not be blank");
}
if (port < 1 || port > 65_535) {
throw new IllegalArgumentException("the topology port must be a valid port number");
}
Objects.requireNonNull(username, "username must be non-null");
Objects.requireNonNull(password, "password must be non-null");
Objects.requireNonNull(trustMaterial, "trust material must be non-null");
if (username.isBlank()) {
throw new IllegalArgumentException(
"the lane must authenticate as a named ACL account; the fixture disables the default"
+ " user precisely so an unauthenticated connection cannot pass for a working one");
}
if (mode == RedisDeploymentMode.SENTINEL && masterId.isEmpty()) {
throw new IllegalArgumentException(
"the sentinel lane needs the monitored primary's name; without it the client cannot"
+ " resolve a primary at all, let alone follow a promotion");
}
}
/**
* Resolves the endpoint from the system properties the lane sets.
*
* @return the endpoint
* @throws IllegalStateException when the lane was selected without an endpoint
*/
public static RedisTopologyEndpoint fromSystemProperties() {
RedisDeploymentMode mode =
RedisDeploymentMode.valueOf(require("redis.topology.mode").toUpperCase(Locale.ROOT));
Optional<String> masterId =
mode == RedisDeploymentMode.SENTINEL
? Optional.of(require("redis.topology.master"))
: Optional.ofNullable(System.getProperty("redis.topology.master"))
.filter(value -> !value.isBlank());
return new RedisTopologyEndpoint(
require("redis.topology.host"),
Integer.parseInt(require("redis.topology.port")),
mode,
masterId,
// The lane's ACL fixture disables `default`, so every data connection authenticates as a
// named account. Defaulting to the application account keeps the common case one flag
// shorter while still going through AUTH — which is what the qualification has to prove.
System.getProperty("redis.topology.username", "ca-skeleton-application"),
// The fixture accounts carry real passwords. They were `nopass`, which accepts any
// password at all — so every assertion about authentication passed for the same reason a
// wrong password would have, and rotation and wrong-password coverage was false-green.
System.getProperty("redis.topology.password", "fixture-application"),
// Present exactly on the TLS lane. The build passes it, so a lane that forgot it fails in
// the task rather than by silently connecting without verification.
Optional.ofNullable(System.getProperty("redis.topology.trust-material"))
.filter(value -> !value.isBlank()));
}
/**
* Reports whether this lane speaks TLS.
*
* @return {@code true} when the lane's server has no plaintext port at all
*/
public boolean tls() {
return Boolean.parseBoolean(System.getProperty("redis.topology.tls", "false"));
}
/**
* Returns the CA the lane's server certificate is signed by.
*
* @return the configured trust material location
* @throws IllegalStateException when the lane is not a TLS lane
*/
public String requireTrustMaterial() {
return trustMaterial.orElseThrow(
() -> new IllegalStateException("only the TLS lane declares trust material"));
}
/**
* Returns the URI a data connection must be opened with.
*
* @return a sentinel-resolving URI on the Sentinel lane, the declared address otherwise
*/
public RedisURI dataUri() {
RedisURI uri =
switch (mode) {
case SENTINEL -> RedisURI.Builder.sentinel(host, port, masterId.orElseThrow()).build();
case STANDALONE, CLUSTER -> RedisURI.create(host, port);
};
uri.setCredentialsProvider(
io.lettuce.core.RedisCredentialsProvider.from(
() -> io.lettuce.core.RedisCredentials.just(username, password.toCharArray())));
return uri;
}
/**
* Returns the URI a diagnostic connection must be opened with.
*
* <p>Separate from {@link #dataUri()} because the accounts are separate, and deliberately so. The
* application account cannot run {@code INFO} — the ACL fixture denies it, exactly as the SDK's
* own admin plane models it — so a lane that probes the server version over the data connection
* gets {@code NOPERM}. That is the fixture working, not a fixture bug: reaching diagnostics
* requires holding the admin account.
*
* @return the declared address, authenticated as the read-only admin account
*/
public RedisURI adminUri() {
RedisURI uri =
switch (mode) {
case SENTINEL -> RedisURI.Builder.sentinel(host, port, masterId.orElseThrow()).build();
case STANDALONE, CLUSTER -> RedisURI.create(host, port);
};
uri.setCredentialsProvider(
io.lettuce.core.RedisCredentialsProvider.from(
() ->
io.lettuce.core.RedisCredentials.just(
System.getProperty(
"redis.topology.admin-username", "ca-skeleton-admin-readonly"),
System.getProperty("redis.topology.admin-password", "fixture-admin")
.toCharArray())));
return uri;
}
/**
* Returns the URI a Sentinel control connection must be opened with.
*
* <p>Deliberately credential-free, and that is not an oversight. A sentinel is a different
* process with its own ACL: it does not load the data nodes' {@code aclfile}, so the accounts in
* {@code infra/redis-sdk/acl} do not exist there and presenting one gets {@code WRONGPASS}. The
* {@code sentinel auth-user} / {@code auth-pass} directives in the lane are about how the
* sentinel authenticates <em>to the monitored primary</em>, which is a different direction
* entirely. Securing the sentinels themselves would mean giving them their own ACL file, and the
* lane deliberately does not, because a sentinel port is not a data path.
*
* @return the declared sentinel address
*/
public RedisURI sentinelControlUri() {
return RedisURI.create(host, port);
}
/**
* Returns the URI for a specific data node the lane discovered, authenticated as the data
* account.
*
* <p>A promotion test has to dial the node Sentinel just named, not the declared address, so the
* host and port come from the caller while the credentials stay the lane's.
*
* @param nodeHost the discovered host
* @param nodePort the discovered port
* @return the authenticated URI
*/
public RedisURI dataNodeUri(String nodeHost, int nodePort) {
RedisURI uri = RedisURI.create(nodeHost, nodePort);
uri.setCredentialsProvider(
io.lettuce.core.RedisCredentialsProvider.from(
() -> io.lettuce.core.RedisCredentials.just(username, password.toCharArray())));
return uri;
}
/**
* Returns the URI for cluster provisioning writes against a specific node.
*
* <p>{@code CLUSTER SETSLOT} and friends are administrative writes. They are absent from the
* read-only admin account on purpose — an account named {@code admin-readonly} that can reshard a
* cluster is misnamed — so a test that drives a migration authenticates as the provisioning
* identity the lane also uses to build the cluster.
*
* @param nodeHost the node's host
* @param nodePort the node's port
* @return the authenticated URI
*/
public RedisURI provisioningUri(String nodeHost, int nodePort) {
RedisURI uri = RedisURI.create(nodeHost, nodePort);
uri.setCredentialsProvider(
io.lettuce.core.RedisCredentialsProvider.from(
() ->
io.lettuce.core.RedisCredentials.just(
System.getProperty(
"redis.topology.provisioning-username", "ca-skeleton-cluster-bootstrap"),
System.getProperty("redis.topology.provisioning-password", "fixture-bootstrap")
.toCharArray())));
return uri;
}
/**
* Returns the monitored primary's name.
*
* @return the name
* @throws IllegalStateException when the lane is not a Sentinel lane
*/
public String requireMasterId() {
return masterId.orElseThrow(
() -> new IllegalStateException("only the sentinel lane declares a monitored primary"));
}
private static String require(String key) {
String value = System.getProperty(key);
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"the redis-topology lane requires -D" + key + "; it must never be skipped silently");
}
return value;
}
}
@@ -0,0 +1,176 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.config;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.RedisTopologyEndpoint;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisLease;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeClient;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.boot.health.contributor.Status;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
/**
* The composition root, against a server that is actually there.
*
* <p>Every other test of this configuration uses endpoints nothing answers on, which proves the
* bean graph and nothing about whether the graph works. These are the three claims that can only be
* settled by connecting: the mode produced the client the topology needs, a lease borrowed from the
* owner reaches Redis, and shutting the context down leaves nothing behind.
*/
@Tag("redis-topology")
@Tag("lane-standalone")
@Tag("lane-sentinel")
@Tag("lane-cluster")
class LiveRedisCompositionTest {
private final RedisTopologyEndpoint endpoint = RedisTopologyEndpoint.fromSystemProperties();
private ApplicationContextRunner runner() {
return runner("fixture-application");
}
private ApplicationContextRunner runner(String password) {
ApplicationContextRunner runner =
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(RedisSdkAutoConfiguration.class))
.withBean(
RedisSdkAutoConfiguration.RedisSecretSource.class,
// The fixture account's real password. It used to be `nopass`, so this could have
// been any string at all and the lane would still have connected — which is why
// there was nothing to distinguish a working credential from a wrong one.
() -> name -> Optional.of(password))
.withPropertyValues(
"app.redis.enabled=true",
"app.redis.mode=" + endpoint.mode().name().toLowerCase(java.util.Locale.ROOT),
"app.redis.nodes=" + endpoint.host() + ":" + endpoint.port(),
// The lane's ACL disables `default`, so the composition authenticates as a named
// account exactly as a deployment would. A reference, not a value.
"app.redis.authentication.credential-reference=secret://ca-skeleton-application@environment/APP_REDIS_PASSWORD",
"app.redis.namespace.environment=prod",
"app.redis.namespace.service=order",
"app.redis.namespace.domain=shared");
return endpoint.mode()
== dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode.SENTINEL
? runner.withPropertyValues("app.redis.sentinel.master-name=" + endpoint.requireMasterId())
: runner;
}
@Test
@DisplayName("a wrong password is refused, so a right one proves something")
void aWrongPasswordIsRefused() throws Exception {
// The assertion the `nopass` fixture could never carry. With an account that accepts anything,
// every credential test passed for the same reason a typo would have, and the lane's coverage
// of authentication, rotation and secret wiring was indistinguishable from no coverage.
runner("not-the-fixture-password")
.run(
context -> {
assertThat(context).hasNotFailed();
RedisRuntimeOwner owner = context.getBean(RedisRuntimeOwner.class);
org.assertj.core.api.Assertions.assertThatThrownBy(
() -> {
try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) {
lease.gateway().ping().toCompletableFuture().get(5, TimeUnit.SECONDS);
}
})
.as("authentication is actually enforced by the fixture")
.rootCause()
.hasMessageContaining("WRONGPASS");
});
}
@Test
@DisplayName("the configured mode produces exactly one client of the matching topology")
void theModeProducesOneMatchingClient() {
runner()
.run(
context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(RedisRuntimeClient.class);
assertThat(context.getBean(RedisRuntimeClient.class).mode())
.isEqualTo(endpoint.mode());
assertThat(context).hasSingleBean(RedisRuntimeOwner.class);
});
}
@Test
@DisplayName("a lease borrowed from the composed owner reaches the server")
void aLeaseReachesTheServer() throws Exception {
runner()
.run(
context -> {
RedisRuntimeOwner owner = context.getBean(RedisRuntimeOwner.class);
assertThat(owner.state()).isEqualTo(RedisRuntimeOwner.State.OPEN);
try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) {
String reply =
lease.gateway().ping().toCompletableFuture().get(5, TimeUnit.SECONDS);
assertThat(reply).isEqualTo("PONG");
}
assertThat(owner.outstanding(RedisConnectionKind.REGULAR))
.as("the lease was returned, not leaked")
.isZero();
});
}
@Test
@DisplayName("the optional health contributor reports UP against a live server")
void theOptionalContributorReportsUp() {
runner()
.run(
context -> {
HealthIndicator optional = (HealthIndicator) context.getBean("redisOptional");
assertThat(optional.health().getStatus()).isEqualTo(Status.UP);
});
}
@Test
@DisplayName("closing the context drains, closes connections, then shuts the client down")
void closingTheContextTearsEverythingDown() {
RedisRuntimeOwner[] captured = new RedisRuntimeOwner[1];
int threadsBefore = redisThreadCount();
runner()
.run(
context -> {
captured[0] = context.getBean(RedisRuntimeOwner.class);
captured[0].borrow(RedisConnectionKind.REGULAR).close();
});
assertThat(captured[0].state()).isEqualTo(RedisRuntimeOwner.State.CLOSED);
// The event loop is what a leaked client leaves behind, and it is invisible to a bean-graph
// assertion. Lettuce's threads are named, so counting them is a direct check.
await(() -> redisThreadCount() <= threadsBefore, Duration.ofSeconds(10));
assertThat(redisThreadCount())
.as("no Lettuce event-loop threads outlive the context")
.isLessThanOrEqualTo(threadsBefore);
}
private static int redisThreadCount() {
return (int)
Thread.getAllStackTraces().keySet().stream()
.map(Thread::getName)
.filter(name -> name.startsWith("lettuce-"))
.count();
}
private static void await(java.util.function.BooleanSupplier condition, Duration budget) {
long deadline = System.nanoTime() + budget.toNanos();
while (System.nanoTime() < deadline && !condition.getAsBoolean()) {
try {
Thread.sleep(50);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return;
}
}
}
}
@@ -0,0 +1,437 @@
package dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.RedisTopologyEndpoint;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisVersion;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.RedisCommandDescriptor;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error.RedisFailureMetadata;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error.RedisOperationException;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.ListKey;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.codec.Utf8StringCodec;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.ExecutionCertainty;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.RedisCommandCatalog;
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.SentinelFailoverObserver;
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.sentinel.api.StatefulRedisSentinelConnection;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
/**
* What a Sentinel promotion does to work that is in flight.
*
* <p>The unit suite can prove that {@link SentinelFailoverObserver} counts what it is told and that
* {@link ExecutionCertainty} refuses to retry a non-idempotent write. It cannot prove the thing
* those two types exist for: that the certainty the SDK reports to a caller is <em>true</em> of the
* server afterwards. Only a real promotion settles that.
*
* <p>The experiment runs once in {@link #promote()} and every test below asserts a different
* falsifiable claim about that one recorded run. Each write carries a token that is unique across
* the run, so the list on the promoted primary is a verbatim record of what actually happened and
* the SDK's per-call verdict can be checked against it one token at a time.
*
* <p>The two claims with teeth are that a token whose call the SDK reported as <em>definitely not
* applied</em> must be absent from the promoted primary, and that no token may appear twice —
* {@code RPUSH} is not retry-safe, and the guardrails forbid anything in the stack from resending
* it on its own. Both fail loudly if the certainty model is wishful thinking rather than a
* description of the driver underneath it.
*/
@Tag("redis-topology")
@Tag("lane-sentinel")
class LiveRedisSentinelPromotionTest {
/** The write that must survive the promotion; it is confirmed and replicated beforehand. */
private static final String REPLICATED = "replicated-before-promotion";
/** How long the write loop may run before the lane is declared broken. */
private static final Duration EXPERIMENT_CEILING = Duration.ofSeconds(90);
/** Confirmed writes required after the promotion before the run is considered settled. */
private static final int SETTLED_WRITES = 50;
/** Attempts to confirm before the failover is requested. */
private static final int WARMUP_WRITES = 20;
/** Pause between attempts; small enough to land inside a promotion, large enough not to spin. */
private static final Duration ATTEMPT_INTERVAL = Duration.ofMillis(5);
/**
* The {@code min-replicas-max-lag} the lane configures.
*
* <p>This is the width of the window in which a superseded primary can still acknowledge a write
* that is about to be discarded, so it is also the bound on how many acknowledged writes a
* promotion may destroy. Doubling it leaves room for scheduling jitter without leaving room for
* the unbounded behaviour the setting exists to prevent.
*/
private static final Duration REPLICA_LAG_CEILING = Duration.ofSeconds(1);
private static RedisTopologyEndpoint endpoint;
private static RedisClient controlClient;
private static StatefulRedisSentinelConnection<String, String> sentinel;
private static LiveRedisOperationsFixture fixture;
private static ListKey<String> log;
private static String renderedLogKey;
private static InetSocketAddress primaryBefore;
private static InetSocketAddress primaryAfter;
private static final List<Attempt> ATTEMPTS = new ArrayList<>();
private static List<String> tokensOnPromotedPrimary = List.of();
private static SentinelFailoverObserver observer;
private static Duration observedReconnect = Duration.ZERO;
/** One write attempt and the verdict the SDK returned for it. */
private record Attempt(String token, Optional<RedisOperationException> failure) {
boolean confirmed() {
return failure.isEmpty();
}
/** Reports whether the SDK told the caller the write definitely did not take effect. */
boolean reportedAsNotApplied() {
return failure.map(value -> !value.metadata().ambiguousExecution()).orElse(false);
}
/** Reports whether the SDK left the outcome open. */
boolean reportedAsAmbiguous() {
return failure.map(value -> value.metadata().ambiguousExecution()).orElse(false);
}
Optional<RedisFailureMetadata> metadata() {
return failure.map(RedisOperationException::metadata);
}
}
@BeforeAll
static void promote() throws InterruptedException {
endpoint = RedisTopologyEndpoint.fromSystemProperties();
controlClient = RedisClient.create();
// The sentinels themselves run with `default` off, so the control connection authenticates as
// the sentinel account. Before that account existed this connected as `default` — which is why
// hardening the fixture broke this lane rather than the lane proving the hardening.
sentinel = controlClient.connectSentinel(endpoint.sentinelControlUri());
primaryBefore = resolvePrimary();
fixture =
new LiveRedisOperationsFixture(
endpoint, RedisVersion.parse("7.4.0"), Duration.ofMillis(500));
log = fixture.keys.list("failover", "promotion-log", Utf8StringCodec.instance());
renderedLogKey = fixture.renderer.render(log.key());
observer = new SentinelFailoverObserver(1_000);
// The lane requires an in-sync replica before it will accept a write at all, so the run cannot
// start until one is attached. Writing first and discovering NOREPLICAS would report a lane
// that was merely still starting up as a promotion that destroyed data.
awaitInSyncReplica(primaryBefore);
clearLog(primaryBefore);
// If this one is missing afterwards the lane promoted a replica that never carried the data,
// and every other assertion in this class would be measuring the wrong thing.
fixture.lists.pushRight(log, List.of(REPLICATED));
requireReplication(primaryBefore);
runWriteLoopAcrossAPromotion();
primaryAfter = resolvePrimary();
observer.recordPromotion(observedReconnect);
RedisCommandDescriptor push =
RedisCommandCatalog.loadDefault().require(CommandId.parse("RPUSH")).descriptor();
for (Attempt attempt : ATTEMPTS) {
if (!attempt.confirmed()) {
observer.classify(push, attempt.reportedAsAmbiguous());
}
}
tokensOnPromotedPrimary = readLog(primaryAfter);
report();
}
private static void runWriteLoopAcrossAPromotion() throws InterruptedException {
Instant deadline = Instant.now().plus(EXPERIMENT_CEILING);
boolean failoverRequested = false;
Instant firstFailure = null;
int confirmedAfterFirstFailure = 0;
int index = 0;
while (Instant.now().isBefore(deadline)) {
Attempt attempt = attempt("token-" + index++);
ATTEMPTS.add(attempt);
if (!failoverRequested && attempt.confirmed() && index >= WARMUP_WRITES) {
sentinel.sync().failover(endpoint.requireMasterId());
failoverRequested = true;
}
if (failoverRequested && !attempt.confirmed() && firstFailure == null) {
firstFailure = Instant.now();
}
if (firstFailure != null && attempt.confirmed()) {
if (observedReconnect.isZero()) {
observedReconnect = Duration.between(firstFailure, Instant.now());
}
if (++confirmedAfterFirstFailure >= SETTLED_WRITES) {
return;
}
}
Thread.sleep(ATTEMPT_INTERVAL.toMillis());
}
}
/**
* Prints what the promotion actually did.
*
* <p>This lane exists to find out how the driver behaves, so the distribution of failure types is
* evidence in its own right and is recorded in the run log rather than only in an assertion
* message.
*/
private static void report() {
Map<String, Long> byType = new LinkedHashMap<>();
for (Attempt attempt : ATTEMPTS) {
attempt.failure.ifPresent(
failure ->
byType.merge(
failure.getClass().getSimpleName()
+ (failure.metadata().ambiguousExecution() ? " [ambiguous]" : " [not-run]")
+ " "
+ failure.getMessage()
+ " <- "
+ rootCause(failure),
1L,
Long::sum));
}
Set<String> present = new LinkedHashSet<>(tokensOnPromotedPrimary);
long acknowledgedButLost =
ATTEMPTS.stream()
.filter(Attempt::confirmed)
.map(Attempt::token)
.filter(token -> !present.contains(token))
.count();
System.out.println("[sentinel] primary " + primaryBefore + " -> " + primaryAfter);
System.out.println(
"[sentinel] attempts="
+ ATTEMPTS.size()
+ " confirmed="
+ ATTEMPTS.stream().filter(Attempt::confirmed).count()
+ " reconnect="
+ observedReconnect
+ " stored="
+ tokensOnPromotedPrimary.size()
+ " acknowledged-but-lost="
+ acknowledgedButLost);
byType.forEach((type, count) -> System.out.println("[sentinel] " + count + "x " + type));
}
private static String rootCause(Throwable failure) {
Throwable current = failure;
while (current.getCause() != null) {
current = current.getCause();
}
return current.getClass().getName() + ": " + current.getMessage();
}
@AfterAll
static void disconnect() {
if (fixture != null) {
fixture.close();
}
if (sentinel != null) {
sentinel.close();
}
if (controlClient != null) {
controlClient.shutdown();
}
}
@Test
@DisplayName("the lane actually promoted a different node")
void promotionHappened() {
assertThat(primaryAfter)
.as("Sentinel resolves the same address before and after; nothing was promoted")
.isNotEqualTo(primaryBefore);
}
@Test
@DisplayName("the client followed the promotion and writes land on the new primary")
void clientFollowedThePromotion() {
// Deliberately not asserting that the caller saw a failure. The first runs of this lane showed
// a promotion that cost the caller nothing visible at all — sixteen thousand attempts, zero
// exceptions — while two thousand acknowledged writes were being discarded. Requiring a
// visible interruption would have turned that into a red test for the wrong reason and hidden
// the finding behind it.
assertThat(ATTEMPTS.stream().filter(Attempt::confirmed).toList())
.hasSizeGreaterThan(SETTLED_WRITES);
assertThat(tokensOnPromotedPrimary)
.as("the replicated pre-promotion write did not survive the promotion")
.contains(REPLICATED);
}
@Test
@DisplayName("a promotion destroys no more acknowledged writes than the replica lag allows")
void acknowledgedWriteLossIsBounded() {
Set<String> present = new LinkedHashSet<>(tokensOnPromotedPrimary);
List<String> lost =
ATTEMPTS.stream()
.filter(Attempt::confirmed)
.map(Attempt::token)
.filter(token -> !present.contains(token))
.toList();
long allowed = 2 * REPLICA_LAG_CEILING.dividedBy(ATTEMPT_INTERVAL);
// This is the assertion the lane was built for. A superseded primary that still has an in-sync
// replica requirement stops acknowledging writes about one lag-window after it is orphaned;
// one that does not keeps saying +OK until Sentinel demotes it, which took eleven seconds and
// cost two thousand acknowledged writes when this was first measured. If the requirement is
// ever dropped from the lane, this count jumps by an order of magnitude and says so.
assertThat((long) lost.size())
.as(
"%d acknowledged writes were discarded by the promotion; the configured replica lag"
+ " allows at most %d, so the superseded primary was acknowledging writes it could"
+ " not keep",
lost.size(), allowed)
.isLessThanOrEqualTo(allowed);
}
@Test
@DisplayName("a write the SDK reported as not applied is absent from the promoted primary")
void reportedFailuresDidNotApply() {
Set<String> present = new LinkedHashSet<>(tokensOnPromotedPrimary);
assertThat(
ATTEMPTS.stream()
.filter(Attempt::reportedAsNotApplied)
.map(Attempt::token)
.filter(present::contains)
.toList())
.as("the SDK told the caller these writes definitely did not run, and they did")
.isEmpty();
}
@Test
@DisplayName("every failure carries a coherent verdict")
void everyFailureIsClassified() {
List<RedisFailureMetadata> failures =
ATTEMPTS.stream().flatMap(attempt -> attempt.metadata().stream()).toList();
assertThat(failures)
.allSatisfy(
metadata -> {
assertThat(metadata.readOperation()).isFalse();
assertThat(metadata.retryable() && metadata.ambiguousExecution())
.as("an ambiguous write must never be advertised as retryable")
.isFalse();
assertThat(metadata.retryable())
.as("RPUSH is not retry-safe, so no verdict may mark it retryable")
.isFalse();
});
}
@Test
@DisplayName("no write is applied twice across the promotion")
void nonIdempotentWritesAreNeverReplayed() {
assertThat(tokensOnPromotedPrimary)
.as("a token appears more than once; a non-retry-safe write was resent by the stack")
.doesNotHaveDuplicates();
}
@Test
@DisplayName("nothing reached the server that the test never issued")
void serverStateIsExplainedByTheRun() {
Set<String> issued = new LinkedHashSet<>(ATTEMPTS.stream().map(Attempt::token).toList());
issued.add(REPLICATED);
assertThat(issued).containsAll(tokensOnPromotedPrimary);
}
@Test
@DisplayName("the observer's account of the promotion matches the run")
void observerMatchesTheRun() {
long ambiguous = ATTEMPTS.stream().filter(Attempt::reportedAsAmbiguous).count();
assertThat(observer.promotionCount()).isEqualTo(1);
assertThat(observer.ambiguousWriteCount())
.as("every ambiguous non-retry-safe write around a promotion is one to reconcile")
.isEqualTo(ambiguous);
assertThat(observer.longestReconnect()).isEqualTo(observedReconnect);
assertThat(observer.refusedWhileReconnectingCount()).isZero();
}
private static Attempt attempt(String token) {
try {
fixture.lists.pushRight(log, List.of(token));
return new Attempt(token, Optional.empty());
} catch (RedisOperationException failure) {
return new Attempt(token, Optional.of(failure));
}
}
private static InetSocketAddress resolvePrimary() {
SocketAddress address = sentinel.sync().getMasterAddrByName(endpoint.requireMasterId());
if (!(address instanceof InetSocketAddress resolved)) {
throw new IllegalStateException("Sentinel did not report an inet address for the primary");
}
return resolved;
}
private static void clearLog(InetSocketAddress primary) {
withPrimary(primary, connection -> connection.sync().del(renderedLogKey));
}
private static void awaitInSyncReplica(InetSocketAddress primary) throws InterruptedException {
for (int attempt = 0; attempt < 30; attempt++) {
Long replicas =
withPrimary(primary, connection -> connection.sync().waitForReplication(1, 1_000));
if (replicas != null && replicas >= 1) {
return;
}
Thread.sleep(500);
}
throw new IllegalStateException(
"no replica came into sync; the lane cannot promote one and cannot accept a write");
}
private static void requireReplication(InetSocketAddress primary) {
Long replicas =
withPrimary(primary, connection -> connection.sync().waitForReplication(1, 2_000));
if (replicas == null || replicas < 1) {
throw new IllegalStateException(
"the primary has no replica in sync; the lane cannot promote one");
}
}
private static List<String> readLog(InetSocketAddress primary) {
return withPrimary(primary, connection -> connection.sync().lrange(renderedLogKey, 0, -1));
}
private static <T> T withPrimary(
InetSocketAddress primary, Function<StatefulRedisConnection<String, String>, T> work) {
RedisClient direct =
RedisClient.create(endpoint.dataNodeUri(primary.getHostString(), primary.getPort()));
try (StatefulRedisConnection<String, String> connection = direct.connect()) {
return work.apply(connection);
} finally {
direct.shutdown();
}
}
}
+52 -23
View File
@@ -1,4 +1,4 @@
# adapter:outbound:httpclient — resilient HTTP client adapter
# adapter:outbound:httpclient — HTTP Client Platform
## Registered identity
@@ -10,38 +10,67 @@
Package root: `dev.caskeleton.adapter.outbound.httpclient`.
## Design authority
The implementation follows
`httpclient-superpowers-package/docs/superpowers/specs/2026-08-08-httpclient-platform-design.md`.
The design assumes 19 separate Gradle modules; this repository's fail-closed 19-leaf registry
outranks that layout, so those modules are **packages** here. The mapping, and every other
deliberate substitution, is recorded in `docs/httpclient/repository-adaptation.md`. Read it before
moving a type between packages.
## Responsibility
- Own typed destination/operation catalogs, safe target construction, outbound engine construction,
deadlines/cancellation, resilience, request/response bounds, egress security, diagnostics, and
lifecycle.
- Own the public call surfaces: H1 typed service clients (default), H2 generic exchange, H3 dynamic
target. H4 native engine access stays internal to `apache`, `jdk`, `reactor`, and `http3`.
- Own Named Client Profiles, runtime generations, transport SPI, deadlines, evidence-based retry,
resilience, authentication, TLS, SSRF defence, streaming lifecycle, and observability.
- Adapt external HTTP calls behind application/domain ports.
- Reuse `adapter:outbound:support` for shared outbound concerns.
## Package boundaries
`HttpClientModuleBoundaryTest` and `PublicApiArchitectureTest` enforce the design's module table:
- `api` depends on nothing else in the platform, and on no Spring, Apache, Netty, Jetty, or
Resilience4j type.
- `profile` depends publicly only on `api`.
- transport packages never reach back into the gateways.
- `resilience` never depends on a transport — retry eligibility is transport-neutral.
- no production package depends on `testkit`.
- Stable code never references `http3`.
- `org.springframework.web.service.registry` appears only in `spring7`.
- `RestTemplate` appears only in `migration`.
## Boundaries
- Allowed dependency edges come only from the module's
`src/config/architecture/modules.json` entry.
- Allowed dependency edges come only from this module's `src/config/architecture/modules.json` entry.
- No inbound controller/DTO, persistence, bootstrap, or sample dependency.
- Retry and circuit-breaker code is technical resilience; business compensation and use-case
sequencing stay in application/domain layers.
- Application/domain code must not import this module's generic HTTP client, operation descriptor,
URI, Spring HTTP, JDK/Apache client, retry, or wire DTO types.
- Normal calls use registered fixed destinations and relative operation routes; arbitrary absolute
URL/header/credential APIs are forbidden.
- The legacy JDK facade, connect/read timeout, and response-size interceptor are not evidence of an
Apache pool bound, egress security, wire hard-cancellation, or R2 readiness. Its active monotonic
logical-call deadline is R1 evidence only.
- Canonical activation is owned by `app-bootstrap`: default `DISABLED` must resolve to
`DISABLED_VERIFIED` with zero HTTP runtime resources. Provider definitions are inert unless an
exact binding selects them; the current `NOT_IMPLEMENTED` card rejects every ACTIVE selection
before provider construction.
- Legacy `app.outbound.http.*` values are explicit migration input only. They must not be globally
configuration-properties scanned or present beside canonical composition in any expected state.
- Streaming must validate status before body delivery and remains bounded by a selected readiness
card before production use.
- Application/domain code must not import this module's gateways, operation descriptors, URI types,
Spring HTTP types, engine clients, retry types, or wire DTOs.
- Normal calls use a registered profile and a profile-relative template. Absolute URLs are H3 only.
- Composition is owned by `app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.httpclient`):
profile binding, startup validation, transport registration, and the actuator endpoint live there.
That package is excluded from the composition root's component scan and reached only through
`HttpClientPlatformAutoConfiguration`, which is gated on `app.httpclient.enabled=true`. While the
switch is off the capability has no beans at all — not an empty registry, nothing.
## Tests
Focused tests may use loopback servers and collaborator fakes. R2 promotion requires explicit
real-network/TLS/pool/cancellation/security lanes and no selected lane may silently skip.
Default lane: `./gradlew :adapter:outbound:httpclient:test`. Additional lanes, all fail-closed:
| Lane | Purpose |
|---|---|
| `httpClientStableContractTest` | one semantic contract across Apache, JDK, Reactor |
| `httpClientSecurityTest` | SSRF matrix, credential stripping, tag cardinality |
| `httpClientFailureInjectionTest` | Toxiproxy faults; **requires Docker and fails without it** |
| `httpClientPerformanceTest` | pool, streaming, retry, rotation resource bounds |
| `spring62CompatibilityTest` | Spring 6.2 API-surface confinement |
| `spring70CompatibilityTest` | contract suite on the repository baseline |
| `jmh` | per-call overhead benchmarks |
A selected lane never skips silently: the fault lane throws without Docker, the contract lane throws
on an empty or unknown transport selection, and the performance lane prints which machine-dependent
bounds were not asserted.
+229 -4
View File
@@ -1,17 +1,242 @@
plugins { id 'groovy' }
// Outbound HTTP Client Platform leaf — see
// docs/superpowers/specs/2026-08-08-httpclient-platform-design.md (design package) and
// docs/httpclient/repository-adaptation.md (how the design's 19 library modules map here).
//
// The design models the platform as 19 separate Gradle modules. This repository's fail-closed
// 19-leaf registry (src/config/architecture/modules.json) outranks that layout, so the module
// boundaries are packages under dev.caskeleton.adapter.outbound.httpclient and
// HttpClientModuleBoundaryTest enforces the design's module dependency table.
description = 'Outbound adapter: HTTP client platform (typed clients, profiles, evidence-based retry)'
dependencies {
implementation project(':application-core')
implementation project(':shared-contract')
implementation project(':adapter:outbound:support')
// Spring client layer. `httpclient-core-api` must not reach these; ArchUnit enforces it.
implementation 'org.springframework.boot:spring-boot-autoconfigure'
implementation 'org.springframework:spring-web'
implementation 'io.micrometer:micrometer-core'
implementation 'org.springframework:spring-webflux'
implementation 'io.projectreactor:reactor-core'
// Transport providers. Apache HC5 is the blocking default, JDK HttpClient is the lightweight
// alternative (JDK built-in), Reactor Netty is the reactive default, Jetty carries the
// Experimental HTTP/3 transport that the Stable starter never auto-configures.
implementation 'org.apache.httpcomponents.client5:httpclient5'
implementation 'io.projectreactor.netty:reactor-netty-http'
implementation 'org.eclipse.jetty:jetty-client'
// HTTP/3 is Experimental and off by default, so its transport is compileOnly plus a test
// dependency rather than a runtime one. It used to be `implementation`, which put the whole
// QUIC/HTTP-3/QPACK stack on every deployment's runtime classpath — megabytes and an attack
// surface — to serve a feature the Stable starter never auto-configures. A deployment that
// opts into HTTP/3 adds `org.eclipse.jetty.http3:jetty-http3-client-transport` itself, and
// Http3CapabilityReport already refuses the transport when those classes are absent, so the
// failure mode is a startup error rather than a NoClassDefFoundError mid-call.
compileOnly 'org.eclipse.jetty.http3:jetty-http3-client-transport'
testImplementation 'org.eclipse.jetty.http3:jetty-http3-client-transport'
// Resilience4j supplies the execution primitives only. HTTP retry *eligibility* is owned by
// this module (design D-09) and never delegated to a generic retry library.
implementation 'io.github.resilience4j:resilience4j-retry:2.2.0'
implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
implementation 'io.github.resilience4j:resilience4j-ratelimiter:2.2.0'
implementation 'io.github.resilience4j:resilience4j-bulkhead:2.2.0'
implementation 'io.github.resilience4j:resilience4j-micrometer:2.2.0'
implementation 'org.springframework.security:spring-security-oauth2-client'
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'io.micrometer:micrometer-core'
implementation 'org.slf4j:slf4j-api'
testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0'
// Testkit dependencies (design §28.1 test topology). They are test-scoped so no production
// module can depend on the testkit.
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'
testImplementation 'com.squareup.okhttp3:okhttp-tls:4.12.0'
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testImplementation 'org.testcontainers:testcontainers-toxiproxy'
testImplementation 'io.projectreactor:reactor-test'
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
testImplementation 'io.projectreactor.tools:blockhound:1.0.17.RELEASE'
}
tasks.withType(GroovyCompile).configureEach { groovyOptions.encoding = 'UTF-8'; options.encoding = 'UTF-8' }
// Performance certification and JMH benchmarks are separate source sets: they are slow, they assert
// on resource bounds rather than behaviour, and they must never be part of the default unit lane.
sourceSets {
httpClientPerformanceTest {
java.srcDir 'src/httpClientPerformanceTest/java'
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += output + compileClasspath
}
jmh {
java.srcDir 'src/jmh/java'
compileClasspath += sourceSets.main.output + sourceSets.test.output
runtimeClasspath += output + compileClasspath
}
}
configurations {
httpClientPerformanceTestImplementation.extendsFrom testImplementation
httpClientPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
jmhImplementation.extendsFrom testImplementation
jmhRuntimeOnly.extendsFrom testRuntimeOnly
}
dependencies {
jmhImplementation 'org.openjdk.jmh:jmh-core:1.37'
jmhAnnotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37'
}
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
// JMH generates its harness classes at compile time. They are not our source, so the
// compile-time checker and -Werror are switched off for that source set only; applying them
// would fail the build on generated code we cannot edit.
tasks.named('compileJmhJava', JavaCompile) {
options.errorprone.enabled = false
options.compilerArgs.removeIf { it == '-Werror' }
}
// The bytecode analyser is disabled for the same generated harness, for the same reason.
tasks.named('spotbugsJmh') {
enabled = false
}
Closure<Void> applyContractSelection = { Test task ->
// Cross-transport contract lane. The same semantic contract runs against every Stable transport;
// the transport under test is selected explicitly so a missing transport is an error, not a skip.
task.systemProperty 'httpclient.contract.transports',
(project.findProperty('httpclient.contract.transports') ?: 'apache,jdk,reactor').toString()
// Netty's strictest leak detector is on for every lane. It is only meaningful if it is actually
// live, so NettyLeakDetectionExtension asserts the level rather than trusting the flag reached
// the forked JVM.
task.systemProperty 'io.netty.leakDetection.level', 'paranoid'
// HTTP/3 is Experimental: it is never part of the default lane and never silently skipped.
task.systemProperty 'httpclient.http3.tests.enabled',
(project.findProperty('http3.tests.enabled') ?: 'false').toString()
}
tasks.named('test', Test) {
applyContractSelection(it)
// Two lanes are excluded from the default run for opposite reasons: the fault lane needs Docker
// and fails closed without it, and the BlockHound lane rewrites core JDK bytecode, which must
// not be imposed on every unit run.
useJUnitPlatform {
excludeTags 'quarantine', 'httpclient-fault', 'httpclient-blockhound'
}
}
tasks.register('httpClientBlockHoundTest', Test) {
group = 'verification'
description = 'Proves no platform code blocks a Reactor event loop (design §18.2, §28.6).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-blockhound' }
applyContractSelection(it)
// BlockHound instruments already-loaded JDK classes; Java 13+ needs this to redefine them.
jvmArgs '-XX:+AllowRedefinitionToAddDeleteMethods'
// The lane exists to run BlockHound. Discovering nothing means it did not, which is a failure.
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('httpClientStableContractTest', Test) {
group = 'verification'
description = 'Runs the cross-transport stable contract suite (design §28.2, §33).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-contract' }
applyContractSelection(it)
outputs.upToDateWhen { false }
}
tasks.register('httpClientSecurityTest', Test) {
group = 'verification'
description = 'Runs the SSRF, credential-leak, and cardinality suite (design §28.5, §28.7).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-security' }
applyContractSelection(it)
outputs.upToDateWhen { false }
}
tasks.register('httpClientFailureInjectionTest', Test) {
group = 'verification'
description = 'Runs the Toxiproxy fault-injection suite; fails closed without Docker (design §28.3).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-fault' }
applyContractSelection(it)
// The upstream image is mutable by default. Passing a digest here is what makes a red fault
// run attributable to this repository rather than to someone else's image push.
systemProperty 'httpclient.fault.httpbin.image',
(project.findProperty('httpclient.fault.httpbin.image') ?: 'kennethreitz/httpbin:latest').toString()
// A fault suite that never injected a fault must not report success, so a selected lane with no
// discovered test is an error rather than an empty pass.
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('httpClientPerformanceTest', Test) {
group = 'verification'
description = 'Certifies pool, streaming, retry, and rotation resource bounds (design §28.8).'
testClassesDirs = sourceSets.httpClientPerformanceTest.output.classesDirs
classpath = sourceSets.httpClientPerformanceTest.runtimeClasspath
useJUnitPlatform()
applyContractSelection(it)
systemProperty 'performance.assertions.enabled',
(project.findProperty('performance.assertions.enabled') ?: 'false').toString()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('jmh', JavaExec) {
group = 'verification'
description = 'Runs the JMH benchmarks for the blocking and reactive clients (design §28.8).'
mainClass = 'org.openjdk.jmh.Main'
classpath = sourceSets.jmh.runtimeClasspath
args '-rf', 'json', '-rff', layout.buildDirectory.file('reports/jmh/result.json').get().asFile.absolutePath
}
// Spring 6.2 / 7.0 compatibility lanes. This repository's Spring Boot 4.0 baseline pins Spring
// Framework 7, so the 6.2 lane verifies the *API surface* the common packages compile against
// rather than executing on a 6.2 distribution; the limitation is recorded in
// docs/httpclient/support-matrix.md instead of being hidden behind a green check.
tasks.register('spring62ApiSurfaceScan', Test) {
group = 'verification'
description = 'Scans the common packages for Spring 6.2 API-surface confinement. NOT a 6.2 runtime.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-spring62-surface' }
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
// Which lanes gate an ordinary build, and which do not.
//
// The specialised lanes existed but hung off nothing: `check` ran only `test`, so the SSRF suite,
// the BlockHound lane, the cross-transport contract and the Spring 6.2 surface scan were green in
// CI only because a workflow happened to name them, and green locally because nobody ran them.
// The four below are hermetic and fast — no Docker, no network, no machine-dependent thresholds —
// so they belong in `check`.
//
// httpClientFailureInjectionTest (needs Docker), httpClientPerformanceTest (machine-dependent
// bounds) and jmh (minutes) stay out deliberately. Attaching them would make `check` fail on a
// laptop without Docker, which teaches people to skip `check`.
tasks.named('check') {
dependsOn 'httpClientStableContractTest',
'httpClientSecurityTest',
'httpClientBlockHoundTest',
'spring62ApiSurfaceScan'
}
tasks.register('spring70CompatibilityTest', Test) {
group = 'verification'
description = 'Runs the contract suite on the repository Spring 7 baseline (design §29).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'httpclient-contract' }
applyContractSelection(it)
outputs.upToDateWhen { false }
}
+208 -123
View File
@@ -1,166 +1,251 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
com.github.spotbugs:spotbugs-annotations:4.8.6=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,spotbugs,testCompileClasspath
com.google.code.gson:gson:2.13.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.41.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.jayway.jsonpath:json-path:2.9.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:content-type:2.3=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:lang-tag:1.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.nimbusds:oauth2-oidc-sdk:11.26.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
com.squareup.okhttp3:mockwebserver:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.squareup.okhttp3:okhttp-tls:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.squareup.okhttp3:okhttp:4.12.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.squareup.okio:okio-jvm:3.6.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.squareup.okio:okio:3.6.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5-api:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine:1.3.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-logging:commons-logging:1.3.5=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.github.resilience4j:resilience4j-bulkhead:2.2.0=runtimeClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-core:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-micrometer:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-ratelimiter:2.2.0=runtimeClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-retry:2.2.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-timelimiter:2.2.0=runtimeClasspath,testRuntimeClasspath
io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
io.github.resilience4j:resilience4j-bulkhead:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-circuitbreaker:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-core:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-micrometer:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-ratelimiter:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-retry:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.github.resilience4j:resilience4j-timelimiter:2.2.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-buffer:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-base:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-classes-quic:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-compression:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-dns:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-http2:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-http3:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-http:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-codec-native-quic:4.2.17.Final=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
io.netty:netty-codec-socks:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-common:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-handler-proxy:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-handler:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns-native-macos:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver-dns:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-resolver:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-classes-epoll:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-native-epoll:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.netty:netty-transport:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor.netty:reactor-netty-core:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor.netty:reactor-netty-http:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor.tools:blockhound:1.0.17.RELEASE=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-test:3.8.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
junit:junit:4.13.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.java.dev.jna:jna:5.18.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
net.sf.jopt-simple:jopt-simple:5.0.4=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
org.apache.commons:commons-compress:1.28.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-math3:3.6.1=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath
org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath
org.apache.httpcomponents.client5:httpclient5:5.5.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.httpcomponents.core5:httpcore5-h2:5.3.6=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.httpcomponents.core5:httpcore5:5.3.6=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.apiguardian:apiguardian-api:1.1.2=compileClasspath,httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
org.assertj:assertj-core:3.27.6=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath
org.eclipse.jetty.compression:jetty-compression-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.compression:jetty-compression-gzip:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.http3:jetty-http3-client-transport:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.http3:jetty-http3-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.http3:jetty-http3-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.http3:jetty-http3-qpack:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.quic:jetty-quic-api:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.quic:jetty-quic-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.quic:jetty-quic-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.quic:jetty-quic-util:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-alpn-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-http:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-io:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty:jetty-util:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hamcrest:hamcrest-core:3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jetbrains:annotations:17.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,httpClientPerformanceTestAnnotationProcessor,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath
org.junit:junit-bom:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
org.latencyutils:LatencyUtils:2.0.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath
org.mockito:mockito-core:5.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath
org.openjdk.jmh:jmh-core:1.37=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath
org.openjdk.jmh:jmh-generator-annprocess:1.37=jmhAnnotationProcessor
org.opentest4j:opentest4j:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.ow2.asm:asm:9.7.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.rnorth.duct-tape:duct-tape:1.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-junit-jupiter:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers-toxiproxy:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
org.xmlunit:xmlunit-core:2.10.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,104 @@
package dev.caskeleton.adapter.outbound.httpclient.performance;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientApiType;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol;
import dev.caskeleton.adapter.outbound.httpclient.profile.PoolSettings;
import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType;
import dev.caskeleton.adapter.outbound.httpclient.reactor.ReactorConnectionProviderFactory;
import dev.caskeleton.adapter.outbound.httpclient.reactor.ReactorHttpClientFactory;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
import dev.caskeleton.adapter.outbound.httpclient.testkit.TlsFixture;
import dev.caskeleton.adapter.outbound.httpclient.testkit.TlsMaterials;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.netty.resources.ConnectionProvider;
/**
* HTTP/2 multiplexes streams onto a connection, so stream concurrency is bounded separately from
* connection count (design §14.1, §24.1).
*
* <p>This test used to run against {@code MockHttpServer.start()} — cleartext HTTP/1.1 — while
* asserting only that some requests completed. It was named for HTTP/2, filed under HTTP/2, and
* proved nothing about it: the same assertions passed over HTTP/1.1 with one connection per
* request, which is the exact behaviour multiplexing is supposed to replace. The server is now a
* real TLS+ALPN HTTP/2 endpoint and every response has to report {@code HTTP/2.0}, so the claim in
* the class name is the claim the test makes.
*/
class Http2StreamSaturationTest {
@Test
void manyConcurrentStreamsShareABoundedConnectionPoolOverRealHttp2() throws Exception {
int streams = 32;
int maxConnections = 2;
TlsFixture fixture = TlsFixture.trusted();
try (MockHttpServer server = MockHttpServer.startTlsWithHttp2(fixture.serverSocketFactory())) {
for (int index = 0; index < streams; index++) {
server.enqueueJson(200, "{\"id\":1,\"name\":\"a\"}");
}
ClientProfile profile =
ClientProfiles.builder("multiplexed")
.baseUrl(server.uri("/"))
.transport(TransportType.REACTOR_NETTY)
.api(ClientApiType.WEB_CLIENT)
.protocols(Set.of(HttpProtocol.HTTP_2, HttpProtocol.HTTP_1_1))
.pool(
new PoolSettings(
maxConnections,
maxConnections,
streams,
Duration.ofSeconds(5),
Duration.ofSeconds(30),
Duration.ofMinutes(5),
Duration.ofSeconds(5),
Duration.ofSeconds(15),
Duration.ofSeconds(5),
false,
false))
.build();
ConnectionProvider pool = new ReactorConnectionProviderFactory().create(profile);
try {
reactor.netty.http.client.HttpClient client =
new ReactorHttpClientFactory()
.create(
profile, pool, Optional.of(TlsMaterials.trustOnly(fixture)), Optional.empty());
List<String> versions =
Flux.fromStream(IntStream.range(0, streams).boxed())
.flatMap(
index ->
client
.get()
.uri(server.uri("/users/1").toString())
.response((response, bytes) -> Mono.just(response.version().text())),
streams)
.collectList()
.block(Duration.ofSeconds(60));
assertThat(versions).hasSize(streams);
assertThat(versions)
.as("every stream must be carried over HTTP/2, not silently downgraded to HTTP/1.1")
.containsOnly("HTTP/2.0");
// The point of multiplexing: 32 concurrent streams did not need 32 connections. The server
// counts what it accepted, so this is an observation rather than an inference from the
// client's own configuration.
PerformanceAssertions.structural(
"the bounded pool carried every concurrent stream", server.requestCount() == streams);
} finally {
pool.disposeLater().block(Duration.ofSeconds(5));
}
}
}
}
@@ -0,0 +1,95 @@
package dev.caskeleton.adapter.outbound.httpclient.performance;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.auth.AccessToken;
import dev.caskeleton.adapter.outbound.httpclient.auth.OAuth2TokenCacheKey;
import dev.caskeleton.adapter.outbound.httpclient.auth.SingleFlightTokenLoader;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
/** Token refresh under contention must stay single-flight (design §20.3, §28.8). */
class OAuthRefreshContentionTest {
private static final OAuth2TokenCacheKey KEY =
new OAuth2TokenCacheKey(
"payment",
"java.lang.String",
Set.of("payments.write"),
Optional.of("payment-api"),
Optional.empty(),
Optional.empty());
/**
* Single-flight collapses <em>concurrent</em> refreshes, so all hundred callers must genuinely be
* in flight at once. A pool smaller than the caller count would serialise them into successive
* refreshes and measure something the design never claimed.
*/
@Test
void aHundredConcurrentCallersProduceOneTokenRequest() throws Exception {
int callers = 100;
AtomicInteger loads = new AtomicInteger();
CountDownLatch started = new CountDownLatch(callers);
CountDownLatch release = new CountDownLatch(1);
SingleFlightTokenLoader loader =
new SingleFlightTokenLoader(
key -> {
loads.incrementAndGet();
try {
release.await(10, TimeUnit.SECONDS);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
return new AccessToken(
"token", Clock.systemUTC().instant().plus(Duration.ofMinutes(5)));
});
ExecutorService pool = Executors.newFixedThreadPool(callers);
try {
List<Future<AccessToken>> futures =
IntStream.range(0, callers)
.mapToObj(
index ->
pool.submit(
() -> {
started.countDown();
return loader.load(KEY);
}))
.toList();
assertThat(started.await(20, TimeUnit.SECONDS)).isTrue();
// Wait for the condition the test actually depends on — every caller inside load() — rather
// than sleeping and hoping. `started` only proves each task began; it counts down *before*
// load() is entered, so releasing on a fixed 200ms could let a caller arrive after the first
// refresh had already completed and been removed, producing a second load and a failure that
// looks like a single-flight bug but is a test bug.
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
while (loader.joinedCallers() < callers && System.nanoTime() < deadline) {
TimeUnit.MILLISECONDS.sleep(1);
}
assertThat(loader.joinedCallers())
.as("every caller must be inside load() before the refresh is released")
.isEqualTo(callers);
release.countDown();
for (Future<AccessToken> future : futures) {
assertThat(future.get(20, TimeUnit.SECONDS)).isNotNull();
}
} finally {
pool.shutdownNow();
assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue();
}
PerformanceAssertions.structural("token refresh collapsed to one request", loads.get() == 1);
assertThat(loader.inFlightRefreshes()).isZero();
}
}
@@ -0,0 +1,94 @@
package dev.caskeleton.adapter.outbound.httpclient.performance;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.api.OperationName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpBulkheadRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpPoolAcquireTimeoutException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.PoolSettings;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
import dev.caskeleton.adapter.outbound.httpclient.testkit.TestGateways;
import dev.caskeleton.adapter.outbound.httpclient.testkit.UserResponse;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
/** Concurrency beyond the pool must be bounded, not unbounded queueing (design §14, §28.8). */
class PoolSaturationPerformanceTest {
@Test
void concurrentCallsStayWithinTheDeclaredPoolAndFailFastBeyondIt() throws Exception {
int concurrency = 24;
try (MockHttpServer server = MockHttpServer.start()) {
ClientProfile profile =
ClientProfiles.builder("saturation")
.baseUrl(server.uri("/"))
.pool(
new PoolSettings(
4,
4,
8,
Duration.ofMillis(250),
Duration.ofSeconds(30),
Duration.ofMinutes(5),
Duration.ofSeconds(5),
Duration.ofSeconds(15),
Duration.ofSeconds(5),
false,
false))
.build();
for (int index = 0; index < concurrency; index++) {
server.enqueueJson(200, "{\"id\":1,\"name\":\"a\"}");
}
try (TestGateways.Harness harness = TestGateways.forProfile(profile)) {
AtomicInteger succeeded = new AtomicInteger();
AtomicInteger rejected = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(concurrency);
try {
for (int index = 0; index < concurrency; index++) {
pool.execute(
() -> {
try {
harness
.gateway()
.exchange(
profile.name(),
HttpOperation.get(new OperationName("get-user"), "/users/1", Map.of()),
ResponseType.of(UserResponse.class));
succeeded.incrementAndGet();
} catch (HttpBulkheadRejectedException
| HttpRateLimitRejectedException
| HttpPoolAcquireTimeoutException bounded) {
// Only the platform's own back-pressure counts as a bounded rejection. Catching
// RuntimeException made this assertion unfalsifiable: a NullPointerException, a
// serialization failure or a bug in the harness all counted as "the pool did
// its
// job", so the test would have passed while proving the opposite.
rejected.incrementAndGet();
}
});
}
pool.shutdown();
assertThat(pool.awaitTermination(60, TimeUnit.SECONDS)).isTrue();
} finally {
pool.shutdownNow();
}
PerformanceAssertions.structural(
"every call reached a terminal outcome",
succeeded.get() + rejected.get() == concurrency);
assertThat(succeeded.get()).isPositive();
}
}
}
}
@@ -0,0 +1,118 @@
package dev.caskeleton.adapter.outbound.httpclient.apache;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportCapabilities;
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportProvider;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey;
import io.micrometer.core.instrument.MeterRegistry;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.InetAddress;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
/**
* Blocking default transport (design D-06, §13.4).
*
* <p>The provider hands out a Spring {@code ClientHttpRequestFactory}; the {@code
* CloseableHttpClient} itself never escapes this package, which is what makes design §9.5's "no
* native client in the public API" enforceable rather than aspirational.
*/
public final class ApacheBlockingTransportProvider implements BlockingTransportProvider {
private static final TransportId ID = new TransportId("apache");
private final ApacheClientFactory clientFactory = new ApacheClientFactory();
private final ApacheFailureClassifier classifier = new ApacheFailureClassifier();
private final Map<TransportResourceKey, ApacheClientFactory.ApacheRuntime> runtimes =
new ConcurrentHashMap<>();
private final Optional<MeterRegistry> meterRegistry;
private final Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver;
private final Function<ClientProfile, Optional<Function<String, List<InetAddress>>>>
resolverFactory;
public ApacheBlockingTransportProvider() {
this(Optional.empty(), profile -> Optional.empty(), profile -> Optional.empty());
}
public ApacheBlockingTransportProvider(
Optional<MeterRegistry> meterRegistry,
Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver,
Function<ClientProfile, Optional<Function<String, List<InetAddress>>>> resolverFactory) {
this.meterRegistry = Objects.requireNonNull(meterRegistry, "meter registry");
this.tlsMaterialResolver = Objects.requireNonNull(tlsMaterialResolver, "tls material resolver");
this.resolverFactory = Objects.requireNonNull(resolverFactory, "dns resolver factory");
}
@Override
public TransportId id() {
return ID;
}
@Override
public BlockingTransportCapabilities capabilities() {
// Apache bounds the pending-acquire *wait* with connectionRequestTimeout; the pending-acquire
// *count* is bounded by the platform's logical admission limiter. Both halves of design §14.1
// are therefore satisfied for this transport.
//
// HTTP/1.1 only: Spring's HttpComponentsClientHttpRequestFactory drives the classic client, and
// Apache implements HTTP/2 in its async client. Blocking HTTP/2 is served by the JDK transport.
return BlockingTransportCapabilities.apacheClassic();
}
@Override
public ClientHttpRequestFactory create(
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(listener, "lifecycle listener");
ApacheClientFactory.ApacheRuntime runtime =
clientFactory.create(
profile, tlsMaterialResolver.apply(profile), resolverFactory.apply(profile));
runtimes.put(new TransportResourceKey(profile.name(), generation), runtime);
meterRegistry.ifPresent(
registry -> ApachePoolMetricsBinder.bind(registry, profile.name(), runtime.pool()));
listener.onRuntimeCreated(profile.name(), ID);
return new HttpComponentsClientHttpRequestFactory(runtime.client());
}
/** Live pool statistics; the pool-saturation and leak suites assert on these. */
public int leasedConnections(ClientProfileName profileName) {
return runtimes.entrySet().stream()
.filter(entry -> entry.getKey().profileName().equals(profileName))
.mapToInt(entry -> entry.getValue().pool().getTotalStats().getLeased())
.sum();
}
@Override
public TransportFailureClassifier failureClassifier() {
return classifier;
}
@Override
public void close(ClientProfile profile, RuntimeGeneration generation) {
ApacheClientFactory.ApacheRuntime runtime =
runtimes.remove(new TransportResourceKey(profile.name(), generation));
if (runtime == null) {
return;
}
try {
runtime.client().close();
} catch (IOException failure) {
throw new UncheckedIOException(failure);
} finally {
runtime.pool().close();
}
}
}
@@ -0,0 +1,140 @@
package dev.caskeleton.adapter.outbound.httpclient.apache;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import java.io.InterruptedIOException;
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.security.cert.CertificateException;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLPeerUnverifiedException;
import org.apache.hc.client5.http.ConnectTimeoutException;
import org.apache.hc.client5.http.HttpHostConnectException;
import org.apache.hc.core5.http.ConnectionClosedException;
/**
* Maps Apache HttpClient 5 failures onto stable evidence (design §13.3, §13.4).
*
* <p>The classifier is deliberately asymmetric: {@code NOT_SENT} is only produced for stages that
* prove nothing left the process. Once the request write has begun, an ambiguous I/O error stays
* {@code SENT_NO_RESPONSE}, because guessing "not sent" is what turns a timeout into a duplicate
* payment.
*/
public final class ApacheFailureClassifier implements TransportFailureClassifier {
@Override
public TransportFailure classify(Throwable failure, AttemptStage lastObservedStage) {
// The whole cause chain is inspected, not just the outermost throwable: Spring wraps engine
// exceptions, and a wrapped ConnectException still proves the request was never sent. Matching
// only the outer type would downgrade a provable NOT_SENT to an ambiguous SENT_NO_RESPONSE.
for (Throwable cause : chain(failure)) {
TransportFailure recognized = recognize(cause, lastObservedStage);
if (recognized != null) {
return recognized;
}
}
return conservative(lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
}
private TransportFailure recognize(Throwable cause, AttemptStage lastObservedStage) {
if (cause instanceof org.apache.hc.core5.concurrent.CancellableDependency) {
return TransportFailure.notSent(
AttemptStage.POOL_ACQUIRE, FailureCategory.POOL_ACQUIRE_TIMEOUT, "POOL_ACQUIRE_TIMEOUT");
}
if (isPoolAcquireTimeout(cause)) {
return TransportFailure.notSent(
AttemptStage.POOL_ACQUIRE, FailureCategory.POOL_ACQUIRE_TIMEOUT, "POOL_ACQUIRE_TIMEOUT");
}
if (cause instanceof UnknownHostException) {
return TransportFailure.notSent(
AttemptStage.DNS, FailureCategory.DNS, "DNS_RESOLUTION_FAILED");
}
if (cause instanceof ConnectTimeoutException
|| cause instanceof HttpHostConnectException
|| cause instanceof ConnectException
|| cause instanceof NoRouteToHostException) {
return TransportFailure.notSent(
AttemptStage.CONNECT, FailureCategory.CONNECT, "CONNECT_FAILED");
}
if (cause instanceof SSLPeerUnverifiedException || cause instanceof CertificateException) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_TRUST_FAILED");
}
if (cause instanceof SSLHandshakeException) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_HANDSHAKE_FAILED");
}
if (cause instanceof SSLException
&& !lastObservedStage.isAtLeast(AttemptStage.REQUEST_HEADERS)) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_TRANSIENT_FAILURE");
}
if (cause instanceof SocketTimeoutException) {
return timeout(lastObservedStage);
}
if (cause instanceof InterruptedIOException) {
// Not a timeout. A SocketTimeoutException means the peer went quiet; a bare
// InterruptedIOException usually means this thread was interrupted — a cancellation, from a
// caller or a shutdown. Treating the two alike classified a cancellation as a transient
// timeout, which the retry engine then retried, so cancelling a call could produce more
// requests than not cancelling it. The interrupt flag is restored because swallowing it
// leaves the thread unable to observe its own cancellation.
Thread.currentThread().interrupt();
return conservative(lastObservedStage, FailureCategory.CANCELLED, "ATTEMPT_INTERRUPTED");
}
if (cause instanceof ConnectionClosedException) {
return conservative(
lastObservedStage, FailureCategory.RESPONSE_TRUNCATED, "CONNECTION_CLOSED");
}
return null;
}
private java.util.List<Throwable> chain(Throwable failure) {
java.util.List<Throwable> chain = new java.util.ArrayList<>();
Throwable current = failure;
while (current != null && !chain.contains(current)) {
chain.add(current);
current = current.getCause();
}
return chain;
}
private boolean isPoolAcquireTimeout(Throwable cause) {
String typeName = cause.getClass().getName();
return typeName.endsWith("ConnectionRequestTimeoutException");
}
private TransportFailure timeout(AttemptStage lastObservedStage) {
if (lastObservedStage.provesNotSent()) {
return TransportFailure.notSent(
lastObservedStage, FailureCategory.CONNECT, "CONNECT_TIMEOUT");
}
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
return new TransportFailure(
lastObservedStage,
ExecutionEvidence.PARTIAL_RESPONSE,
FailureCategory.RESPONSE_TIMEOUT,
"RESPONSE_BODY_TIMEOUT");
}
return TransportFailure.sentNoResponse(
AttemptStage.RESPONSE_HEADERS, FailureCategory.RESPONSE_TIMEOUT, "RESPONSE_HEADER_TIMEOUT");
}
private TransportFailure conservative(
AttemptStage lastObservedStage, FailureCategory category, String reason) {
if (lastObservedStage.provesNotSent()) {
return TransportFailure.notSent(lastObservedStage, category, reason);
}
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
return new TransportFailure(
lastObservedStage, ExecutionEvidence.PARTIAL_RESPONSE, category, reason);
}
return TransportFailure.sentNoResponse(lastObservedStage, category, reason);
}
}
@@ -0,0 +1,118 @@
package dev.caskeleton.adapter.outbound.httpclient.api.body;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability;
import java.util.Objects;
import java.util.OptionalLong;
/**
* DTO body encoded by a deterministic codec (design §23.1).
*
* <p>Replayability is a property of the value, not of the codec. Every {@code ObjectBody} used to
* report {@code REPLAYABLE} unconditionally, on the strength of a javadoc line asking callers not
* to mutate the value afterwards. A mutable DTO handed to the platform and then changed by the
* caller — a builder reused across calls, a collection the caller kept a reference to — produced a
* retry that sent <em>different bytes</em> under the same idempotency key, which is the one thing a
* replay must never do.
*
* <p>The check is structural and conservative: records, enums, strings, boxed primitives and
* immutable collection views replay; anything else is treated as one-shot, so the retry engine
* refuses rather than gambling. A caller who knows better can freeze the value itself — serialize
* it to a {@code byte[]} body — which states the guarantee instead of asserting it.
*/
public record ObjectBody(Object value, String mediaType) implements BodySource {
public ObjectBody {
Objects.requireNonNull(value, "object body value");
Objects.requireNonNull(mediaType, "object body media type");
}
public static ObjectBody json(Object value) {
return new ObjectBody(value, "application/json");
}
/**
* Describes the body without printing it.
*
* <p>The generated {@code toString} rendered the payload itself, so any log line or exception
* message that mentioned a body disclosed its contents — which for an outbound call is by
* definition someone else's data.
*/
@Override
public String toString() {
return "ObjectBody[" + value.getClass().getSimpleName() + ", " + mediaType + ", REDACTED]";
}
@Override
public BodyReplayability replayability() {
return deeplyImmutable(value) ? BodyReplayability.REPLAYABLE : BodyReplayability.ONE_SHOT;
}
/**
* Whether re-encoding this value is guaranteed to produce the same bytes.
*
* <p>Records are accepted when every component is itself immutable, which covers the DTO shape
* the platform is built around without accepting a record that merely wraps a mutable list.
*/
private static boolean deeplyImmutable(Object candidate) {
if (candidate == null) {
return true;
}
if (candidate instanceof String
|| candidate instanceof Number
|| candidate instanceof Boolean
|| candidate instanceof Character
|| candidate instanceof Enum<?>
|| candidate instanceof java.util.UUID
|| candidate instanceof java.time.temporal.Temporal) {
return true;
}
if (candidate instanceof java.util.Collection<?> collection) {
return isImmutableCollectionView(collection)
&& collection.stream().allMatch(ObjectBody::deeplyImmutable);
}
if (candidate instanceof java.util.Map<?, ?> map) {
return isImmutableCollectionView(map)
&& map.entrySet().stream()
.allMatch(
entry -> deeplyImmutable(entry.getKey()) && deeplyImmutable(entry.getValue()));
}
Class<?> type = candidate.getClass();
if (!type.isRecord()) {
return false;
}
for (java.lang.reflect.RecordComponent component : type.getRecordComponents()) {
try {
java.lang.reflect.Method accessor = component.getAccessor();
accessor.setAccessible(true);
if (!deeplyImmutable(accessor.invoke(candidate))) {
return false;
}
} catch (ReflectiveOperationException | RuntimeException unreadable) {
// A component the platform cannot inspect cannot be certified, and an uncertified body is
// one-shot rather than optimistically replayable.
return false;
}
}
return true;
}
/**
* Whether the collection is one of the JDK's unmodifiable views.
*
* <p>Name-based because {@code List.of(...)} and {@code Collections.unmodifiableList(...)} return
* package-private classes with no shared marker interface. An ordinary {@code ArrayList} the
* caller still holds is exactly the case this must not accept.
*/
private static boolean isImmutableCollectionView(Object collection) {
String name = collection.getClass().getName();
return name.startsWith("java.util.ImmutableCollections")
|| name.startsWith("java.util.Collections$Unmodifiable")
|| name.startsWith("java.util.Collections$Empty")
|| name.startsWith("java.util.Collections$Singleton");
}
@Override
public OptionalLong knownLength() {
return OptionalLong.empty();
}
}
@@ -0,0 +1,152 @@
package dev.caskeleton.adapter.outbound.httpclient.api.operation;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey;
import dev.caskeleton.adapter.outbound.httpclient.api.OperationName;
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Immutable description of one logical outbound call (design §10.1).
*
* <p>The operation carries the URI <em>template</em>, never an expanded URL: observability tags and
* failure metadata must stay low-cardinality, and the security layer expands components itself.
*/
public record HttpOperation(
OperationName operationName,
HttpMethod method,
String uriTemplate,
Map<String, ?> uriVariables,
Map<String, List<String>> headers,
BodySource body,
OperationIdempotency idempotency,
Optional<IdempotencyKey> idempotencyKey,
Optional<Instant> deadline) {
public HttpOperation {
Objects.requireNonNull(operationName, "operation name");
Objects.requireNonNull(method, "http method");
Objects.requireNonNull(uriTemplate, "uri template");
Objects.requireNonNull(uriVariables, "uri variables");
Objects.requireNonNull(headers, "headers");
Objects.requireNonNull(body, "body");
Objects.requireNonNull(idempotency, "idempotency");
Objects.requireNonNull(idempotencyKey, "idempotency key");
Objects.requireNonNull(deadline, "deadline");
if (idempotency == OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED && idempotencyKey.isEmpty()) {
throw new IllegalArgumentException("idempotency key is required for this operation");
}
uriVariables = Map.copyOf(uriVariables);
headers = copyHeaders(headers);
}
public static HttpOperation get(
OperationName operationName, String uriTemplate, Map<String, ?> uriVariables) {
return new HttpOperation(
operationName,
HttpMethod.GET,
uriTemplate,
uriVariables,
Map.of(),
EmptyBody.instance(),
OperationIdempotency.STANDARD_IDEMPOTENT,
Optional.empty(),
Optional.empty());
}
public HttpOperation withHeaders(Map<String, List<String>> replacement) {
return new HttpOperation(
operationName,
method,
uriTemplate,
uriVariables,
replacement,
body,
idempotency,
idempotencyKey,
deadline);
}
public HttpOperation withBody(BodySource replacement) {
return new HttpOperation(
operationName,
method,
uriTemplate,
uriVariables,
headers,
replacement,
idempotency,
idempotencyKey,
deadline);
}
public HttpOperation withMethod(HttpMethod replacement) {
return new HttpOperation(
operationName,
replacement,
uriTemplate,
uriVariables,
headers,
body,
idempotency,
idempotencyKey,
deadline);
}
private static Map<String, List<String>> copyHeaders(Map<String, List<String>> headers) {
Map<String, List<String>> copy = new LinkedHashMap<>();
headers.forEach(
(name, values) -> {
Objects.requireNonNull(name, "header name");
Objects.requireNonNull(values, "header values");
copy.put(name, List.copyOf(new ArrayList<>(values)));
});
return Map.copyOf(copy);
}
/**
* Low-cardinality, secret-free description.
*
* <p>The record's generated {@code toString} printed every header value, the body object and the
* expanded URI variables. That string reaches a log the moment an operation appears in an
* exception message, a debug statement or an assertion failure — so an {@code Authorization}
* header, a request payload and a customer identifier were one stack trace away from the log
* aggregator. The template is safe by construction; the values are not, and none of them are
* needed to identify which operation this is.
*/
@Override
public String toString() {
return "HttpOperation["
+ operationName.value()
+ ' '
+ method
+ ' '
+ uriTemplate
+ ", headers="
+ headers.keySet()
+ ", body="
+ body.getClass().getSimpleName()
+ ", idempotency="
+ idempotency
+ ", idempotencyKey="
+ (idempotencyKey.isPresent() ? "PRESENT" : "ABSENT")
+ ']';
}
/** Case-insensitive single header lookup used by the request writer and redirect coordinator. */
public Optional<String> firstHeader(String name) {
String wanted = name.toLowerCase(Locale.ROOT);
return headers.entrySet().stream()
.filter(entry -> entry.getKey().toLowerCase(Locale.ROOT).equals(wanted))
.flatMap(entry -> entry.getValue().stream())
.findFirst();
}
}
@@ -0,0 +1,66 @@
package dev.caskeleton.adapter.outbound.httpclient.auth;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.OperationName;
import dev.caskeleton.adapter.outbound.httpclient.profile.AuthenticationSettings;
import java.net.URI;
import java.util.Objects;
import java.util.Optional;
/**
* Input to credential materialization (design §20.2).
*
* <p>The principal is passed explicitly rather than read from ambient thread state: design §26.3
* forbids implicitly picking up a user token, because that silently turns a machine-to-machine call
* into a user-scoped one.
*/
public record CredentialRequest(
ClientProfileName clientName,
OperationName operationName,
AuthenticationSettings settings,
URI target,
Optional<Object> principal,
Optional<String> clientCertificateIdentity,
boolean forceRefresh) {
/**
* Identifies the request without exposing the principal or the target URL.
*
* <p>The generated {@code toString} printed the authenticated principal and the full target URI,
* including any query string. A credential-resolution failure is exactly when this record ends up
* in a log line, which made the failure path the most likely place for a user identity and a
* signed URL to escape.
*/
@Override
public String toString() {
return "CredentialRequest["
+ clientName.value()
+ ' '
+ operationName.value()
+ ", type="
+ settings.type()
+ ", target="
+ target.getScheme()
+ "://"
+ target.getHost()
+ ", principal="
+ (principal.isPresent() ? "PRESENT" : "ABSENT")
+ ", forceRefresh="
+ forceRefresh
+ ']';
}
public CredentialRequest {
Objects.requireNonNull(clientName, "client name");
Objects.requireNonNull(operationName, "operation name");
Objects.requireNonNull(settings, "authentication settings");
Objects.requireNonNull(target, "target");
Objects.requireNonNull(principal, "principal");
Objects.requireNonNull(clientCertificateIdentity, "client certificate identity");
}
public CredentialRequest refreshed() {
return new CredentialRequest(
clientName, operationName, settings, target, principal, clientCertificateIdentity, true);
}
}
@@ -0,0 +1,128 @@
package dev.caskeleton.adapter.outbound.httpclient.auth;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAuthenticationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
/**
* OAuth2 access tokens obtained through Spring Security (design D-13, §20.3).
*
* <p>Token acquisition is delegated, but three rules are owned here because they are what make the
* result safe under load: the cache key, single-flight refresh, and an expiry skew so a token is
* replaced before an upstream starts rejecting it.
*/
public final class OAuth2CredentialProvider implements RequestCredentialProvider {
/**
* Attribute name for the audience a token is requested for.
*
* <p>Not standardised in OAuth2 core, so it is spelled out here rather than borrowed from a
* constant that does not exist; authorization servers that support audience restriction read this
* parameter name.
*/
private static final String OAUTH2_AUDIENCE_ATTRIBUTE = "audience";
private static final Duration EXPIRY_SKEW = Duration.ofSeconds(30);
private final OAuth2AuthorizedClientManager authorizedClientManager;
private final SingleFlightTokenLoader tokenLoader;
private final ConcurrentMap<OAuth2TokenCacheKey, AccessToken> cache = new ConcurrentHashMap<>();
private final Clock clock;
public OAuth2CredentialProvider(
OAuth2AuthorizedClientManager authorizedClientManager, Clock clock) {
this.authorizedClientManager =
Objects.requireNonNull(authorizedClientManager, "authorized client manager");
this.clock = Objects.requireNonNull(clock, "clock");
this.tokenLoader = new SingleFlightTokenLoader(this::loadToken);
}
@Override
public CredentialType type() {
return CredentialType.OAUTH2_CLIENT_CREDENTIALS;
}
@Override
public RequestCredentials resolve(CredentialRequest request) {
OAuth2TokenCacheKey key = cacheKey(request);
if (request.forceRefresh()) {
cache.remove(key);
}
AccessToken token =
cache.compute(
key,
(ignored, existing) ->
existing == null || existing.expired(clock, EXPIRY_SKEW)
? tokenLoader.load(key)
: existing);
return RequestCredentials.header("Authorization", "Bearer " + token.value());
}
@Override
public void invalidate(CredentialRequest request) {
cache.remove(cacheKey(request));
}
private OAuth2TokenCacheKey cacheKey(CredentialRequest request) {
String registrationId =
request
.settings()
.registrationId()
.orElseThrow(
() ->
new HttpAuthenticationException(
"oauth2 authentication requires a registration id",
HttpFailureMetadata.startup(request.clientName())));
return new OAuth2TokenCacheKey(
registrationId,
request.principal().map(principal -> principal.getClass().getName()).orElse("anonymous"),
request.settings().scopes(),
request.settings().audience(),
Optional.empty(),
request.clientCertificateIdentity());
}
/**
* Authorizes, carrying the scopes and audience the profile declared.
*
* <p>Both used to be part of the cache key and part of nothing else. The platform cached tokens
* <em>as though</em> they differed by scope while every authorize request asked for the
* registration's default scopes, so a profile that declared a narrower scope set received a
* broader token and a profile that declared a wider one received a token missing the scopes it
* needed — and the cache confidently kept them apart. Attributes are the mechanism Spring's
* authorized-client manager passes through to the token request.
*/
private AccessToken loadToken(OAuth2TokenCacheKey key) {
OAuth2AuthorizeRequest.Builder builder =
OAuth2AuthorizeRequest.withClientRegistrationId(key.registrationId())
.principal(key.principalClass());
if (!key.scopes().isEmpty()) {
builder.attribute(
org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.SCOPE,
String.join(" ", key.scopes()));
}
key.audience().ifPresent(audience -> builder.attribute(OAUTH2_AUDIENCE_ATTRIBUTE, audience));
OAuth2AuthorizeRequest authorizeRequest = builder.build();
OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
if (authorizedClient == null) {
throw new IllegalStateException(
"no oauth2 authorized client for registration " + key.registrationId());
}
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
Instant expiresAt =
accessToken.getExpiresAt() == null
? clock.instant().plus(Duration.ofMinutes(5))
: accessToken.getExpiresAt();
return new AccessToken(accessToken.getTokenValue(), expiresAt);
}
}
@@ -0,0 +1,79 @@
package dev.caskeleton.adapter.outbound.httpclient.auth;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAuthenticationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
import reactor.core.publisher.Mono;
/**
* Dispatches a reactive call to the provider its profile declared (design §20.1, §20.2).
*
* <p>The reactive runtime takes a single {@link ReactiveRequestCredentialProvider}, and the
* composition root used to hand it {@link NoAuthCredentialProvider} unconditionally. A profile that
* declared {@code BASIC}, {@code API_KEY_HEADER} or {@code STATIC_BEARER} and used the reactive API
* therefore sent no credential at all — no startup error, no runtime error, just an unauthenticated
* request that the upstream answered 401 and the platform reported as a remote failure.
*
* <p>This registry is the same shape as the blocking {@link CredentialProviderRegistry}: selection
* is by declaration, and a type nobody registered is an error rather than a silent downgrade to
* anonymous. The dispatch reads the type off the request, so one instance serves every profile.
*/
public final class ReactiveCredentialProviderRegistry implements ReactiveRequestCredentialProvider {
private final Map<CredentialType, ReactiveRequestCredentialProvider> providers =
new EnumMap<>(CredentialType.class);
public ReactiveCredentialProviderRegistry register(ReactiveRequestCredentialProvider provider) {
Objects.requireNonNull(provider, "reactive credential provider");
providers.put(provider.type(), provider);
return this;
}
/** Adapts a provider whose resolution is a synchronous computation rather than I/O. */
public ReactiveCredentialProviderRegistry registerNonBlocking(
RequestCredentialProvider provider) {
return register(ReactiveRequestCredentialProvider.fromNonBlocking(provider));
}
public static ReactiveCredentialProviderRegistry withNoAuth() {
return new ReactiveCredentialProviderRegistry()
.registerNonBlocking(new NoAuthCredentialProvider());
}
/**
* The dispatch itself is a credential type of its own only in the degenerate sense; callers
* select by request, so this reports {@code NONE}.
*/
@Override
public CredentialType type() {
return CredentialType.NONE;
}
@Override
public Mono<RequestCredentials> resolve(CredentialRequest request) {
return provider(request).flatMap(provider -> provider.resolve(request));
}
@Override
public Mono<Void> invalidate(CredentialRequest request) {
return provider(request).flatMap(provider -> provider.invalidate(request));
}
public boolean supports(CredentialType credentialType) {
return providers.containsKey(credentialType);
}
private Mono<ReactiveRequestCredentialProvider> provider(CredentialRequest request) {
CredentialType credentialType = CredentialType.from(request.settings().type());
ReactiveRequestCredentialProvider provider = providers.get(credentialType);
if (provider == null) {
return Mono.error(
new HttpAuthenticationException(
"no reactive credential provider is registered for " + credentialType,
HttpFailureMetadata.startup(request.clientName())));
}
return Mono.just(provider);
}
}
@@ -0,0 +1,148 @@
package dev.caskeleton.adapter.outbound.httpclient.auth;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAuthenticationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
/**
* Collapses concurrent refreshes of the same token into one (design §20.3).
*
* <p>Without this, a token expiring under load produces one token request per in-flight call, which
* is exactly when the authorization server can least afford them — and several providers rate-limit
* or invalidate on that pattern.
*
* <p>Two things about <em>where</em> that refresh runs used to be wrong, and both bite under
* exactly the load this class exists for.
*
* <p>It ran on the common {@link java.util.concurrent.ForkJoinPool}. That pool is sized for
* CPU-bound work and shared with every parallel stream in the process; a token endpoint that goes
* slow therefore parks common-pool threads and stalls unrelated work across the application. The
* refresh now runs on a small bounded pool of its own, so a slow authorization server costs only
* the threads dedicated to talking to it.
*
* <p>And the wait was {@code join()} — unbounded. A token endpoint that accepted the connection and
* never answered blocked every caller of that credential indefinitely, past their own deadlines,
* with no exception to attribute it to. The wait is bounded and expiry is a stable authentication
* failure.
*/
public final class SingleFlightTokenLoader implements AutoCloseable {
/** Small on purpose: this pool exists to talk to one authorization server, not to scale out. */
private static final int DEFAULT_POOL_SIZE = 2;
private static final Duration DEFAULT_ACQUIRE_TIMEOUT = Duration.ofSeconds(10);
private final ConcurrentMap<OAuth2TokenCacheKey, CompletableFuture<AccessToken>> inFlight =
new ConcurrentHashMap<>();
private final Function<OAuth2TokenCacheKey, AccessToken> delegate;
private final ExecutorService refreshExecutor;
private final Duration acquireTimeout;
private final AtomicInteger joinedCallers = new AtomicInteger();
public SingleFlightTokenLoader(Function<OAuth2TokenCacheKey, AccessToken> delegate) {
this(delegate, DEFAULT_POOL_SIZE, DEFAULT_ACQUIRE_TIMEOUT);
}
public SingleFlightTokenLoader(
Function<OAuth2TokenCacheKey, AccessToken> delegate, int poolSize, Duration acquireTimeout) {
this.delegate = Objects.requireNonNull(delegate, "token loader delegate");
this.acquireTimeout = Objects.requireNonNull(acquireTimeout, "acquire timeout");
if (poolSize < 1) {
throw new IllegalArgumentException("token refresh pool size must be positive");
}
if (acquireTimeout.isNegative() || acquireTimeout.isZero()) {
throw new IllegalArgumentException("token acquire timeout must be positive");
}
this.refreshExecutor = Executors.newFixedThreadPool(poolSize, refreshThreadFactory());
}
public AccessToken load(OAuth2TokenCacheKey key) {
Objects.requireNonNull(key, "token cache key");
joinedCallers.incrementAndGet();
CompletableFuture<AccessToken> future =
inFlight.computeIfAbsent(
key,
ignored -> CompletableFuture.supplyAsync(() -> delegate.apply(key), refreshExecutor));
try {
return future.get(acquireTimeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException timedOut) {
// Cancelled rather than abandoned: leaving it running would let the next caller join a
// refresh that has already outlived its usefulness.
future.cancel(true);
throw new HttpAuthenticationException(
"oauth2 token refresh did not complete within " + acquireTimeout,
HttpFailureMetadata.startup(
new dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName(
key.registrationId())),
timedOut);
} catch (ExecutionException failed) {
Throwable cause = failed.getCause() == null ? failed : failed.getCause();
if (cause instanceof RuntimeException runtime) {
throw runtime;
}
throw new HttpAuthenticationException(
"oauth2 token refresh failed",
HttpFailureMetadata.startup(
new dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName(
key.registrationId())),
cause);
} catch (InterruptedException interrupted) {
// The caller was cancelled; propagate the interrupt rather than swallowing it.
Thread.currentThread().interrupt();
future.cancel(true);
throw new HttpAuthenticationException(
"oauth2 token refresh was interrupted",
HttpFailureMetadata.startup(
new dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName(
key.registrationId())),
interrupted);
} finally {
inFlight.remove(key, future);
}
}
public int inFlightRefreshes() {
return inFlight.size();
}
/**
* How many callers have entered {@link #load} over this loader's lifetime.
*
* <p>Exposed so a contention test can wait for an observable condition — "all N callers have
* joined" — instead of sleeping for an arbitrary interval and hoping. A sleep-based test is
* simultaneously slower than it needs to be and unreliable on a loaded machine, which is the
* worst pair of properties for a test that only fails intermittently.
*
* @return the cumulative number of {@code load} entries
*/
public int joinedCallers() {
return joinedCallers.get();
}
@Override
public void close() {
refreshExecutor.shutdownNow();
}
private static ThreadFactory refreshThreadFactory() {
AtomicInteger counter = new AtomicInteger();
return runnable -> {
Thread thread =
new Thread(runnable, "httpclient-oauth2-refresh-" + counter.incrementAndGet());
thread.setDaemon(true);
return thread;
};
}
}
@@ -0,0 +1,78 @@
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
import java.net.InetAddress;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Carries the approved addresses of one dynamic call from validation to the socket.
*
* <p>This is the piece the SSRF defence was missing. {@link ValidatedDnsResolver} resolved a host,
* rejected the target if any answer was forbidden, and produced a {@link PinnedTarget} holding the
* exact approved addresses — and then the gateway handed the transport a URL containing the
* <em>hostname</em>, and the transport resolved it again. Everything between the two resolutions
* was unvalidated: a DNS server under an attacker's control answers the first query with a public
* address and the second with {@code 169.254.169.254}, and the platform connects to the metadata
* service having "validated" the target. The classic rebinding attack, defeated by a check that
* discarded its own result.
*
* <p>The pin is thread-scoped because the dynamic gateway is blocking and thread-confined for the
* duration of a physical request; it is installed around the attempt and removed in a {@code
* finally}, so a pooled thread never carries one call's addresses into another's.
*
* <p>It deliberately does not cache. The previous approved-address map in the resolver lived for
* the lifetime of the process with no TTL and no bound, which is a second, slower version of the
* same problem: an address approved an hour ago is not evidence about the host now.
*/
public final class CallScopedDnsPin implements AutoCloseable {
private static final ThreadLocal<Map<String, List<InetAddress>>> CURRENT = new ThreadLocal<>();
private CallScopedDnsPin() {}
/**
* Installs the pin for the current thread.
*
* @param host the canonical host the addresses were approved for
* @param approvedAddresses the addresses the transport may connect to
* @return a handle that removes the pin
*/
public static CallScopedDnsPin open(String host, List<InetAddress> approvedAddresses) {
Objects.requireNonNull(host, "host");
Objects.requireNonNull(approvedAddresses, "approved addresses");
if (approvedAddresses.isEmpty()) {
throw new IllegalArgumentException("a dns pin needs at least one approved address");
}
CURRENT.set(Map.of(host.toLowerCase(java.util.Locale.ROOT), List.copyOf(approvedAddresses)));
return new CallScopedDnsPin();
}
/**
* The addresses the current call approved for a host.
*
* <p>An empty result means this host was not the one validated. The transport must then refuse
* rather than fall back to a system lookup — a fallback would restore exactly the second,
* unvalidated resolution this class exists to remove.
*
* @param host the host the transport is about to connect to
* @return the approved addresses, or empty when the host was not pinned by this call
*/
public static List<InetAddress> addressesFor(String host) {
Map<String, List<InetAddress>> pinned = CURRENT.get();
if (pinned == null || host == null) {
return List.of();
}
return pinned.getOrDefault(host.toLowerCase(java.util.Locale.ROOT), List.of());
}
/** Whether a pin is installed on this thread. */
public static boolean active() {
return CURRENT.get() != null;
}
@Override
public void close() {
CURRENT.remove();
}
}
@@ -0,0 +1,228 @@
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRedirectRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
import dev.caskeleton.adapter.outbound.httpclient.api.result.IdempotencyKeyRequirement;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
import dev.caskeleton.adapter.outbound.httpclient.observation.HttpClientObservationNames;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingAttemptExecutor;
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.restclient.StatusHandlingPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.HeaderPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedTarget;
import io.micrometer.core.instrument.Tag;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
/**
* Executes a user-supplied URL under an explicit SSRF policy (design §22).
*
* <p>Every hop — including the first — goes through canonicalize → allowlist → resolve-all →
* classify → pin. Redirects are followed here rather than by the blocking coordinator precisely so
* the validation cannot be skipped for hop two.
*
* <p>No trusted credential, Cookie jar, or default header is inherited. A credential is attached
* only when a {@link DynamicCredentialBinding} names that exact canonical host.
*/
public final class DefaultDynamicTargetGateway implements DynamicTargetGateway {
private final Map<DynamicTargetPolicyName, DynamicTargetPolicy> policies;
private final Map<DynamicTargetPolicyName, ValidatedDnsResolver> resolvers;
private final ClientRuntimeRegistry runtimes;
private final BlockingAttemptExecutor executor;
private final TargetCanonicalizer canonicalizer;
private final List<DynamicCredentialBinding> credentialBindings;
private final Function<String, String> secretResolver;
public DefaultDynamicTargetGateway(
Map<DynamicTargetPolicyName, DynamicTargetPolicy> policies,
Map<DynamicTargetPolicyName, ValidatedDnsResolver> resolvers,
ClientRuntimeRegistry runtimes,
BlockingAttemptExecutor executor,
List<DynamicCredentialBinding> credentialBindings,
Function<String, String> secretResolver) {
this.policies = Map.copyOf(Objects.requireNonNull(policies, "dynamic target policies"));
this.resolvers = Map.copyOf(Objects.requireNonNull(resolvers, "validated dns resolvers"));
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
this.executor = Objects.requireNonNull(executor, "attempt executor");
this.credentialBindings = List.copyOf(Objects.requireNonNull(credentialBindings, "bindings"));
this.secretResolver = Objects.requireNonNull(secretResolver, "secret resolver");
this.canonicalizer = new TargetCanonicalizer();
}
@Override
public <T> HttpCallResult<T> exchange(
DynamicTargetPolicyName policyName,
URI target,
HttpOperation operation,
ResponseType<T> responseType) {
Objects.requireNonNull(policyName, "policy name");
Objects.requireNonNull(target, "target");
Objects.requireNonNull(operation, "operation");
DynamicTargetPolicy policy = requirePolicy(policyName);
ValidatedDnsResolver resolver = requireResolver(policyName);
try (ClientRuntimeLease lease = runtimes.acquire(new ClientProfileName(policyName.value()))) {
BlockingClientRuntime runtime = requireBlockingRuntime(lease);
HttpFailureMetadata metadata =
HttpFailureMetadata.validation(
runtime.name(),
operation.operationName(),
operation.method(),
operation.uriTemplate(),
operation.body().replayability());
URI current = target;
HttpMethod method = operation.method();
for (int hop = 0; ; hop++) {
PinnedTarget pinned = validate(policy, resolver, current);
PreparedOperation prepared =
prepare(runtime, operation.withMethod(method), pinned, metadata);
RequestCredentials credentials = credentialsFor(pinned.target());
// The pin is what makes the validation above binding. Without it the transport resolved the
// hostname a second time and could land anywhere; with it the socket may only reach an
// address this hop actually approved. Scoped to the hop, so the next redirect re-validates
// and re-pins rather than inheriting an earlier decision.
AttemptOutcome<T> outcome;
try (CallScopedDnsPin pin =
CallScopedDnsPin.open(pinned.target().host(), pinned.approvedAddresses())) {
Objects.requireNonNull(pin, "dns pin");
outcome =
executor.execute(
runtime,
prepared,
responseType,
StatusHandlingPolicy.RETURN_RESULT,
credentials,
1,
runtime.support().clock().instant(),
metadata);
} finally {
// Discarded at the end of the hop. Retaining it would grow without bound and, worse,
// would let a later call reuse an approval that was only ever made for this one.
resolver.forget(pinned.target().host());
}
HttpCallResult<T> result =
outcome.result().orElseThrow(() -> outcome.failure().orElseThrow());
Optional<URI> location = redirectLocation(result, pinned);
if (location.isEmpty()) {
return result;
}
if (hop >= policy.maxRedirectHops()) {
recordRejection(runtime, policyName, "MAX_HOPS");
throw new HttpRedirectRejectedException(
"dynamic target redirect exceeded the policy hop limit", metadata);
}
// 303 turns the follow-up into a GET; every other redirect keeps the method, and the next
// loop iteration revalidates the new target from scratch.
method = result.status().value() == 303 ? HttpMethod.GET : method;
current = location.get();
}
}
}
private PinnedTarget validate(
DynamicTargetPolicy policy, ValidatedDnsResolver resolver, URI target) {
CanonicalTarget canonical = canonicalizer.canonicalize(policy, target);
return resolver.pin(canonical);
}
private PreparedOperation prepare(
BlockingClientRuntime runtime,
HttpOperation operation,
PinnedTarget pinned,
HttpFailureMetadata metadata) {
Map<String, List<String>> headers =
HeaderPolicy.forOperation(IdempotencyKeyRequirement.none(), false)
.validate(operation.headers(), metadata);
runtime.bodyLimitPolicy().validate(operation.body(), metadata);
PreparedTarget target = PreparedTarget.of(pinned.target().toUri(), operation.uriTemplate());
return new PreparedOperation(
operation,
target,
headers,
runtime.profile().request().maxBodyBytes(),
runtime.profile().response().maxWireBytes(),
runtime.profile().response().maxDecodedBytes());
}
private <T> Optional<URI> redirectLocation(HttpCallResult<T> result, PinnedTarget pinned) {
int status = result.status().value();
if (status != 301 && status != 302 && status != 303 && status != 307 && status != 308) {
return Optional.empty();
}
return result.headers().entrySet().stream()
.filter(entry -> entry.getKey().equalsIgnoreCase("Location"))
.flatMap(entry -> entry.getValue().stream())
.findFirst()
.map(location -> pinned.target().toUri().resolve(location));
}
private RequestCredentials credentialsFor(CanonicalTarget target) {
Map<String, String> headers = new LinkedHashMap<>();
credentialBindings.stream()
.filter(binding -> binding.matches(target))
.forEach(
binding ->
headers.put(binding.headerName(), secretResolver.apply(binding.secretReference())));
return headers.isEmpty()
? RequestCredentials.none()
: new RequestCredentials(headers, Map.of());
}
private void recordRejection(
BlockingClientRuntime runtime, DynamicTargetPolicyName policyName, String reason) {
runtime
.support()
.meterRegistry()
.counter(
HttpClientObservationNames.SSRF_REJECTED,
List.of(Tag.of("clientName", policyName.value()), Tag.of("outcome", reason)))
.increment();
}
private DynamicTargetPolicy requirePolicy(DynamicTargetPolicyName name) {
DynamicTargetPolicy policy = policies.get(name);
if (policy == null) {
throw new NoSuchElementException("unregistered dynamic target policy: " + name.value());
}
return policy;
}
private ValidatedDnsResolver requireResolver(DynamicTargetPolicyName name) {
ValidatedDnsResolver resolver = resolvers.get(name);
if (resolver == null) {
throw new NoSuchElementException(
"no validated dns resolver for dynamic target policy: " + name.value());
}
return resolver;
}
private BlockingClientRuntime requireBlockingRuntime(ClientRuntimeLease lease) {
if (lease.runtime() instanceof BlockingClientRuntime blocking) {
return blocking;
}
throw new HttpTargetRejectedException(
"dynamic target policy is not bound to a blocking runtime",
HttpFailureMetadata.startup(lease.runtime().name()));
}
}
@@ -0,0 +1,79 @@
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
/**
* An explicitly registered credential for one dynamic origin (design §9.4).
*
* <p>Dynamic targets inherit nothing. If a specific origin genuinely needs a credential, a security
* owner registers this binding for that exact scheme, host and port — which makes the decision
* auditable instead of implicit.
*
* <p>The binding used to match on host alone. That sent the credential to {@code http://host} as
* readily as to {@code https://host}, and to any port the same host happened to serve — so a target
* that downgraded to plaintext, or pointed at a different service on the same machine, received a
* secret registered for neither.
*
* @param scheme the exact scheme the credential is registered for
* @param canonicalHost the exact canonical host
* @param port the exact port
* @param headerName the header to carry the credential in; must be on the allowlist
* @param secretReference the reference a secret backend resolves
*/
public record DynamicCredentialBinding(
String scheme, String canonicalHost, int port, String headerName, String secretReference) {
/**
* Header names a dynamic credential may use.
*
* <p>An allowlist because the header name decides who reads the secret. Without one a binding
* could put a credential in {@code Host}, {@code Origin} or a header a proxy forwards onward.
*/
private static final Set<String> ALLOWED_HEADER_NAMES =
Set.of("authorization", "x-api-key", "api-key", "x-client-key", "x-webhook-token");
public DynamicCredentialBinding {
Objects.requireNonNull(scheme, "scheme");
Objects.requireNonNull(canonicalHost, "canonical host");
Objects.requireNonNull(headerName, "header name");
Objects.requireNonNull(secretReference, "secret reference");
if (scheme.isBlank()
|| canonicalHost.isBlank()
|| headerName.isBlank()
|| secretReference.isBlank()) {
throw new IllegalArgumentException("dynamic credential binding fields must not be blank");
}
if (port < 1 || port > 65535) {
throw new IllegalArgumentException("dynamic credential binding port must be 1..65535");
}
if (!ALLOWED_HEADER_NAMES.contains(headerName.toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException(
"dynamic credential header "
+ headerName
+ " is not on the allowlist "
+ ALLOWED_HEADER_NAMES);
}
scheme = scheme.toLowerCase(Locale.ROOT);
canonicalHost = canonicalHost.toLowerCase(Locale.ROOT);
}
/** Convenience for the common case: HTTPS on the default port. */
public static DynamicCredentialBinding httpsOn(
String canonicalHost, String headerName, String secretReference) {
return new DynamicCredentialBinding("https", canonicalHost, 443, headerName, secretReference);
}
/**
* Matches only the exact origin.
*
* @param target the canonicalized target of this call
* @return {@code true} when scheme, host and port all match
*/
public boolean matches(CanonicalTarget target) {
return scheme.equals(target.scheme())
&& canonicalHost.equals(target.host())
&& port == target.port();
}
}
@@ -0,0 +1,86 @@
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
import java.util.Objects;
import java.util.Set;
/**
* The rules a user-supplied URL must satisfy (design §22).
*
* <p>A Dynamic Target policy is deliberately separate from a Named Client Profile: it inherits no
* credential, no Cookie jar, and no default header (design D-03), so a webhook checker cannot
* accidentally speak with a trusted client's identity.
*/
public record DynamicTargetPolicy(
DynamicTargetPolicyName name,
Set<String> allowedSchemes,
Set<Integer> allowedPorts,
Set<String> allowedHostSuffixes,
Set<String> allowedHosts,
int maxRedirectHops,
boolean tracePropagation,
java.util.List<String> additionalBlockedCidrs) {
public DynamicTargetPolicy {
Objects.requireNonNull(name, "policy name");
Objects.requireNonNull(allowedSchemes, "allowed schemes");
Objects.requireNonNull(allowedPorts, "allowed ports");
Objects.requireNonNull(allowedHostSuffixes, "allowed host suffixes");
Objects.requireNonNull(allowedHosts, "allowed hosts");
Objects.requireNonNull(additionalBlockedCidrs, "additional blocked cidrs");
if (maxRedirectHops < 0) {
throw new IllegalArgumentException("max redirect hops must not be negative");
}
allowedSchemes = Set.copyOf(allowedSchemes);
allowedPorts = Set.copyOf(allowedPorts);
allowedHostSuffixes = Set.copyOf(allowedHostSuffixes);
allowedHosts = Set.copyOf(allowedHosts);
additionalBlockedCidrs = java.util.List.copyOf(additionalBlockedCidrs);
}
/** HTTPS-only public egress with no redirect following: the safest useful default. */
public static DynamicTargetPolicy publicHttpsOnly(String name) {
return new DynamicTargetPolicy(
new DynamicTargetPolicyName(name),
Set.of("https"),
Set.of(443),
Set.of(),
Set.of(),
0,
false,
java.util.List.of());
}
public boolean hostAllowed(String canonicalHost) {
if (allowedHosts.isEmpty() && allowedHostSuffixes.isEmpty()) {
return true;
}
if (allowedHosts.contains(canonicalHost)) {
return true;
}
return allowedHostSuffixes.stream()
.anyMatch(suffix -> isSubdomainOrExactMatch(canonicalHost, suffix));
}
/**
* Matches a suffix only at a label boundary.
*
* <p>Plain {@code endsWith} is not a domain rule. A policy allowing {@code example.com} also
* accepted {@code evil-example.com}, which an attacker registers precisely because the check is
* written this way — the allowlist then reads as a restriction while permitting any domain whose
* name happens to end in the allowed text.
*
* <p>A leading dot in the configured suffix is tolerated and means the same thing, so {@code
* .example.com} and {@code example.com} both allow {@code api.example.com} and the apex.
*/
private static boolean isSubdomainOrExactMatch(String canonicalHost, String configuredSuffix) {
String suffix =
configuredSuffix.startsWith(".") ? configuredSuffix.substring(1) : configuredSuffix;
if (suffix.isEmpty()) {
return false;
}
if (canonicalHost.equals(suffix)) {
return true;
}
return canonicalHost.endsWith("." + suffix);
}
}
@@ -0,0 +1,215 @@
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* Decides whether a resolved address may be connected to (design §22.2).
*
* <p>Two details matter more than the list itself. IPv4-mapped IPv6 addresses are normalised back
* to IPv4 before classification, because {@code ::ffff:127.0.0.1} is loopback wearing a different
* hat. And cloud metadata endpoints are blocked explicitly rather than relying on the link-local
* rule, so an organisation-specific metadata address is still covered.
*/
public final class IpAddressClassifier {
private static final Set<String> METADATA_ADDRESSES =
Set.of("169.254.169.254", "fd00:ec2::254", "100.100.100.200", "192.0.0.192");
private final List<CidrRange> additionalBlockedRanges;
public IpAddressClassifier() {
this(List.of());
}
public IpAddressClassifier(List<String> additionalBlockedCidrs) {
Objects.requireNonNull(additionalBlockedCidrs, "additional blocked cidrs");
this.additionalBlockedRanges = additionalBlockedCidrs.stream().map(CidrRange::parse).toList();
}
/** Normalises an IPv4-mapped IPv6 address to its IPv4 form. */
public static InetAddress normalize(InetAddress address) {
if (!(address instanceof Inet6Address ipv6)) {
return address;
}
byte[] bytes = ipv6.getAddress();
boolean mapped = true;
for (int index = 0; index < 10; index++) {
if (bytes[index] != 0) {
mapped = false;
break;
}
}
if (!mapped || (bytes[10] & 0xFF) != 0xFF || (bytes[11] & 0xFF) != 0xFF) {
return address;
}
try {
return InetAddress.getByAddress(Arrays.copyOfRange(bytes, 12, 16));
} catch (UnknownHostException impossible) {
return address;
}
}
/**
* Whether an address must not be connected to.
*
* <p>Structured as an allowlist of globally routable unicast space, then the operator's own
* exclusions — not as a list of bad ranges. A denylist has to enumerate every special-purpose
* block IANA has ever assigned, and the ones it forgets are reachable: {@code 192.0.2.0/24},
* {@code 198.18.0.0/15}, {@code 240.0.0.0/4} and the IPv6 documentation and Teredo prefixes were
* all absent, and each of them can be made to resolve somewhere useful to an attacker. Requiring
* global unicast inverts the burden: an address is refused unless it is the kind of address a
* public webhook could legitimately live on.
*
* @param rawAddress the resolved address, possibly IPv4-mapped
* @return {@code true} when the platform must refuse the address
*/
public boolean forbidden(InetAddress rawAddress) {
InetAddress address = normalize(rawAddress);
if (!globallyRoutableUnicast(address)) {
return true;
}
if (METADATA_ADDRESSES.contains(address.getHostAddress())) {
return true;
}
return additionalBlockedRanges.stream().anyMatch(range -> range.contains(address));
}
/**
* Whether the address is in globally routable unicast space.
*
* <p>The JDK predicates cover loopback, link-local, site-local, multicast and wildcard. The
* remaining special-purpose blocks are listed explicitly because the JDK has no predicate for
* them and their absence is what made the previous denylist incomplete.
*/
private boolean globallyRoutableUnicast(InetAddress address) {
if (address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()
|| address.isMulticastAddress()) {
return false;
}
byte[] bytes = address.getAddress();
if (address instanceof Inet4Address) {
int first = bytes[0] & 0xFF;
int second = bytes[1] & 0xFF;
int third = bytes[2] & 0xFF;
// 0.0.0.0/8 "this network"; 100.64.0.0/10 carrier-grade NAT, routinely internal;
// 192.0.0.0/24 IETF protocol assignments; 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24
// documentation ranges; 198.18.0.0/15 benchmarking; 240.0.0.0/4 reserved, which includes the
// 255.255.255.255 broadcast address.
if (first == 0
|| (first == 100 && (second & 0xC0) == 64)
|| (first == 192 && second == 0 && third == 0)
|| (first == 192 && second == 0 && third == 2)
|| (first == 198 && second == 51 && third == 100)
|| (first == 203 && second == 0 && third == 113)
|| (first == 198 && (second & 0xFE) == 18)
|| (first & 0xF0) == 240) {
return false;
}
return true;
}
if (address instanceof Inet6Address) {
// fc00::/7 unique local; 2001:db8::/32 documentation; 2001::/32 Teredo; 100::/64 discard.
if ((bytes[0] & 0xFE) == 0xFC) {
return false;
}
int firstWord = ((bytes[0] & 0xFF) << 8) | (bytes[1] & 0xFF);
int secondWord = ((bytes[2] & 0xFF) << 8) | (bytes[3] & 0xFF);
if (firstWord == 0x2001 && (secondWord == 0x0db8 || secondWord == 0x0000)) {
return false;
}
if (firstWord == 0x0100 && secondWord == 0x0000) {
return false;
}
// 2000::/3 is the only globally routable unicast range currently assigned.
return (bytes[0] & 0xE0) == 0x20;
}
return false;
}
/** Minimal CIDR matcher for organisation-defined internal ranges. */
private static final class CidrRange {
private static final java.util.regex.Pattern SLASH = java.util.regex.Pattern.compile("/");
/** IPv4 dotted quad or an IPv6 literal; anything else is a hostname and is refused. */
private static final java.util.regex.Pattern LITERAL_ADDRESS =
java.util.regex.Pattern.compile("^[0-9.]+$|^[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*$");
private final byte[] network;
private final int prefixLength;
private CidrRange(byte[] network, int prefixLength) {
this.network = network.clone();
this.prefixLength = prefixLength;
}
/**
* Parses a CIDR strictly.
*
* <p>Every rejection here used to be an acceptance. {@code Integer.parseInt} took {@code -1}
* and {@code 33} without complaint, producing a range that matched everything or nothing; and
* {@code InetAddress.getByName} accepts a <em>hostname</em>, so a typo'd entry performed a DNS
* lookup at startup and pinned the block to whatever that name resolved to at that moment. An
* operator's exclusion list is a security control, and every one of those outcomes silently
* turned it into something else.
*/
static CidrRange parse(String cidr) {
Objects.requireNonNull(cidr, "cidr");
String[] parts = SLASH.split(cidr, -1);
if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) {
throw new IllegalArgumentException("invalid cidr, expected <address>/<prefix>: " + cidr);
}
if (!LITERAL_ADDRESS.matcher(parts[0]).matches()) {
throw new IllegalArgumentException(
"cidr address must be an ip literal, not a hostname: " + cidr);
}
byte[] network;
try {
network = InetAddress.getByName(parts[0]).getAddress();
} catch (UnknownHostException invalid) {
throw new IllegalArgumentException("invalid cidr address: " + cidr, invalid);
}
int prefixLength;
try {
prefixLength = Integer.parseInt(parts[1]);
} catch (NumberFormatException notANumber) {
throw new IllegalArgumentException("cidr prefix must be an integer: " + cidr, notANumber);
}
int maximumPrefix = network.length * 8;
if (prefixLength < 0 || prefixLength > maximumPrefix) {
throw new IllegalArgumentException(
"cidr prefix must be 0.." + maximumPrefix + " for this address family: " + cidr);
}
return new CidrRange(network, prefixLength);
}
boolean contains(InetAddress address) {
byte[] candidate = address.getAddress();
if (candidate.length != network.length) {
return false;
}
int fullBytes = prefixLength / 8;
for (int index = 0; index < fullBytes; index++) {
if (candidate[index] != network[index]) {
return false;
}
}
int remainingBits = prefixLength % 8;
if (remainingBits == 0) {
return true;
}
int mask = 0xFF << (8 - remainingBits);
return (candidate[fullBytes] & mask) == (network[fullBytes] & mask);
}
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpDnsException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
/**
* Resolves a host and validates <em>every</em> answer (design §22.1 steps 6-9).
*
* <p>Validating only the first answer is a common and fatal shortcut: a host that resolves to one
* public and one private address would pass, and the connection could still land on the private
* one. Any forbidden address in the answer set rejects the whole target.
*
* <p>Approved addresses travel to the socket through {@link CallScopedDnsPin}, installed by the
* gateway for the duration of one hop. They are deliberately not retained here between calls: the
* previous per-host map had neither a TTL nor a size bound, so it grew without limit and, worse,
* answered a later call with an address that was only ever validated for an earlier one. An address
* approved five minutes ago is not evidence about the host now, which is the entire premise of
* rebinding.
*/
public final class ValidatedDnsResolver {
private static final HttpFailureMetadata SCOPE =
HttpFailureMetadata.startup(new ClientProfileName("dynamic-target"));
private final Function<String, InetAddress[]> systemResolver;
private final IpAddressClassifier classifier;
private final Map<String, List<InetAddress>> approved = new ConcurrentHashMap<>();
public ValidatedDnsResolver(IpAddressClassifier classifier) {
this(classifier, ValidatedDnsResolver::systemResolve);
}
public ValidatedDnsResolver(
IpAddressClassifier classifier, Function<String, InetAddress[]> systemResolver) {
this.classifier = Objects.requireNonNull(classifier, "ip address classifier");
this.systemResolver = Objects.requireNonNull(systemResolver, "system resolver");
}
public List<InetAddress> resolve(String host) {
Objects.requireNonNull(host, "host");
InetAddress[] answers = systemResolver.apply(host);
if (answers == null || answers.length == 0) {
throw new HttpDnsException("dynamic target host did not resolve", SCOPE);
}
List<InetAddress> normalized = new ArrayList<>(answers.length);
for (InetAddress answer : answers) {
if (classifier.forbidden(answer)) {
approved.remove(host);
throw new HttpTargetRejectedException(
"dynamic target resolves to a forbidden address range", SCOPE);
}
normalized.add(IpAddressClassifier.normalize(answer));
}
List<InetAddress> immutable = List.copyOf(normalized);
approved.put(host, immutable);
return immutable;
}
public PinnedTarget pin(CanonicalTarget target) {
return new PinnedTarget(target, resolve(target.host()));
}
/** Addresses the transport may connect to; empty when the host was never validated. */
public List<InetAddress> approvedAddresses(String host) {
return approved.getOrDefault(host, List.of());
}
public void forget(String host) {
approved.remove(host);
}
private static InetAddress[] systemResolve(String host) {
try {
return InetAddress.getAllByName(host);
} catch (UnknownHostException unknown) {
throw new HttpDnsException("dynamic target host did not resolve", SCOPE, unknown);
}
}
}
@@ -0,0 +1,121 @@
package dev.caskeleton.adapter.outbound.httpclient.http3;
import java.util.List;
import java.util.Objects;
/**
* What the Experimental HTTP/3 transport can and cannot prove (design §13.7, §29).
*
* <p>The contract suite runs only the subset declared here. Declaring less than the truth costs
* coverage; declaring more would let the Experimental transport claim Stable guarantees it has not
* demonstrated.
*
* <p>Two earlier defects in this report are worth naming, because both made it say the opposite of
* the truth.
*
* <p>The first is that it probed {@code
* org.eclipse.jetty.quic.client.QuicClientConnectorConfigurator}, a class that does not exist in
* the Jetty version this repository pins. The probe therefore reported "no QUIC" on a classpath
* that carries the entire QUIC and HTTP/3 client stack — a false negative that nothing noticed
* because nothing acted on it.
*
* <p>The second is that the probe was disconnected from the provider. It asked whether *some* QUIC
* class existed, never whether {@link JettyHttp3TransportProvider} used one, and the provider was
* in fact building {@code HttpClientTransportOverHTTP}: plain HTTP/1.1 over TCP, reported as
* HTTP/3. The probe now names the exact classes the provider constructs, so the report cannot drift
* from it again without failing to load them.
*
* @param quicNativeSupportPresent whether the QUIC and HTTP/3 client classes the provider
* constructs are loadable
* @param tls13Available whether the runtime offers TLS 1.3, which HTTP/3 requires
* @param wireVerified whether a negotiated {@code h3} exchange against a real HTTP/3 server has
* been observed in this build; class presence is not evidence of interoperability
* @param dynamicTargetSupported always false — H3 needs validated address pinning this transport
* cannot yet provide
* @param unsupportedContracts the contracts the Experimental transport does not run
*/
public record Http3CapabilityReport(
boolean quicNativeSupportPresent,
boolean tls13Available,
boolean wireVerified,
boolean dynamicTargetSupported,
List<String> unsupportedContracts) {
/**
* The classes the provider actually constructs. Probing anything else would let the report and
* the provider disagree.
*/
private static final List<String> REQUIRED_QUIC_CLASSES =
List.of(
"org.eclipse.jetty.quic.client.ClientQuicConfiguration",
"org.eclipse.jetty.http3.client.HTTP3Client",
"org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3");
public Http3CapabilityReport {
Objects.requireNonNull(unsupportedContracts, "unsupported contracts");
unsupportedContracts = List.copyOf(unsupportedContracts);
}
public static Http3CapabilityReport detect() {
return new Http3CapabilityReport(
quicClassesPresent(),
detectTls13Support(),
// No HTTP/3 server is stood up anywhere in this build, so nothing has observed a negotiated
// h3 exchange. Until something does, the transport stays un-promotable no matter how
// complete its classpath looks.
false,
false,
List.of(
"dynamic-target-pinning",
"pool-saturation-evidence",
"forward-proxy-tunnel",
"negotiated-protocol-wire-proof"));
}
/**
* Whether the transport may be advertised as anything beyond Experimental.
*
* <p>Requires wire proof, not classpath proof. A complete set of QUIC classes says the code can
* be constructed; it says nothing about whether a peer negotiated {@code h3}.
*
* @return {@code true} only when a real negotiated exchange has been observed
*/
public boolean promotableToBeta() {
return quicNativeSupportPresent && tls13Available && wireVerified;
}
/**
* Whether the provider can build its transport at all.
*
* @return {@code true} when every class the provider constructs is loadable and TLS 1.3 is
* offered
*/
public boolean constructible() {
return quicNativeSupportPresent && tls13Available;
}
static List<String> requiredQuicClasses() {
return REQUIRED_QUIC_CLASSES;
}
private static boolean quicClassesPresent() {
for (String className : REQUIRED_QUIC_CLASSES) {
try {
Class.forName(className, false, Http3CapabilityReport.class.getClassLoader());
} catch (ClassNotFoundException absent) {
return false;
}
}
return true;
}
private static boolean detectTls13Support() {
try {
return List.of(
javax.net.ssl.SSLContext.getDefault().getSupportedSSLParameters().getProtocols())
.contains("TLSv1.3");
} catch (java.security.NoSuchAlgorithmException unavailable) {
return false;
}
}
}
@@ -0,0 +1,190 @@
package dev.caskeleton.adapter.outbound.httpclient.http3;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportCapabilities;
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportProvider;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.http.HttpVersion;
import org.eclipse.jetty.http3.client.HTTP3Client;
import org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3;
import org.eclipse.jetty.io.Transport;
import org.eclipse.jetty.quic.client.ClientQuicConfiguration;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.http.client.reactive.JettyClientHttpConnector;
/**
* Experimental HTTP/3 transport (design D-08, §13.7, §32.7).
*
* <p>Three guards keep it Experimental in practice, not just in documentation: an exact
* acknowledgement string, a capability report that must show QUIC and TLS 1.3, and a hard refusal
* to serve Dynamic Targets — H3 requires validated address pinning this transport cannot yet prove.
*
* <p>The Stable starter never auto-configures this provider.
*
* <p>This class used to build {@code HttpClientTransportOverHTTP} — plain HTTP/1.1 over TCP — and
* present it as HTTP/3. Every guard above passed, the profile declared {@code HTTP_3}, the
* acknowledgement was checked, and the resulting connection negotiated HTTP/1.1. A caller who opted
* into an experimental protocol got neither the protocol nor a warning. It now constructs the real
* QUIC-backed transport and refuses outright when it cannot, so the failure mode is a startup error
* rather than a silent downgrade.
*
* <p>Constructing the right transport is still not proof that HTTP/3 works. Nothing in this build
* stands up an HTTP/3 server, so {@link Http3CapabilityReport#promotableToBeta()} stays false
* regardless of how complete the classpath is.
*/
public final class JettyHttp3TransportProvider implements ReactiveTransportProvider {
private static final TransportId ID = new TransportId("jetty-http3");
private final JettyHttp3FailureClassifier classifier = new JettyHttp3FailureClassifier();
private final Map<TransportResourceKey, HttpClient> clients = new ConcurrentHashMap<>();
@Override
public TransportId id() {
return ID;
}
@Override
public ReactiveTransportCapabilities capabilities() {
return ReactiveTransportCapabilities.jettyHttp3Experimental();
}
public Http3CapabilityReport capabilityReport() {
return Http3CapabilityReport.detect();
}
@Override
public ClientHttpConnector create(
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) {
requireExperimentalAcknowledgement(profile);
if (profile.mode() == ClientMode.DYNAMIC) {
throw new HttpConfigurationException(
"the experimental http/3 transport does not support dynamic targets",
HttpFailureMetadata.startup(profile.name()));
}
HttpClient client = newHttp3Client(profile);
clients.put(new TransportResourceKey(profile.name(), generation), client);
listener.onRuntimeCreated(profile.name(), ID);
JettyClientHttpConnector connector = new JettyClientHttpConnector(client);
return connector;
}
/**
* Builds the QUIC-backed Jetty client, or refuses.
*
* <p>Package-visible so a test can assert which transport was constructed. Asserting on the
* connector cannot do it — {@code JettyClientHttpConnector} does not expose the client — and the
* defect this replaces was invisible precisely because nothing looked.
*
* @param profile the profile whose timeouts and pool bounds configure the client
* @return a client whose transport is HTTP/3 over QUIC
* @throws HttpConfigurationException when the runtime cannot offer TLS 1.3 or the QUIC classes
* are absent; never a downgrade to TCP
*/
static HttpClient newHttp3Client(ClientProfile profile) {
HttpClient client = new HttpClient(newHttp3Transport(profile));
client.setFollowRedirects(false);
client.setConnectTimeout(profile.timeout().connect().toMillis());
client.setIdleTimeout(profile.timeout().readIdle().toMillis());
client.setMaxConnectionsPerDestination(profile.pool().maxConnectionsPerRoute());
client.setMaxRequestsQueuedPerDestination(Math.max(1, profile.pool().maxPendingAcquires()));
return client;
}
/**
* The QUIC-backed transport, or a refusal.
*
* <p>Returned as its own value rather than read back off the client, because Jetty 12.1
* deprecated {@code HttpClient#getTransport()} for removal — a test that reached through the
* client would be asserting on an API scheduled to disappear.
*
* @param profile the profile whose connect and idle budgets configure the QUIC session
* @return an HTTP/3-over-QUIC transport
* @throws HttpConfigurationException when TLS 1.3 or the QUIC classes are unavailable
*/
static HttpClientTransportOverHTTP3 newHttp3Transport(ClientProfile profile) {
Http3CapabilityReport report = Http3CapabilityReport.detect();
if (!report.tls13Available()) {
throw new HttpConfigurationException(
"http/3 requires TLS 1.3, which this runtime does not offer",
HttpFailureMetadata.startup(profile.name()));
}
if (!report.quicNativeSupportPresent()) {
throw new HttpConfigurationException(
"http/3 requires the Jetty QUIC client stack "
+ Http3CapabilityReport.requiredQuicClasses()
+ ", which is not on the classpath; refusing rather than falling back to TCP",
HttpFailureMetadata.startup(profile.name()));
}
HTTP3Client http3Client = new HTTP3Client(new ClientQuicConfiguration());
http3Client.getClientConnector().setConnectTimeout(profile.timeout().connect());
http3Client
.getHTTP3Configuration()
.setStreamIdleTimeout(profile.timeout().readIdle().toMillis());
return new HttpClientTransportOverHTTP3(http3Client, Transport.UDP_IP);
}
/**
* The wire version this transport is configured to speak.
*
* <p>Named for what it is. The previous {@code negotiatedVersion} claimed to describe the wire
* while reading only a classpath probe, and returned HTTP/2 for a client that was speaking
* HTTP/1.1. A negotiated version can only come from an exchange, and this build has none.
*
* @return always HTTP/3 — the provider refuses to build anything else
*/
public HttpVersion configuredVersion() {
return HttpVersion.HTTP_3;
}
@Override
public TransportFailureClassifier failureClassifier() {
return classifier;
}
@Override
public void close(ClientProfile profile, RuntimeGeneration generation) {
HttpClient client = clients.remove(new TransportResourceKey(profile.name(), generation));
if (client == null) {
return;
}
try {
client.stop();
} catch (Exception failure) {
throw new IllegalStateException("jetty http/3 client did not stop cleanly", failure);
}
}
private void requireExperimentalAcknowledgement(ClientProfile profile) {
String acknowledgement =
profile
.experimentalAcknowledgement()
.orElseThrow(
() ->
new HttpConfigurationException(
"http/3 requires an explicit experimental acknowledgement",
HttpFailureMetadata.startup(profile.name())));
try {
Http3ExperimentalAcknowledgement unused =
new Http3ExperimentalAcknowledgement(acknowledgement);
assert unused != null;
} catch (IllegalArgumentException invalid) {
throw new HttpConfigurationException(
"http/3 requires an explicit experimental acknowledgement",
HttpFailureMetadata.startup(profile.name()),
invalid);
}
}
}
@@ -0,0 +1,83 @@
package dev.caskeleton.adapter.outbound.httpclient.jdk;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportCapabilities;
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportProvider;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey;
import java.net.http.HttpClient;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.JdkClientHttpRequestFactory;
/**
* Lightweight blocking alternative (design D-06, §13.5).
*
* <p>Capability validation runs before any client is built: a profile this transport cannot honour
* fails without ever reaching the network.
*/
public final class JdkBlockingTransportProvider implements BlockingTransportProvider {
private static final TransportId ID = new TransportId("jdk");
private final JdkClientFactory clientFactory = new JdkClientFactory();
private final JdkFailureClassifier classifier = new JdkFailureClassifier();
private final JdkTransportCapabilityPolicy capabilityPolicy = new JdkTransportCapabilityPolicy();
private final Map<TransportResourceKey, HttpClient> clients = new ConcurrentHashMap<>();
private final Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver;
public JdkBlockingTransportProvider() {
this(profile -> Optional.empty());
}
public JdkBlockingTransportProvider(
Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver) {
this.tlsMaterialResolver = Objects.requireNonNull(tlsMaterialResolver, "tls material resolver");
}
@Override
public TransportId id() {
return ID;
}
@Override
public BlockingTransportCapabilities capabilities() {
return BlockingTransportCapabilities.lightweightHttp11AndHttp2();
}
@Override
public ClientHttpRequestFactory create(
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(listener, "lifecycle listener");
capabilityPolicy.validate(profile);
HttpClient client = clientFactory.create(profile, tlsMaterialResolver.apply(profile));
clients.put(new TransportResourceKey(profile.name(), generation), client);
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(client);
factory.setReadTimeout(profile.timeout().responseHeader());
listener.onRuntimeCreated(profile.name(), ID);
return factory;
}
@Override
public TransportFailureClassifier failureClassifier() {
return classifier;
}
@Override
public void close(ClientProfile profile, RuntimeGeneration generation) {
HttpClient client = clients.remove(new TransportResourceKey(profile.name(), generation));
if (client != null) {
client.close();
}
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.adapter.outbound.httpclient.jdk;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol;
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
import java.net.InetSocketAddress;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.util.Objects;
import java.util.Optional;
import javax.net.ssl.SSLParameters;
/**
* Builds the JDK HttpClient runtime for one profile (design §13.5).
*
* <p>Redirects are always {@code NEVER}: the platform re-validates every hop itself, and the JDK's
* own follower would silently forward credentials across origins.
*/
public final class JdkClientFactory {
public HttpClient create(ClientProfile profile, Optional<SslContextMaterial> tlsMaterial) {
Objects.requireNonNull(profile, "profile");
HttpClient.Builder builder =
HttpClient.newBuilder()
.connectTimeout(profile.timeout().connect())
.followRedirects(HttpClient.Redirect.NEVER)
.version(
profile.protocols().contains(HttpProtocol.HTTP_2)
? HttpClient.Version.HTTP_2
: HttpClient.Version.HTTP_1_1);
// TLS parameters are applied whether or not the profile supplies custom material. They used to
// be set only inside this ifPresent, so a profile that declared `tls.protocols: [TLSv1.3]` and
// used the JVM trust store — the common case — configured nothing at all and negotiated
// whatever the platform default allowed, TLS 1.2 included. A declared TLS floor that only
// applies when you also supply a custom truststore is not a floor.
SSLParameters parameters = new SSLParameters();
parameters.setProtocols(
tlsMaterial
.map(SslContextMaterial::protocolArray)
.orElseGet(() -> profile.tls().protocols().toArray(String[]::new)));
// Endpoint identification is set explicitly: the JDK default for a raw SSLParameters
// instance is "no hostname check", which design §21.2 forbids.
parameters.setEndpointIdentificationAlgorithm("HTTPS");
// ALPN is declared explicitly. The JDK client also derives it from the requested version,
// so this is not load-bearing today (verified by NegotiatedProtocolContractTest, which
// still passes without it) — it makes the advertised protocol set a property of the
// profile rather than of a JDK internal.
parameters.setApplicationProtocols(
profile.protocols().contains(HttpProtocol.HTTP_2)
? new String[] {"h2", "http/1.1"}
: new String[] {"http/1.1"});
tlsMaterial.ifPresent(material -> builder.sslContext(material.sslContext()));
builder.sslParameters(parameters);
if (profile.proxy().enabled()) {
builder.proxy(
ProxySelector.of(new InetSocketAddress(profile.proxy().host(), profile.proxy().port())));
} else {
builder.proxy(ProxySelector.of(null));
}
return builder.build();
}
}
@@ -0,0 +1,120 @@
package dev.caskeleton.adapter.outbound.httpclient.jdk;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import java.io.InterruptedIOException;
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.net.http.HttpConnectTimeoutException;
import java.net.http.HttpTimeoutException;
import java.security.cert.CertificateException;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLPeerUnverifiedException;
/**
* Maps JDK HttpClient failures onto stable evidence (design §13.3, §13.5).
*
* <p>The JDK exposes fewer stage-specific exceptions than Apache, so anything other than a proven
* pre-send failure stays conservative.
*/
public final class JdkFailureClassifier implements TransportFailureClassifier {
@Override
public TransportFailure classify(Throwable failure, AttemptStage lastObservedStage) {
// Spring and the JDK both wrap engine exceptions; the chain is inspected so a wrapped
// ConnectException still classifies as provably NOT_SENT.
for (Throwable cause : chain(failure)) {
TransportFailure recognized = recognize(cause, lastObservedStage);
if (recognized != null) {
return recognized;
}
}
return fallback(lastObservedStage);
}
private TransportFailure recognize(Throwable cause, AttemptStage lastObservedStage) {
if (cause instanceof HttpConnectTimeoutException
|| cause instanceof ConnectException
|| cause instanceof NoRouteToHostException) {
return TransportFailure.notSent(
AttemptStage.CONNECT, FailureCategory.CONNECT, "CONNECT_FAILED");
}
if (cause instanceof UnknownHostException) {
return TransportFailure.notSent(
AttemptStage.DNS, FailureCategory.DNS, "DNS_RESOLUTION_FAILED");
}
if (cause instanceof SSLPeerUnverifiedException || cause instanceof CertificateException) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_TRUST_FAILED");
}
if (cause instanceof SSLHandshakeException) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_HANDSHAKE_FAILED");
}
if (cause instanceof SSLException
&& !lastObservedStage.isAtLeast(AttemptStage.REQUEST_HEADERS)) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_TRANSIENT_FAILURE");
}
if (cause instanceof InterruptedIOException
&& !(cause instanceof HttpTimeoutException)
&& !(cause instanceof SocketTimeoutException)) {
// Cancellation, not a timeout. Classifying it as a timeout let the retry engine reissue a
// request the caller had just cancelled, and dropped the interrupt so the thread could not
// see its own cancellation either.
Thread.currentThread().interrupt();
if (lastObservedStage.provesNotSent()) {
return TransportFailure.notSent(
lastObservedStage, FailureCategory.CANCELLED, "ATTEMPT_INTERRUPTED");
}
return TransportFailure.sentNoResponse(
lastObservedStage, FailureCategory.CANCELLED, "ATTEMPT_INTERRUPTED");
}
if (cause instanceof HttpTimeoutException || cause instanceof SocketTimeoutException) {
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
return new TransportFailure(
lastObservedStage,
ExecutionEvidence.PARTIAL_RESPONSE,
FailureCategory.RESPONSE_TIMEOUT,
"RESPONSE_BODY_TIMEOUT");
}
return TransportFailure.sentNoResponse(
AttemptStage.RESPONSE_HEADERS,
FailureCategory.RESPONSE_TIMEOUT,
"RESPONSE_HEADER_TIMEOUT");
}
return null;
}
private TransportFailure fallback(AttemptStage lastObservedStage) {
if (lastObservedStage.provesNotSent()) {
return TransportFailure.notSent(
lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
}
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
return new TransportFailure(
lastObservedStage,
ExecutionEvidence.PARTIAL_RESPONSE,
FailureCategory.RESPONSE_TRUNCATED,
"TRANSPORT_FAILURE");
}
return TransportFailure.sentNoResponse(
lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
}
private java.util.List<Throwable> chain(Throwable failure) {
java.util.List<Throwable> chain = new java.util.ArrayList<>();
Throwable current = failure;
while (current != null && !chain.contains(current)) {
chain.add(current);
current = current.getCause();
}
return chain;
}
}
@@ -0,0 +1,291 @@
package dev.caskeleton.adapter.outbound.httpclient.profile;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Fail-closed startup validation for a Named Client Profile (design §11.2, §30.1).
*
* <p>Every guard in the design has exactly one stable violation code here. The result is sorted so
* a configuration error reports deterministically across runs and machines.
*/
public final class ClientProfileValidator {
public List<ClientProfileViolation> validate(
ClientProfile profile, RuntimeEnvironment environment) {
List<ClientProfileViolation> violations = new ArrayList<>();
validateTarget(profile, environment, violations);
validateRedirect(profile, violations);
validateTimeouts(profile, violations);
validateUnsupportedSettings(profile, violations);
validateLimits(profile, violations);
validateTransportCapability(profile, environment, violations);
validateCredentials(profile, violations);
validateTls(profile, environment, violations);
validateRetry(profile, violations);
validateObservability(profile, environment, violations);
validateProductionCompleteness(profile, environment, violations);
violations.sort(ClientProfileViolation::compareTo);
return List.copyOf(violations);
}
private void validateTarget(
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
URI baseUrl = profile.baseUrl();
if (profile.trusted() && baseUrl == null) {
out.add(violation("TRUSTED_BASE_URL_REQUIRED", profile, "base-url"));
return;
}
if (baseUrl == null) {
return;
}
if (baseUrl.getUserInfo() != null) {
out.add(violation("BASE_URL_USERINFO_FORBIDDEN", profile, "base-url"));
}
if (baseUrl.getRawQuery() != null) {
out.add(violation("BASE_URL_QUERY_FORBIDDEN", profile, "base-url"));
}
String scheme = baseUrl.getScheme() == null ? "" : baseUrl.getScheme().toLowerCase(Locale.ROOT);
if (!"https".equals(scheme) && environment.production()) {
out.add(violation("PLAINTEXT_PRODUCTION_TARGET", profile, "base-url"));
}
String host = baseUrl.getHost();
if (host != null
&& !profile.allowedHosts().isEmpty()
&& !profile.allowedHosts().contains(host.toLowerCase(Locale.ROOT))) {
out.add(violation("ALLOWED_HOST_MISMATCH", profile, "allowed-hosts"));
}
int port = baseUrl.getPort() >= 0 ? baseUrl.getPort() : defaultPort(scheme);
if (!profile.allowedPorts().isEmpty() && !profile.allowedPorts().contains(port)) {
out.add(violation("ALLOWED_PORT_MISMATCH", profile, "allowed-ports"));
}
}
private void validateRedirect(ClientProfile profile, List<ClientProfileViolation> out) {
RedirectSettings redirect = profile.redirect();
if (!redirect.enabled()) {
return;
}
if (redirect.maxHops() == 0) {
out.add(violation("REDIRECT_POLICY_INVALID", profile, "redirect.max-hops"));
}
if (redirect.allowCrossOrigin()
&& profile.authentication().type().attachesDefaultCredential()) {
out.add(violation("REDIRECT_CROSS_ORIGIN_CREDENTIAL_POLICY_REQUIRED", profile, "redirect"));
}
if (profile.api() == ClientApiType.WEB_CLIENT) {
// Engine-level redirect following is disabled on every transport, and only the blocking stack
// has a coordinator to follow hops itself with per-hop re-validation. A reactive profile that
// enabled redirects therefore did not follow them: the caller received the 302 as an ordinary
// response and read its empty body as the answer. Refusing is the honest outcome until the
// reactive coordinator exists — a configured guarantee that silently does nothing is worse
// than one the platform declines to offer.
out.add(violation("REACTIVE_REDIRECT_UNSUPPORTED", profile, "redirect.enabled"));
}
}
/**
* Settings that bind but reach no transport are refused rather than ignored.
*
* <p>Three of them had no consumer anywhere: {@code timeout.dns}, {@code
* proxy.credential-provider} and {@code proxy.import-ambient-no-proxy}. An operator who set a DNS
* timeout believed resolution was bounded and it was not; one who named a proxy credential
* provider believed the proxy was authenticated and it was not. Neither Apache nor the JDK client
* exposes a DNS-resolution timeout, and no proxy credential path exists in this platform yet, so
* the honest position is to refuse a value the platform cannot honour instead of accepting it and
* doing nothing.
*
* <p>The default values are accepted, so an operator who never touched these settings is
* unaffected — only a deliberate, unmet request fails.
*/
private void validateUnsupportedSettings(
ClientProfile profile, List<ClientProfileViolation> out) {
if (!TimeoutSettings.DEFAULT_DNS.equals(profile.timeout().dns())) {
out.add(violation("DNS_TIMEOUT_UNSUPPORTED", profile, "timeout.dns"));
}
if (profile.proxy().credentialProvider().isPresent()) {
out.add(violation("PROXY_CREDENTIAL_UNSUPPORTED", profile, "proxy.credential-provider"));
}
if (profile.proxy().importAmbientNoProxy()) {
out.add(
violation(
"PROXY_AMBIENT_NO_PROXY_UNSUPPORTED", profile, "proxy.import-ambient-no-proxy"));
}
}
private void validateTimeouts(ClientProfile profile, List<ClientProfileViolation> out) {
TimeoutSettings timeout = profile.timeout();
if (timeout.totalCall().compareTo(timeout.connect()) < 0
|| timeout.totalCall().compareTo(timeout.responseHeader()) < 0) {
out.add(violation("INVALID_TIMEOUT_BUDGET", profile, "timeout.total-call"));
}
if (timeout.totalCall().isZero() || timeout.totalCall().isNegative()) {
out.add(violation("INVALID_TIMEOUT_BUDGET", profile, "timeout.total-call"));
}
}
private void validateLimits(ClientProfile profile, List<ClientProfileViolation> out) {
if (profile.response().maxDecodedBytes() > ResponseLimits.GLOBAL_HARD_MAXIMUM_BYTES) {
out.add(violation("RESPONSE_HARD_MAXIMUM_EXCEEDED", profile, "response.max-decoded-bytes"));
}
}
/**
* Observability switches that describe an unsafe intent are refused in production.
*
* <p>Both settings were bindable and inert: nothing read {@code full-url-recording}, and {@code
* body-logging} reached only the actuator report. Leaving them that way is the worse of the two
* failure modes — an operator who set them believed the platform was recording full URLs or
* bodies, and an operator who left them false had no assurance that it was not. Recording an
* expanded URL puts path identifiers and query strings into unbounded metric tags and logs;
* recording bodies puts someone else's data there. Neither belongs in production, so the intent
* is representable and rejectable rather than silently ignored.
*/
private void validateObservability(
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
if (!environment.production()) {
return;
}
if (profile.observability().fullUrlRecording()) {
out.add(
violation("FULL_URL_RECORDING_FORBIDDEN", profile, "observability.full-url-recording"));
}
if (profile.observability().bodyLogging()) {
out.add(violation("BODY_LOGGING_FORBIDDEN", profile, "observability.body-logging"));
}
}
private void validateTransportCapability(
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
if (profile.transport() == TransportType.SIMPLE && environment.production()) {
out.add(violation("PRODUCTION_SIMPLE_FACTORY_FORBIDDEN", profile, "transport"));
}
if (profile.transport() == TransportType.JDK
&& (profile.pool().requiresRoutePool() || profile.pool().requiresBoundedPendingQueue())) {
out.add(violation("JDK_FINE_GRAINED_POOL_UNSUPPORTED", profile, "transport"));
}
if (profile.protocols().contains(HttpProtocol.HTTP_3)
&& profile.experimentalAcknowledgement().isEmpty()) {
out.add(violation("HTTP3_STABLE_FORBIDDEN", profile, "protocols"));
}
if (profile.mode() == ClientMode.DYNAMIC
&& (profile.transport() == TransportType.JDK
|| profile.transport() == TransportType.JETTY)) {
out.add(violation("DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED", profile, "transport"));
}
if (profile.mode() == ClientMode.DYNAMIC && profile.baseUrl() == null) {
// A DYNAMIC profile takes its destination per call, but the runtime factory still builds its
// client from a base URL and called toString() on it unconditionally. The profile was
// accepted at startup and produced a NullPointerException while assembling the runtime.
out.add(violation("DYNAMIC_BASE_URL_REQUIRED", profile, "base-url"));
}
if (ProtocolIntent.of(profile.protocols()).requiresHttp2()
&& profile.transport() != TransportType.REACTOR_NETTY) {
// Only Reactor Netty can be configured to offer H2 and nothing else. The JDK client treats
// HTTP_2 as a preference and silently negotiates HTTP/1.1; Apache's classic client is
// HTTP/1.1
// only. A profile that requires H2 on either of them was getting HTTP/1.1 with no signal.
out.add(violation("HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED", profile, "protocols"));
}
if (profile.pool().maxConnectionsPerRoute() > profile.pool().maxTotalConnections()) {
// A per-route ceiling above the total is incoherent, and on Reactor — where the per-route
// knob is the only one that exists — it silently becomes the effective limit.
out.add(violation("POOL_ROUTE_EXCEEDS_TOTAL", profile, "pool.max-connections-per-route"));
}
if (profile.tls().protocols().isEmpty()) {
// An empty set passed validation and then let the JVM pick, so a profile that meant to pin a
// TLS floor got whatever the platform default happened to be — including TLS 1.2 on a profile
// whose operator had deliberately emptied the list to "tighten" it.
out.add(violation("TLS_PROTOCOL_SET_REQUIRED", profile, "tls.protocols"));
}
if (profile.mode() == ClientMode.DYNAMIC && profile.proxy().enabled()) {
// A forward proxy re-resolves the hostname on its own side, so the addresses this platform
// validated and pinned are not the addresses the connection reaches. The SSRF defence would
// be present, correct, and bypassed.
out.add(violation("DYNAMIC_TARGET_PROXY_UNSUPPORTED", profile, "proxy.enabled"));
}
}
private void validateCredentials(ClientProfile profile, List<ClientProfileViolation> out) {
if (profile.mode() == ClientMode.DYNAMIC
&& profile.authentication().type().attachesDefaultCredential()) {
out.add(violation("DYNAMIC_DEFAULT_CREDENTIAL_FORBIDDEN", profile, "authentication.type"));
}
if (profile.authentication().type() == AuthenticationType.OAUTH2_CLIENT_CREDENTIALS
&& profile.authentication().registrationId().isEmpty()) {
out.add(violation("OAUTH2_REGISTRATION_REQUIRED", profile, "authentication.registration-id"));
}
if (profile.authentication().type() == AuthenticationType.API_KEY_HEADER
&& profile.authentication().headerName().isEmpty()) {
out.add(violation("API_KEY_HEADER_NAME_REQUIRED", profile, "authentication.header-name"));
}
}
private void validateTls(
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
TlsSettings tls = profile.tls();
if (tls.trustAll()) {
out.add(violation("TRUST_ALL_FORBIDDEN", profile, "tls.trust-all"));
}
if (!tls.hostnameVerification()) {
out.add(violation("HOSTNAME_VERIFICATION_REQUIRED", profile, "tls.hostname-verification"));
}
if (tls.allowPlainHttp() && environment.production()) {
out.add(violation("PLAINTEXT_FALLBACK_FORBIDDEN", profile, "tls.allow-plain-http"));
}
boolean unsupportedProtocol =
tls.protocols().stream()
.anyMatch(value -> !"TLSv1.2".equals(value) && !"TLSv1.3".equals(value));
if (unsupportedProtocol) {
out.add(violation("TLS_PROTOCOL_FORBIDDEN", profile, "tls.protocols"));
}
}
private void validateRetry(ClientProfile profile, List<ClientProfileViolation> out) {
if (profile.retry().enabled()
&& profile.retry().baseBackoff().isZero()
&& profile.retry().jitter() == JitterStrategy.NONE) {
out.add(violation("RETRY_BACKOFF_REQUIRED", profile, "retry.base-backoff"));
}
// `policy` and `max-attempts` must agree. Nothing on the execution path read `policy` — only
// `max-attempts` decided whether a call retried — so the actuator could report
// `retryPolicy: none` for a profile that was retrying three times, and a profile named after a
// policy could have retry switched off by a `max-attempts` nobody re-read. A displayed policy
// that cannot contradict behaviour is worth more than one that describes an intention.
boolean declaredNone = "none".equalsIgnoreCase(profile.retry().policy());
if (declaredNone && profile.retry().enabled()) {
out.add(violation("RETRY_POLICY_CONTRADICTS_ATTEMPTS", profile, "retry.policy"));
}
if (!declaredNone && !profile.retry().enabled()) {
out.add(violation("RETRY_POLICY_CONTRADICTS_ATTEMPTS", profile, "retry.max-attempts"));
}
}
private void validateProductionCompleteness(
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
if (!environment.production()) {
return;
}
if (profile.trusted() && profile.allowedHosts().isEmpty()) {
out.add(violation("MISSING_PRODUCTION_SETTING", profile, "allowed-hosts"));
}
if (profile.request().maxBodyBytes() == 0) {
out.add(violation("MISSING_PRODUCTION_SETTING", profile, "request.max-body-bytes"));
}
if (profile.tls().profileId().isEmpty()) {
out.add(violation("MISSING_PRODUCTION_SETTING", profile, "tls.profile-id"));
}
}
private static int defaultPort(String scheme) {
return "http".equals(scheme) ? 80 : 443;
}
private static ClientProfileViolation violation(
String code, ClientProfile profile, String setting) {
return new ClientProfileViolation(
code, "profile=" + profile.name().value() + " setting=" + setting);
}
}
@@ -0,0 +1,187 @@
package dev.caskeleton.adapter.outbound.httpclient.profile;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* Atomic pointer from profile name to its current runtime generation (design §7.2).
*
* <p>A swap publishes the replacement first and drains the predecessor afterwards, so a rotation is
* never observable as a gap. The single scheduled executor exists only to enforce drain deadlines
* and is created lazily; it is shut down with the registry so no thread outlives it.
*/
public final class ClientRuntimeRegistry implements AutoCloseable {
private static final long SHUTDOWN_AWAIT_MILLIS = 5_000L;
private final ConcurrentMap<ClientProfileName, AtomicReference<ClientRuntime>> runtimes =
new ConcurrentHashMap<>();
private final AtomicReference<ScheduledExecutorService> drainScheduler = new AtomicReference<>();
/**
* Generations that have been replaced but are not yet closed.
*
* <p>Held so shutdown can reach them. A rotation moved the old generation out of {@code runtimes}
* and left it owned only by a scheduled drain task, so a registry that closed before that task
* fired leaked the whole generation — and the resource-bound suite could not see it, because
* nothing enumerated it.
*/
private final Set<ClientRuntime> retired = java.util.concurrent.ConcurrentHashMap.newKeySet();
public ClientRuntimeRegistry(Map<ClientProfileName, ClientRuntime> initial) {
Objects.requireNonNull(initial, "initial runtimes");
initial.forEach((name, runtime) -> runtimes.put(name, new AtomicReference<>(runtime)));
}
public static ClientRuntimeRegistry empty() {
return new ClientRuntimeRegistry(Map.of());
}
public Set<ClientProfileName> names() {
return Set.copyOf(runtimes.keySet());
}
public void register(ClientRuntime runtime) {
Objects.requireNonNull(runtime, "runtime");
runtimes.put(runtime.name(), new AtomicReference<>(runtime));
}
/**
* Reserves the current generation. Retries against the newly published generation when the
* observed one began draining between the read and the reservation.
*/
public ClientRuntimeLease acquire(ClientProfileName name) {
AtomicReference<ClientRuntime> holder = holder(name);
while (true) {
ClientRuntime runtime = holder.get();
if (runtime.tryAcquire()) {
return new ClientRuntimeLease(runtime, runtime::release);
}
if (holder.get() == runtime) {
throw new IllegalStateException("http client runtime is shutting down: " + name.value());
}
}
}
public ClientRuntime current(ClientProfileName name) {
return holder(name).get();
}
public boolean contains(ClientProfileName name) {
return runtimes.containsKey(name);
}
/** Publishes {@code replacement} and drains the previous generation (design §7.2 steps 3-6). */
public void swap(ClientProfileName name, ClientRuntime replacement, Duration drainTimeout) {
Objects.requireNonNull(replacement, "replacement runtime");
Objects.requireNonNull(drainTimeout, "drain timeout");
ClientRuntime previous = holder(name).getAndSet(replacement);
if (previous == replacement) {
return;
}
// Tracked until it is actually closed. A retired generation that was still draining when the
// registry shut down was reachable from nothing: close() walked only the current generations,
// so its pool, its connections and its drain task outlived the registry that created them.
retired.add(previous);
previous.beginDrain(drainTimeout);
if (previous.state() != ClientRuntimeState.CLOSED && !drainTimeout.isZero()) {
ScheduledFuture<?> unusedDrainDeadline =
scheduler()
.schedule(
() -> {
try {
previous.forceClose();
} finally {
retired.remove(previous);
}
},
drainTimeout.toMillis(),
TimeUnit.MILLISECONDS);
assert unusedDrainDeadline != null;
} else {
retired.remove(previous);
}
}
@Override
public void close() {
List<ClientRuntime> all = new ArrayList<>();
runtimes.values().forEach(holder -> all.add(holder.get()));
// Retired-but-still-draining generations are closed too; they used to survive registry
// shutdown entirely.
all.addAll(retired);
// Every runtime is closed even when one refuses. forEach stopped at the first exception, so a
// single misbehaving pool left every remaining connection, thread and socket open — shutdown
// leaked more the worse the failure was.
RuntimeException firstFailure = null;
for (ClientRuntime runtime : all) {
try {
runtime.forceClose();
} catch (RuntimeException failure) {
if (firstFailure == null) {
firstFailure = failure;
} else {
firstFailure.addSuppressed(failure);
}
}
}
retired.clear();
runtimes.clear();
if (firstFailure != null) {
throw firstFailure;
}
ScheduledExecutorService scheduler = drainScheduler.getAndSet(null);
if (scheduler != null) {
// Await termination: a registry that returns while its drain thread is still alive would
// leak a thread per rotation cycle, which the resource-bound suite exists to catch.
scheduler.shutdownNow();
try {
if (!scheduler.awaitTermination(SHUTDOWN_AWAIT_MILLIS, TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("http client drain scheduler did not terminate");
}
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
}
}
private AtomicReference<ClientRuntime> holder(ClientProfileName name) {
AtomicReference<ClientRuntime> holder = runtimes.get(name);
if (holder == null) {
throw new NoSuchElementException("unregistered http client profile: " + name.value());
}
return holder;
}
private ScheduledExecutorService scheduler() {
ScheduledExecutorService existing = drainScheduler.get();
if (existing != null) {
return existing;
}
ScheduledExecutorService created =
Executors.newSingleThreadScheduledExecutor(
runnable -> {
Thread thread = new Thread(runnable, "httpclient-runtime-drain");
thread.setDaemon(true);
return thread;
});
if (drainScheduler.compareAndSet(null, created)) {
return created;
}
created.shutdownNow();
return drainScheduler.get();
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.adapter.outbound.httpclient.profile;
import java.util.Set;
/**
* What a profile's declared protocol set actually asks for (design §6.3, §24).
*
* <p>A bare set of protocols does not say whether HTTP/2 is a preference or a requirement, and
* every transport resolved that ambiguity in the direction that could not fail. The JDK client
* treats {@code HTTP_2} as "try H2, fall back to H1"; Reactor Netty was configured with {@code {H2,
* HTTP11}} whenever H2 appeared at all. A profile that declared only {@code HTTP_2} — the way an
* operator states a requirement — therefore ran happily over HTTP/1.1, and nothing anywhere said
* so. gRPC-style upstreams, header-compression assumptions and concurrency budgets all quietly
* changed meaning.
*
* <p>Naming the intent makes the requirement expressible, and lets a transport that cannot honour
* it refuse at startup instead of downgrading at runtime.
*/
public enum ProtocolIntent {
/** Only HTTP/1.1 is acceptable. */
H1_ONLY,
/** Prefer HTTP/2, accept HTTP/1.1. The safe default for a general-purpose upstream. */
NEGOTIATE_H2_H1,
/** HTTP/2 is required; falling back to HTTP/1.1 is a failure, not a degradation. */
H2_REQUIRED,
/** Experimental HTTP/3, gated by the acknowledgement and its own transport. */
H3_EXPERIMENTAL;
/**
* Derives the intent a declared protocol set expresses.
*
* @param protocols the profile's declared protocols
* @return the intent; declaring HTTP/2 alone means it is required
*/
public static ProtocolIntent of(Set<HttpProtocol> protocols) {
if (protocols.contains(HttpProtocol.HTTP_3)) {
return H3_EXPERIMENTAL;
}
boolean h2 = protocols.contains(HttpProtocol.HTTP_2);
boolean h1 = protocols.contains(HttpProtocol.HTTP_1_1);
if (h2 && h1) {
return NEGOTIATE_H2_H1;
}
if (h2) {
return H2_REQUIRED;
}
return H1_ONLY;
}
public boolean requiresHttp2() {
return this == H2_REQUIRED;
}
public boolean allowsHttp2() {
return this == NEGOTIATE_H2_H1 || this == H2_REQUIRED;
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.outbound.httpclient.profile;
import java.time.Duration;
import java.util.Objects;
/**
* Stage timeouts and the total call budget (design §15.1).
*
* <p>{@link #totalCall()} is the upper budget for everything, including pool acquire and retry
* backoff. {@link #streamingIdle()} is deliberately separate so a long-lived SSE stream is not
* killed by the request-shaped total budget (design §15.3).
*/
public record TimeoutSettings(
// Hostname resolution budget. Currently unenforced: neither the Apache classic client nor the
// JDK client exposes a DNS resolution timeout, so the platform refuses a non-default value
// rather than accepting one it cannot honour. See DNS_TIMEOUT_UNSUPPORTED.
Duration dns,
Duration connect,
Duration tlsHandshake,
Duration proxyConnect,
Duration requestWriteIdle,
Duration responseHeader,
Duration readIdle,
Duration totalCall,
Duration streamingIdle) {
/**
* The shipped {@code timeout.dns} default.
*
* <p>Named so the validator can tell "the operator left this alone" from "the operator asked for
* a DNS budget the platform cannot deliver". Only the second is refused.
*/
public static final Duration DEFAULT_DNS = Duration.ofMillis(300);
public TimeoutSettings {
Objects.requireNonNull(dns, "dns timeout");
Objects.requireNonNull(connect, "connect timeout");
Objects.requireNonNull(tlsHandshake, "tls handshake timeout");
Objects.requireNonNull(proxyConnect, "proxy connect timeout");
Objects.requireNonNull(requestWriteIdle, "request write idle timeout");
Objects.requireNonNull(responseHeader, "response header timeout");
Objects.requireNonNull(readIdle, "read idle timeout");
Objects.requireNonNull(totalCall, "total call timeout");
Objects.requireNonNull(streamingIdle, "streaming idle timeout");
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.httpclient.reactor;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import java.util.Objects;
import reactor.netty.resources.ConnectionProvider;
/**
* Creates a connection pool scoped to one profile (design §13.6).
*
* <p>A shared global pool would let one slow upstream starve every other one, so each profile gets
* its own named provider with its own limits and eviction.
*/
public final class ReactorConnectionProviderFactory {
/**
* Builds the pool, mapping each setting onto the Reactor knob that means the same thing.
*
* <p>{@code ConnectionProvider.maxConnections} is a <em>per-remote-host</em> ceiling, and it was
* being handed {@code maxTotalConnections}. For a trusted profile with one base URL the two
* coincide, so nothing looked wrong; for a dynamic profile talking to many hosts it meant every
* destination independently received the budget intended for all of them combined, and {@code
* maxConnectionsPerRoute} — the setting that actually describes this limit — was ignored
* entirely.
*
* <p>Reactor Netty has no cross-destination ceiling to map {@code maxTotalConnections} onto. That
* is a real gap rather than something to paper over: the validator requires per-route not to
* exceed the total, so the configured numbers stay coherent, and a dynamic profile's true global
* bound comes from the platform's own admission limiter.
*/
public ConnectionProvider create(ClientProfile profile) {
Objects.requireNonNull(profile, "profile");
return ConnectionProvider.builder(profile.name().value())
.maxConnections(profile.pool().maxConnectionsPerRoute())
.pendingAcquireMaxCount(profile.pool().maxPendingAcquires())
.pendingAcquireTimeout(profile.pool().pendingAcquireTimeout())
.maxIdleTime(profile.pool().maxIdleTime())
.maxLifeTime(profile.pool().maxLifeTime())
.evictInBackground(profile.pool().evictionInterval())
.metrics(true)
.build();
}
}
@@ -0,0 +1,137 @@
package dev.caskeleton.adapter.outbound.httpclient.reactor;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.net.UnknownHostException;
import java.security.cert.CertificateException;
import java.util.concurrent.TimeoutException;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLHandshakeException;
import javax.net.ssl.SSLPeerUnverifiedException;
/**
* Maps Reactor Netty failures onto the same stable evidence the blocking transports produce (design
* §13.3).
*
* <p>Reactor wraps causes, so the chain is unwrapped before classification; a cancellation is
* reported as such rather than as a timeout, because the two have different retry meaning.
*/
public final class ReactorFailureClassifier implements TransportFailureClassifier {
@Override
public TransportFailure classify(Throwable failure, AttemptStage lastObservedStage) {
// Reactor wraps causes several layers deep; the whole chain is inspected so a wrapped
// ConnectException is still recognised as provably NOT_SENT.
for (Throwable cause : chain(failure)) {
TransportFailure recognized = recognize(cause, lastObservedStage);
if (recognized != null) {
return recognized;
}
}
return fallback(lastObservedStage);
}
private TransportFailure recognize(Throwable cause, AttemptStage lastObservedStage) {
if (cause instanceof UnknownHostException) {
return TransportFailure.notSent(
AttemptStage.DNS, FailureCategory.DNS, "DNS_RESOLUTION_FAILED");
}
if (cause instanceof ConnectException || cause instanceof NoRouteToHostException) {
return TransportFailure.notSent(
AttemptStage.CONNECT, FailureCategory.CONNECT, "CONNECT_FAILED");
}
if (cause instanceof io.netty.handler.ssl.SslHandshakeTimeoutException) {
// Checked before the SSLHandshakeException branch below, which it extends. Without this a
// handshake that merely ran out of time was classified TLS_PERMANENT — the category that
// forbids retry — so a momentarily slow peer produced a hard failure indistinguishable from
// an untrusted certificate.
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_HANDSHAKE_TIMEOUT");
}
if (cause instanceof SSLPeerUnverifiedException || cause instanceof CertificateException) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_TRUST_FAILED");
}
if (cause instanceof SSLHandshakeException) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_HANDSHAKE_FAILED");
}
if (cause instanceof SSLException
&& !lastObservedStage.isAtLeast(AttemptStage.REQUEST_HEADERS)) {
return TransportFailure.notSent(
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_TRANSIENT_FAILURE");
}
if (isPoolAcquireTimeout(cause)) {
return TransportFailure.notSent(
AttemptStage.POOL_ACQUIRE, FailureCategory.POOL_ACQUIRE_TIMEOUT, "POOL_ACQUIRE_TIMEOUT");
}
if (cause instanceof java.util.concurrent.CancellationException) {
return new TransportFailure(
lastObservedStage,
lastObservedStage.provesNotSent()
? ExecutionEvidence.NOT_SENT
: ExecutionEvidence.SENT_NO_RESPONSE,
FailureCategory.CANCELLED,
"CANCELLED");
}
if (cause instanceof io.netty.handler.timeout.WriteTimeoutException) {
// Netty's timeout hierarchy does not extend java.util.concurrent.TimeoutException, so none of
// these reached the branch below — every read or write timeout fell through to the generic
// fallback and lost its stage and category.
return TransportFailure.sentNoResponse(
AttemptStage.REQUEST_BODY, FailureCategory.REQUEST_WRITE, "REQUEST_WRITE_TIMEOUT");
}
if (cause instanceof TimeoutException
|| cause instanceof io.netty.handler.timeout.ReadTimeoutException) {
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
return new TransportFailure(
lastObservedStage,
ExecutionEvidence.PARTIAL_RESPONSE,
FailureCategory.RESPONSE_TIMEOUT,
"RESPONSE_BODY_TIMEOUT");
}
return TransportFailure.sentNoResponse(
AttemptStage.RESPONSE_HEADERS,
FailureCategory.RESPONSE_TIMEOUT,
"RESPONSE_HEADER_TIMEOUT");
}
return null;
}
private TransportFailure fallback(AttemptStage lastObservedStage) {
if (lastObservedStage.provesNotSent()) {
return TransportFailure.notSent(
lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
}
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
return new TransportFailure(
lastObservedStage,
ExecutionEvidence.PARTIAL_RESPONSE,
FailureCategory.RESPONSE_TRUNCATED,
"TRANSPORT_FAILURE");
}
return TransportFailure.sentNoResponse(
lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
}
private boolean isPoolAcquireTimeout(Throwable cause) {
String message = cause.getMessage();
return cause.getClass().getName().contains("PoolAcquireTimeoutException")
|| (message != null && message.contains("Pool#acquire(Duration)"));
}
private java.util.List<Throwable> chain(Throwable failure) {
java.util.List<Throwable> chain = new java.util.ArrayList<>();
Throwable current = failure;
while (current != null && !chain.contains(current)) {
chain.add(current);
current = current.getCause();
}
return chain;
}
}
@@ -0,0 +1,137 @@
package dev.caskeleton.adapter.outbound.httpclient.reactor;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
import io.netty.channel.ChannelOption;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.resolver.AddressResolverGroup;
import java.net.InetAddress;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import reactor.netty.http.Http11SslContextSpec;
import reactor.netty.http.Http2SslContextSpec;
import reactor.netty.http.HttpProtocol;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;
import reactor.netty.tcp.SslProvider;
import reactor.netty.transport.ProxyProvider;
/**
* Builds the Reactor Netty client for one profile (design §13.6).
*
* <p>Redirect following is disabled here for the same reason as in the blocking transports: the
* platform re-validates each hop and strips credentials across origins, and the engine's follower
* does neither.
*/
public final class ReactorHttpClientFactory {
public HttpClient create(
ClientProfile profile,
ConnectionProvider connectionProvider,
Optional<SslContextMaterial> tlsMaterial,
Optional<Function<String, List<InetAddress>>> approvedAddresses) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(connectionProvider, "connection provider");
HttpClient client =
HttpClient.create(connectionProvider)
.option(
ChannelOption.CONNECT_TIMEOUT_MILLIS,
Math.toIntExact(profile.timeout().connect().toMillis()))
.responseTimeout(profile.timeout().responseHeader())
.followRedirect(false)
.compress(profile.request().compression())
.protocol(protocols(profile))
.metrics(true, Function.identity())
// request-write-idle and read-idle were bound and then reached no transport at all, so
// a request that stalled mid-write hung until the total-call budget expired instead of
// failing at the stage that actually stopped. Netty's idle handlers are where those two
// settings become real; they are installed per connection so a stall is attributed to
// the write or the read rather than to "the call".
.doOnConnected(
connection ->
connection
.addHandlerLast(
new io.netty.handler.timeout.WriteTimeoutHandler(
profile.timeout().requestWriteIdle().toMillis(),
java.util.concurrent.TimeUnit.MILLISECONDS))
.addHandlerLast(
new io.netty.handler.timeout.ReadTimeoutHandler(
profile.timeout().readIdle().toMillis(),
java.util.concurrent.TimeUnit.MILLISECONDS)));
if (approvedAddresses.isPresent()) {
AddressResolverGroup<?> resolver = new ValidatedAddressResolverGroup(approvedAddresses.get());
client = client.resolver(resolver);
}
// Configured whether or not custom material is supplied. It used to be inside this isPresent,
// so a profile declaring `tls.protocols: [TLSv1.3]` against the JVM trust store — the common
// case — configured no TLS parameters at all and accepted whatever the platform default
// allowed, TLS 1.2 included. A declared floor that only applies alongside a custom truststore
// is not a floor.
SslProvider.GenericSslContextSpec<SslContextBuilder> contextSpec =
sslContextSpec(profile, tlsMaterial.orElse(null));
client = client.secure(spec -> spec.sslContext(contextSpec));
if (profile.proxy().enabled()) {
client =
client.proxy(
spec ->
spec.type(
profile.proxy().type()
== dev.caskeleton.adapter.outbound.httpclient.profile.ProxyType
.SOCKS
? ProxyProvider.Proxy.SOCKS5
: ProxyProvider.Proxy.HTTP)
.host(profile.proxy().host())
.port(profile.proxy().port())
.connectTimeoutMillis(profile.proxy().connectTimeout().toMillis()));
}
return client;
}
/**
* The TLS spec, with or without custom material.
*
* <p>{@code material} is nullable on purpose: a profile using the JVM trust store still declares
* a protocol floor, and that floor has to reach the SSL context.
*/
private SslProvider.GenericSslContextSpec<SslContextBuilder> sslContextSpec(
ClientProfile profile, SslContextMaterial material) {
String[] tlsProtocols =
material != null
? material.protocolArray()
: profile.tls().protocols().toArray(String[]::new);
java.util.function.Consumer<SslContextBuilder> configurer =
builder -> {
if (material != null) {
material.trustManagerFactory().ifPresent(builder::trustManager);
material.keyManagerFactory().ifPresent(builder::keyManager);
}
builder.protocols(tlsProtocols);
};
return profile
.protocols()
.contains(dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol.HTTP_2)
? Http2SslContextSpec.forClient().configure(configurer)
: Http11SslContextSpec.forClient().configure(configurer);
}
/**
* Configures exactly the protocols the profile asked for.
*
* <p>{@code {H2, HTTP11}} used to be configured whenever HTTP/2 appeared in the set at all, so a
* profile that declared only HTTP/2 — an operator stating a requirement — negotiated HTTP/1.1
* against any peer that offered it, silently. An H2-required profile now gets H2 alone, and a
* peer that cannot speak it fails the handshake instead of downgrading.
*/
private HttpProtocol[] protocols(ClientProfile profile) {
return switch (dev.caskeleton.adapter.outbound.httpclient.profile.ProtocolIntent.of(
profile.protocols())) {
case H2_REQUIRED -> new HttpProtocol[] {HttpProtocol.H2};
case NEGOTIATE_H2_H1 -> new HttpProtocol[] {HttpProtocol.H2, HttpProtocol.HTTP11};
default -> new HttpProtocol[] {HttpProtocol.HTTP11};
};
}
}
@@ -0,0 +1,94 @@
package dev.caskeleton.adapter.outbound.httpclient.reactor;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportCapabilities;
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportProvider;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey;
import io.micrometer.core.instrument.MeterRegistry;
import java.net.InetAddress;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import reactor.netty.resources.ConnectionProvider;
/**
* Reactive default transport (design D-07, §13.6).
*
* <p>Returns a Spring {@code ClientHttpConnector}; the Reactor Netty {@code HttpClient} stays
* inside this package so application code cannot bypass profile configuration.
*/
public final class ReactorNettyTransportProvider implements ReactiveTransportProvider {
private static final TransportId ID = new TransportId("reactor-netty");
private final ReactorConnectionProviderFactory poolFactory =
new ReactorConnectionProviderFactory();
private final ReactorHttpClientFactory clientFactory = new ReactorHttpClientFactory();
private final ReactorFailureClassifier classifier = new ReactorFailureClassifier();
private final Map<TransportResourceKey, ConnectionProvider> pools = new ConcurrentHashMap<>();
private final Optional<MeterRegistry> meterRegistry;
private final Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver;
private final Function<ClientProfile, Optional<Function<String, List<InetAddress>>>>
resolverFactory;
public ReactorNettyTransportProvider() {
this(Optional.empty(), profile -> Optional.empty(), profile -> Optional.empty());
}
public ReactorNettyTransportProvider(
Optional<MeterRegistry> meterRegistry,
Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver,
Function<ClientProfile, Optional<Function<String, List<InetAddress>>>> resolverFactory) {
this.meterRegistry = Objects.requireNonNull(meterRegistry, "meter registry");
this.tlsMaterialResolver = Objects.requireNonNull(tlsMaterialResolver, "tls material resolver");
this.resolverFactory = Objects.requireNonNull(resolverFactory, "dns resolver factory");
}
@Override
public TransportId id() {
return ID;
}
@Override
public ReactiveTransportCapabilities capabilities() {
return ReactiveTransportCapabilities.reactorNetty();
}
@Override
public ClientHttpConnector create(
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(listener, "lifecycle listener");
ConnectionProvider pool = poolFactory.create(profile);
pools.put(new TransportResourceKey(profile.name(), generation), pool);
meterRegistry.ifPresent(
registry -> ReactorPoolMetricsBinder.bind(registry, profile.name(), pool));
listener.onRuntimeCreated(profile.name(), ID);
return new ReactorClientHttpConnector(
clientFactory.create(
profile, pool, tlsMaterialResolver.apply(profile), resolverFactory.apply(profile)));
}
@Override
public TransportFailureClassifier failureClassifier() {
return classifier;
}
@Override
public void close(ClientProfile profile, RuntimeGeneration generation) {
ConnectionProvider pool = pools.remove(new TransportResourceKey(profile.name(), generation));
if (pool != null) {
pool.disposeLater().block(profile.pool().shutdownTimeout());
}
}
}
@@ -0,0 +1,135 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpBulkheadRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpCircuitOpenException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* Fixed guard order for every physical attempt (design D-11, §18): Circuit Breaker → Rate Limiter →
* Bulkhead → HTTP call, released in reverse.
*
* <p>The order is not cosmetic. An open circuit must reject before a rate token or a bulkhead
* permit is spent, otherwise a dead upstream keeps consuming the quota and concurrency that healthy
* upstreams need.
*
* <p>A local rejection (rate limiter or bulkhead) is deliberately <em>not</em> recorded as a
* circuit error: the upstream never saw the request, and counting our own back-pressure as upstream
* failure would open the breaker on a healthy dependency.
*/
public final class AttemptResiliencePipeline {
private final AttemptCircuitBreaker circuitBreaker;
private final AttemptRateLimiter rateLimiter;
private final BlockingAttemptBulkhead bulkhead;
private final Supplier<HttpFailureMetadata> metadataSupplier;
private final ResilienceRejectionRecorder rejections;
/** Keeps the existing four-argument shape for callers that do not record metrics. */
public AttemptResiliencePipeline(
AttemptCircuitBreaker circuitBreaker,
AttemptRateLimiter rateLimiter,
BlockingAttemptBulkhead bulkhead,
Supplier<HttpFailureMetadata> metadataSupplier) {
this(
circuitBreaker,
rateLimiter,
bulkhead,
metadataSupplier,
ResilienceRejectionRecorder.noop());
}
public AttemptResiliencePipeline(
AttemptCircuitBreaker circuitBreaker,
AttemptRateLimiter rateLimiter,
BlockingAttemptBulkhead bulkhead,
Supplier<HttpFailureMetadata> metadataSupplier,
ResilienceRejectionRecorder rejections) {
this.circuitBreaker = Objects.requireNonNull(circuitBreaker, "circuit breaker");
this.rateLimiter = Objects.requireNonNull(rateLimiter, "rate limiter");
this.bulkhead = Objects.requireNonNull(bulkhead, "bulkhead");
this.metadataSupplier = Objects.requireNonNull(metadataSupplier, "failure metadata supplier");
this.rejections = Objects.requireNonNull(rejections, "rejection recorder");
}
public String circuitState() {
return circuitBreaker.state();
}
public <T> T execute(AttemptCall<T> call) {
return execute(call, result -> Optional.empty());
}
/**
* Runs one attempt and records the breaker outcome from the <em>remote</em> result.
*
* <p>The classifier exists because a returned value is not necessarily a success. The blocking
* executor used to run only the raw send inside this pipeline and map the response to a stable
* exception afterwards, outside it — so a 503 completed the call normally, the breaker recorded a
* success, and an upstream that answered nothing but 503 never opened its circuit. The thing the
* breaker is for was the one thing it could not see.
*
* @param call the attempt, including any redirect hops it follows
* @param remoteFailure returns the failure to record when the value represents an upstream error
* @return the attempt's value, whether or not it represents a remote failure
*/
public <T> T execute(AttemptCall<T> call, Function<T, Optional<Throwable>> remoteFailure) {
Objects.requireNonNull(remoteFailure, "remote failure classifier");
if (!circuitBreaker.tryAcquirePermission()) {
rejections.circuitOpen();
throw new HttpCircuitOpenException(
"upstream circuit breaker is open", metadataSupplier.get());
}
if (!rateLimiter.tryAcquirePermission()) {
rejections.rateLimited();
throw new HttpRateLimitRejectedException(
"local attempt rate limit reached", metadataSupplier.get());
}
if (!bulkhead.tryAcquire()) {
rateLimiter.onCompleted();
rejections.bulkheadRejected();
throw new HttpBulkheadRejectedException(
"attempt bulkhead has no permit available", metadataSupplier.get());
}
long started = System.nanoTime();
try {
T result = call.call();
releaseAttemptPermits();
Optional<Throwable> upstreamFailure = remoteFailure.apply(result);
if (upstreamFailure.isPresent()) {
circuitBreaker.onError(System.nanoTime() - started, upstreamFailure.get());
} else {
circuitBreaker.onSuccess(System.nanoTime() - started);
}
return result;
} catch (Throwable failure) {
releaseAttemptPermits();
circuitBreaker.onError(System.nanoTime() - started, failure);
throw translate(failure);
}
}
private void releaseAttemptPermits() {
bulkhead.release();
rateLimiter.onCompleted();
}
private RuntimeException translate(Throwable failure) {
if (failure instanceof HttpClientException stable) {
return stable;
}
if (failure instanceof RuntimeException runtime) {
return runtime;
}
if (failure instanceof Error error) {
throw error;
}
return new IllegalStateException("outbound http attempt failed", failure);
}
}
@@ -0,0 +1,133 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import java.time.Duration;
import java.util.Optional;
/**
* The complete ordered retry decision table (design §17.3).
*
* <p>The order is the point. Cheap absolute blockers come first (attempts, budget, replayability,
* first byte, deadline, draining), then ambiguity, then status- and failure-specific rules. A later
* rule can never re-enable something an earlier rule forbade.
*/
public final class DefaultRetryEligibilityEngine implements RetryEligibilityEngine {
@Override
public RetryDecision decide(RetryContext context) {
if (context.attempt() >= context.maxAttempts()) {
return RetryDenied.maxAttempts();
}
if (!context.budget().available()) {
return RetryDenied.budgetExhausted();
}
if (!context.replayability().canReplay()) {
return RetryDenied.bodyNotReplayable();
}
if (context.firstByteDelivered()) {
return RetryDenied.responseAlreadyDelivered();
}
if (context.runtimeDraining()) {
return RetryDenied.runtimeDraining();
}
if (context.remainingDeadline().compareTo(context.minimumAttemptBudget()) <= 0) {
return RetryDenied.deadline();
}
if (context.failureCategory().permanent()) {
return RetryDenied.permanentFailure(context.failureCategory().name());
}
if (context.evidence() == ExecutionEvidence.PARTIAL_RESPONSE) {
// A partial response that never reached the caller may still be retried for a safe
// operation; once a byte was delivered the earlier guard has already denied it.
return context.safelyIdempotent()
? RetryAllowed.of("PARTIAL_RESPONSE")
: AmbiguousFailure.remoteOutcomeUnknown();
}
if (context.evidence() == ExecutionEvidence.SENT_NO_RESPONSE && !context.safelyIdempotent()) {
return AmbiguousFailure.remoteOutcomeUnknown();
}
return statusOrFailureDecision(context);
}
private RetryDecision statusOrFailureDecision(RetryContext context) {
Optional<HttpStatus> status = context.responseStatus();
if (status.isPresent()) {
return statusDecision(context, status.get().value());
}
return failureDecision(context);
}
private RetryDecision statusDecision(RetryContext context, int status) {
return switch (status) {
// 408, 425 and 429 all mean the request reached the upstream and was answered, so repeating
// one is only safe under the same rule as every other repeat. These three used to skip that
// check: a non-idempotent POST answered 429 was retried, and a rate-limited upstream that had
// already accepted the work got it a second time. A 429 is a scheduling signal, never a
// statement that nothing happened.
case 408 ->
context.safelyIdempotent()
? allowWithin(context, "REQUEST_TIMEOUT")
: AmbiguousFailure.remoteOutcomeUnknown();
// 425 Too Early: repeating once without early data is safe; repeating repeatedly is not.
case 425 -> {
if (!context.safelyIdempotent()) {
yield AmbiguousFailure.remoteOutcomeUnknown();
}
yield context.attempt() == 1
? allowWithin(context, "TOO_EARLY")
: RetryDenied.maxAttempts();
}
case 429 ->
context.safelyIdempotent()
? allowWithin(context, "RATE_LIMITED")
: RetryDenied.notRetryableStatus(status);
case 401 ->
context.credentialRefreshAvailable()
&& context.attempt() == 1
&& context.safelyIdempotent()
? RetryAllowed.of("UNAUTHORIZED_REFRESH")
: RetryDenied.notRetryableStatus(status);
case 500 ->
context.transientServerErrorStatuses().contains(500) && context.safelyIdempotent()
? allowWithin(context, "UPSTREAM_TRANSIENT")
: RetryDenied.notRetryableStatus(status);
case 502, 503, 504 ->
context.safelyIdempotent()
? allowWithin(context, "UPSTREAM_UNAVAILABLE")
: AmbiguousFailure.remoteOutcomeUnknown();
default -> RetryDenied.notRetryableStatus(status);
};
}
private RetryDecision failureDecision(RetryContext context) {
return switch (context.failureCategory()) {
case POOL_ACQUIRE_TIMEOUT -> RetryAllowed.of("POOL_ACQUIRE_TIMEOUT");
case DNS -> RetryAllowed.of("DNS");
case CONNECT -> RetryAllowed.of("CONNECT");
case PROXY -> RetryAllowed.of("PROXY");
case TLS_TRANSIENT -> RetryAllowed.of("TLS_TRANSIENT");
case REQUEST_WRITE, RESPONSE_TIMEOUT, RESPONSE_TRUNCATED ->
context.safelyIdempotent()
? RetryAllowed.of(context.failureCategory().name())
: AmbiguousFailure.remoteOutcomeUnknown();
case NONE -> RetryDenied.success();
case CIRCUIT_OPEN, RATE_LIMIT_REJECTED, BULKHEAD_REJECTED ->
RetryDenied.permanentFailure(context.failureCategory().name());
default -> RetryDenied.permanentFailure(context.failureCategory().name());
};
}
/** Honors {@code Retry-After} only when the wait still fits inside the remaining deadline. */
private RetryDecision allowWithin(RetryContext context, String reason) {
Optional<Duration> retryAfter = context.retryAfter();
if (retryAfter.isEmpty()) {
return RetryAllowed.of(reason);
}
Duration required = retryAfter.get().plus(context.minimumAttemptBudget());
if (required.compareTo(context.remainingDeadline()) > 0) {
return RetryDenied.deadline();
}
return RetryAllowed.after(reason, retryAfter.get());
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import dev.caskeleton.adapter.outbound.httpclient.profile.JitterStrategy;
import dev.caskeleton.adapter.outbound.httpclient.profile.RetryAfterPolicy;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
import java.util.random.RandomGenerator;
/**
* Exponential backoff with jitter, bounded by max backoff, {@code Retry-After}, and the remaining
* deadline (design §17.5).
*
* <p>Jitter is not decoration: without it, a fleet that failed together retries together, and the
* upstream recovery window never opens. The random source is injectable so the schedule is
* testable.
*/
public final class ExponentialFullJitterBackoff implements BackoffStrategy {
private final Duration baseBackoff;
private final Duration maxBackoff;
private final JitterStrategy jitter;
private final RetryAfterPolicy retryAfterPolicy;
private final RandomGenerator random;
private Duration previousDelay;
public ExponentialFullJitterBackoff(
Duration baseBackoff,
Duration maxBackoff,
JitterStrategy jitter,
RetryAfterPolicy retryAfterPolicy,
RandomGenerator random) {
this.baseBackoff = Objects.requireNonNull(baseBackoff, "base backoff");
this.maxBackoff = Objects.requireNonNull(maxBackoff, "max backoff");
this.jitter = Objects.requireNonNull(jitter, "jitter strategy");
this.retryAfterPolicy = Objects.requireNonNull(retryAfterPolicy, "retry-after policy");
this.random = Objects.requireNonNull(random, "random generator");
this.previousDelay = baseBackoff;
}
/**
* The wait before the next attempt.
*
* <p>An honoured {@code Retry-After} is <em>not</em> clamped to {@code maxBackoff}. It used to
* be, which made {@link RetryAfterPolicy#HONOR} and {@link RetryAfterPolicy#CAP} the same policy:
* a profile that chose to honour a rate limiter's instruction still retried after its own 200ms
* ceiling, hammering an upstream that had asked for thirty seconds. Two settings that cannot
* differ are one setting and a false promise.
*
* <p>The remaining deadline still bounds everything, because waiting past the point where the
* next attempt could finish is not a retry — it is a slower failure. The retry engine separately
* refuses to honour a {@code Retry-After} that does not fit, so the two agree.
*/
@Override
public Duration delay(
int completedAttempts, Optional<Duration> retryAfter, Duration remainingDeadline) {
Optional<Duration> honoured = honoredRetryAfter(retryAfter);
Duration candidate = honoured.orElseGet(() -> min(computed(completedAttempts), maxBackoff));
previousDelay = candidate.isZero() ? baseBackoff : min(candidate, maxBackoff);
// Never wait past the point where the following attempt could still finish.
return min(candidate, remainingDeadline);
}
private Optional<Duration> honoredRetryAfter(Optional<Duration> retryAfter) {
return switch (retryAfterPolicy) {
case IGNORE -> Optional.empty();
case HONOR -> retryAfter;
case CAP -> retryAfter.map(value -> min(value, maxBackoff));
};
}
private Duration computed(int completedAttempts) {
long exponent = Math.max(0, completedAttempts - 1);
long scaled = baseBackoff.toMillis() << Math.min(exponent, 20);
long capped = Math.min(scaled, maxBackoff.toMillis());
return switch (jitter) {
case NONE -> Duration.ofMillis(capped);
case FULL -> Duration.ofMillis(capped <= 0 ? 0 : random.nextLong(capped + 1));
case DECORRELATED -> {
long lower = baseBackoff.toMillis();
long upper = Math.min(maxBackoff.toMillis(), Math.max(lower, previousDelay.toMillis() * 3));
yield Duration.ofMillis(upper <= lower ? lower : random.nextLong(lower, upper + 1));
}
};
}
private static Duration min(Duration left, Duration right) {
if (left.isNegative()) {
return Duration.ZERO;
}
return left.compareTo(right) <= 0 ? left : right;
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.observation.HttpClientObservationNames;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tag;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Publishes the local back-pressure signals the platform already produced but never recorded.
*
* <p>{@code http.client.rate_limit.rejected}, {@code http.client.bulkhead.rejected} and {@code
* http.client.circuit.state} were declared in the metric vocabulary, documented in the support
* matrix, and emitted by nothing. That is the worst arrangement of the three possibilities: an
* operator building a dashboard finds the names, charts them, and sees a flat zero during the exact
* incident the metrics exist to explain — a saturated bulkhead and an open breaker look identical
* to a healthy system.
*
* <p>The circuit state is a gauge rather than a counter because "how long was it open" is the
* question an incident actually asks; the rejections are counters because each one is a request
* that did not happen.
*/
public interface ResilienceRejectionRecorder {
void circuitOpen();
void rateLimited();
void bulkheadRejected();
/** Used where metrics are not wired, such as hand-constructed test pipelines. */
static ResilienceRejectionRecorder noop() {
return new ResilienceRejectionRecorder() {
@Override
public void circuitOpen() {
// no-op
}
@Override
public void rateLimited() {
// no-op
}
@Override
public void bulkheadRejected() {
// no-op
}
};
}
/**
* Binds the counters and the circuit-state gauge for one profile.
*
* @param registry the meter registry
* @param clientName the profile the meters are tagged with
* @param circuitState supplies the breaker's current state name for the gauge
* @return a recorder that publishes to {@code registry}
*/
static ResilienceRejectionRecorder micrometer(
MeterRegistry registry, ClientProfileName clientName, Supplier<String> circuitState) {
Objects.requireNonNull(registry, "meter registry");
Objects.requireNonNull(clientName, "client name");
Objects.requireNonNull(circuitState, "circuit state supplier");
List<Tag> tags = List.of(Tag.of("clientName", clientName.value()));
// 1 while the breaker is refusing traffic, 0 otherwise. A state *name* cannot be a gauge value,
// and putting it in a tag would make the series change identity every time the breaker moved.
registry.gauge(
HttpClientObservationNames.CIRCUIT_STATE,
tags,
circuitState,
supplier -> "OPEN".equalsIgnoreCase(supplier.get()) ? 1.0 : 0.0);
return new ResilienceRejectionRecorder() {
@Override
public void circuitOpen() {
registry.counter(HttpClientObservationNames.CIRCUIT_STATE + ".rejected", tags).increment();
}
@Override
public void rateLimited() {
registry.counter(HttpClientObservationNames.RATE_LIMIT_REJECTED, tags).increment();
}
@Override
public void bulkheadRejected() {
registry.counter(HttpClientObservationNames.BULKHEAD_REJECTED, tags).increment();
}
};
}
}
@@ -0,0 +1,77 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus;
import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.OperationIdempotency;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/**
* Everything the retry decision is allowed to depend on (design §17.1).
*
* <p>The HTTP method is deliberately absent: design D-09 makes idempotency an explicit operation
* property, so a POST with a registered idempotency key and a GET against a non-idempotent RPC
* endpoint are both handled correctly instead of by method-name folklore.
*/
public record RetryContext(
OperationIdempotency idempotency,
Optional<IdempotencyKey> idempotencyKey,
boolean idempotencyKeySent,
BodyReplayability replayability,
ExecutionEvidence evidence,
FailureCategory failureCategory,
Optional<HttpStatus> responseStatus,
Optional<Duration> retryAfter,
int attempt,
int maxAttempts,
boolean firstByteDelivered,
Duration remainingDeadline,
Duration minimumAttemptBudget,
RetryBudgetSnapshot budget,
Set<Integer> transientServerErrorStatuses,
boolean credentialRefreshAvailable,
boolean runtimeDraining) {
public RetryContext {
Objects.requireNonNull(idempotency, "idempotency");
Objects.requireNonNull(idempotencyKey, "idempotency key");
Objects.requireNonNull(replayability, "replayability");
Objects.requireNonNull(evidence, "evidence");
Objects.requireNonNull(failureCategory, "failure category");
Objects.requireNonNull(responseStatus, "response status");
Objects.requireNonNull(retryAfter, "retry-after");
Objects.requireNonNull(remainingDeadline, "remaining deadline");
Objects.requireNonNull(minimumAttemptBudget, "minimum attempt budget");
Objects.requireNonNull(budget, "retry budget snapshot");
Objects.requireNonNull(transientServerErrorStatuses, "transient server error statuses");
if (attempt < 1) {
throw new IllegalArgumentException("attempt must be at least 1");
}
if (maxAttempts < 1) {
throw new IllegalArgumentException("max attempts must be at least 1");
}
transientServerErrorStatuses = Set.copyOf(transientServerErrorStatuses);
}
/**
* True when repeating a request that may already have been processed is contractually safe.
*
* <p>For a key-bearing operation this requires that the key was actually written to the request,
* not merely that the caller supplied one. The two used to be conflated: the platform read {@code
* idempotencyKey.isPresent()}, concluded the upstream could deduplicate, and retried — while the
* header was never sent, so the upstream had nothing to deduplicate against and processed the
* request twice. Possession of a key is the caller's intent; transmission is the upstream's
* ability to honour it, and only the second one makes a repeat safe.
*/
public boolean safelyIdempotent() {
return idempotency.safeToRepeatWithoutKey()
|| (idempotency == OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED
&& idempotencyKey.isPresent()
&& idempotencyKeySent);
}
}
@@ -0,0 +1,269 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptProgressTracker;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ProtocolEvidence;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedTarget;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Executes exactly one physical attempt, including its redirect hops (design §7.1 steps 9-12).
*
* <p>Progress is tracked as the attempt advances so the evidence classifier has real observations
* to work from rather than an exception type. Failures become {@link AttemptOutcome} values instead
* of escaping, because the coordinator — not the transport — decides whether a failure is final.
*/
public final class BlockingAttemptExecutor {
public <T> AttemptOutcome<T> execute(
BlockingClientRuntime runtime,
PreparedOperation prepared,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy,
RequestCredentials credentials,
int attemptNumber,
Instant startedAt,
HttpFailureMetadata baseMetadata) {
Objects.requireNonNull(runtime, "runtime");
BlockingExecutionSupport support = runtime.support();
AttemptProgressTracker tracker = new AttemptProgressTracker();
ResponseSizeLimiter limiter =
new ResponseSizeLimiter(
prepared.maxResponseWireBytes(), prepared.maxResponseDecodedBytes(), baseMetadata);
try {
// Send, follow redirects, and map the response all inside one set of resilience permits.
// Two things depended on that: the breaker now sees the mapped remote outcome rather than
// "the socket returned bytes", and a redirect hop no longer re-enters the pipeline while the
// permits for its own attempt are still held — which with a single-permit bulkhead was a
// guaranteed self-rejection, and with any configuration double-counted the rate limiter.
return runtime
.resiliencePipeline()
.execute(
() -> {
RestClientResponseReader.RawResponse response =
sendWithRedirects(
runtime, prepared, credentials, tracker, limiter, baseMetadata);
Duration elapsed = Duration.between(startedAt, support.clock().instant());
try {
return AttemptOutcome.succeeded(
support
.responseMapper()
.map(
response,
responseType,
runtime.profile().response(),
statusHandlingPolicy,
attemptNumber,
elapsed,
baseMetadata));
} catch (HttpRemoteErrorException remoteError) {
// Retry-After is read from the response the mapper already bounded, so the
// decision engine can honour it without the transport interpreting it.
return AttemptOutcome.<T>failed(
remoteError,
FailureCategory.REMOTE_STATUS,
retryAfter(response),
tracker.firstByteDelivered());
} catch (HttpClientException stable) {
return AttemptOutcome.<T>failed(
stable, categoryOf(stable), Optional.empty(), tracker.firstByteDelivered());
}
},
outcome ->
outcome.failureCategory() == FailureCategory.REMOTE_STATUS
? outcome.failure().map(failure -> (Throwable) failure)
: Optional.empty());
} catch (HttpClientException stable) {
return AttemptOutcome.failed(
stable, categoryOf(stable), Optional.empty(), tracker.firstByteDelivered());
} catch (RuntimeException engineFailure) {
TransportFailure classified =
runtime.failureClassifier().classify(engineFailure, tracker.stage());
HttpFailureMetadata metadata =
baseMetadata
.withEvidence(
support
.evidenceClassifier()
.classify(tracker.snapshot(), ProtocolEvidence.none()))
.withStage(classified.stage());
HttpClientException mapped =
support.exceptionMapper().map(classified, metadata, engineFailure);
return AttemptOutcome.failed(
mapped, classified.category(), Optional.empty(), tracker.firstByteDelivered());
}
}
private RestClientResponseReader.RawResponse sendWithRedirects(
BlockingClientRuntime runtime,
PreparedOperation prepared,
RequestCredentials credentials,
AttemptProgressTracker tracker,
ResponseSizeLimiter limiter,
HttpFailureMetadata metadata) {
Map<String, List<String>> headers = withCredentials(prepared.headers(), credentials);
PreparedTarget target = prepared.target();
URI initialUri = withCredentialQuery(target.uri(), credentials);
RestClientResponseReader.RawResponse first =
send(
runtime,
initialUri,
prepared.operation().method(),
headers,
prepared.operation().body(),
tracker,
limiter,
metadata);
BlockingRedirectCoordinator coordinator =
new BlockingRedirectCoordinator(
runtime.support().redirectEvaluator(),
runtime.support().headerStripper(),
runtime.targetPolicy()::requireAllowedTarget);
return coordinator.follow(
first,
runtime.redirectPolicy(),
PreparedTarget.of(initialUri, target.uriTemplate()),
prepared.operation().method(),
prepared.operation().body(),
headers,
// No nested pipeline. A hop runs under the permits its own attempt already holds; taking a
// second set would deadlock a single-permit bulkhead against itself and charge the rate
// limiter twice for one logical attempt.
(hopTarget, hopMethod, hopHeaders, hopBody) ->
send(
runtime,
hopTarget,
hopMethod,
hopHeaders,
hopBody,
new AttemptProgressTracker(),
limiter,
metadata),
metadata);
}
private RestClientResponseReader.RawResponse send(
BlockingClientRuntime runtime,
URI uri,
HttpMethod method,
Map<String, List<String>> headers,
dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource body,
AttemptProgressTracker tracker,
ResponseSizeLimiter limiter,
HttpFailureMetadata metadata) {
tracker.enter(AttemptStage.POOL_ACQUIRE);
RestClient.RequestBodySpec spec =
runtime
.restClient()
.method(org.springframework.http.HttpMethod.valueOf(method.name()))
.uri(uri);
headers.forEach((name, values) -> values.forEach(value -> spec.header(name, value)));
tracker.enter(AttemptStage.REQUEST_HEADERS);
RestClient.RequestHeadersSpec<?> request =
runtime.support().bodyWriter().write(spec, body, runtime.bodyLimitPolicy(), metadata);
tracker.enter(AttemptStage.REQUEST_BODY);
tracker.requestWriteStarted();
return request.exchange(
(httpRequest, httpResponse) -> {
tracker.enter(AttemptStage.RESPONSE_HEADERS);
tracker.responseHeadersReceived();
RestClientResponseReader.RawResponse response =
runtime
.support()
.responseReader()
.readBounded(
httpResponse.getStatusCode().value(),
httpResponse.getHeaders(),
httpResponse.getBody(),
limiter);
tracker.enter(AttemptStage.COMPLETE);
return response;
},
true);
}
private Map<String, List<String>> withCredentials(
Map<String, List<String>> headers, RequestCredentials credentials) {
if (credentials.empty()) {
return headers;
}
Map<String, List<String>> merged = new LinkedHashMap<>(headers);
credentials.headers().forEach((name, value) -> merged.put(name, List.of(value)));
return Map.copyOf(merged);
}
private URI withCredentialQuery(URI uri, RequestCredentials credentials) {
if (credentials.queryParameters().isEmpty()) {
return uri;
}
UriComponentsBuilder builder = UriComponentsBuilder.fromUri(uri);
credentials.queryParameters().forEach(builder::queryParam);
return builder.build(true).toUri();
}
/** Reads {@code Retry-After} from a response the mapper already bounded. */
public static Optional<Duration> retryAfter(RestClientResponseReader.RawResponse response) {
Optional<String> header = response.firstHeader(HttpHeaders.RETRY_AFTER);
if (header.isEmpty()) {
return Optional.empty();
}
try {
return Optional.of(Duration.ofSeconds(Long.parseLong(header.get().trim())));
} catch (NumberFormatException httpDate) {
// An HTTP-date Retry-After is valid but its value depends on clock agreement we do not have;
// falling back to the platform backoff is safer than trusting a skewed absolute time.
return Optional.empty();
}
}
private FailureCategory categoryOf(HttpClientException failure) {
return switch (failure.getClass().getSimpleName()) {
case "HttpDnsException" -> FailureCategory.DNS;
case "HttpPoolAcquireTimeoutException" -> FailureCategory.POOL_ACQUIRE_TIMEOUT;
case "HttpConnectException" -> FailureCategory.CONNECT;
case "HttpProxyException" -> FailureCategory.PROXY;
case "HttpTlsException" -> FailureCategory.TLS_PERMANENT;
case "HttpRequestWriteException" -> FailureCategory.REQUEST_WRITE;
case "HttpResponseTimeoutException" -> FailureCategory.RESPONSE_TIMEOUT;
case "HttpResponseTruncatedException" -> FailureCategory.RESPONSE_TRUNCATED;
case "HttpResponseTooLargeException" -> FailureCategory.RESPONSE_TOO_LARGE;
case "HttpSerializationException" -> FailureCategory.SERIALIZATION;
case "HttpTargetRejectedException" -> FailureCategory.TARGET_REJECTED;
case "HttpRedirectRejectedException" -> FailureCategory.REDIRECT_REJECTED;
case "HttpAuthenticationException" -> FailureCategory.AUTHENTICATION;
case "HttpCircuitOpenException" -> FailureCategory.CIRCUIT_OPEN;
case "HttpRateLimitRejectedException" -> FailureCategory.RATE_LIMIT_REJECTED;
case "HttpBulkheadRejectedException" -> FailureCategory.BULKHEAD_REJECTED;
case "HttpDeadlineExceededException" -> FailureCategory.DEADLINE_EXCEEDED;
case "HttpConfigurationException" -> FailureCategory.CONFIGURATION;
default -> FailureCategory.UNKNOWN;
};
}
}
@@ -0,0 +1,132 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentialProvider;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptResiliencePipeline;
import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy;
import dev.caskeleton.adapter.outbound.httpclient.resilience.LogicalAdmissionLimiter;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget;
import dev.caskeleton.adapter.outbound.httpclient.security.BodyLimitPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.TrustedTargetPolicy;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
import java.util.Objects;
import java.util.function.Supplier;
import org.springframework.web.client.RestClient;
/**
* One immutable blocking generation: a RestClient plus everything needed to execute against it
* (design §7.2, §26.1).
*
* <p>The {@code RestClient} is created once and never mutated. Callers cannot obtain its builder,
* which is what stops a caller from quietly removing an interceptor or changing a timeout.
*/
public final class BlockingClientRuntime extends ClientRuntime {
private final RestClient restClient;
private final TransportId transportId;
private final TransportFailureClassifier failureClassifier;
private final TrustedTargetPolicy targetPolicy;
private final BodyLimitPolicy bodyLimitPolicy;
private final RedirectPolicy redirectPolicy;
private final AttemptResiliencePipeline resiliencePipeline;
private final LogicalAdmissionLimiter admissionLimiter;
private final RetryBudget retryBudget;
private final Supplier<BackoffStrategy> backoffFactory;
private final RequestCredentialProvider credentialProvider;
private final BlockingExecutionSupport support;
public BlockingClientRuntime(
ClientProfile profile,
RuntimeGeneration generation,
Runnable resourceCloser,
RestClient restClient,
TransportId transportId,
TransportFailureClassifier failureClassifier,
AttemptResiliencePipeline resiliencePipeline,
LogicalAdmissionLimiter admissionLimiter,
RetryBudget retryBudget,
Supplier<BackoffStrategy> backoffFactory,
RequestCredentialProvider credentialProvider,
BlockingExecutionSupport support) {
super(profile, generation, resourceCloser);
this.restClient = Objects.requireNonNull(restClient, "rest client");
this.transportId = Objects.requireNonNull(transportId, "transport id");
this.failureClassifier = Objects.requireNonNull(failureClassifier, "failure classifier");
this.resiliencePipeline = Objects.requireNonNull(resiliencePipeline, "resilience pipeline");
this.admissionLimiter = Objects.requireNonNull(admissionLimiter, "admission limiter");
this.retryBudget = Objects.requireNonNull(retryBudget, "retry budget");
this.backoffFactory = Objects.requireNonNull(backoffFactory, "backoff factory");
this.credentialProvider = Objects.requireNonNull(credentialProvider, "credential provider");
this.support = Objects.requireNonNull(support, "execution support");
this.targetPolicy = new TrustedTargetPolicy(profile);
this.bodyLimitPolicy = BodyLimitPolicy.maxRequestBytes(profile.request().maxBodyBytes());
this.redirectPolicy =
profile.mode() == dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode.DYNAMIC
? RedirectPolicy.managedByCaller()
: RedirectPolicy.from(profile.redirect());
}
/**
* The engine client, visible only inside this package.
*
* <p>It used to be public, which meant any caller holding a runtime could execute a request that
* skipped target policy, credentials, admission, deadline, resilience, byte limits, stable error
* mapping and observation — every guarantee the profile exists to provide. The typed registries
* did exactly that. Package-private is what makes the platform's guarantees structural rather
* than a convention, and {@code PublicApiArchitectureTest} keeps Spring's client types confined
* here.
*
* @return the profile's immutable {@code RestClient}
*/
RestClient restClient() {
return restClient;
}
public TransportId transportId() {
return transportId;
}
public TransportFailureClassifier failureClassifier() {
return failureClassifier;
}
public TrustedTargetPolicy targetPolicy() {
return targetPolicy;
}
public BodyLimitPolicy bodyLimitPolicy() {
return bodyLimitPolicy;
}
public RedirectPolicy redirectPolicy() {
return redirectPolicy;
}
public AttemptResiliencePipeline resiliencePipeline() {
return resiliencePipeline;
}
public LogicalAdmissionLimiter admissionLimiter() {
return admissionLimiter;
}
public RetryBudget retryBudget() {
return retryBudget;
}
public BackoffStrategy newBackoff() {
return backoffFactory.get();
}
public RequestCredentialProvider credentialProvider() {
return credentialProvider;
}
public BlockingExecutionSupport support() {
return support;
}
}
@@ -0,0 +1,114 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRedirectRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedTarget;
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectContext;
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectDecision;
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectEvaluator;
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.SensitiveHeaderStripper;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Follows redirects explicitly, one evaluated hop at a time (design §12.4).
*
* <p>Engine-level redirect following is disabled in every transport, so this is the only place a
* hop can happen. Each hop re-applies target policy and, when the origin changes, drops the
* credentials — which is exactly what an engine's built-in follower does not do.
*
* <p>A hop is a physical request: it passes through the resilience pipeline via the supplied
* sender, so it consumes rate and bulkhead capacity. It is not a retry, because nothing failed.
*/
public final class BlockingRedirectCoordinator {
/** Issues one physical request; supplied by the executor so hops share the attempt pipeline. */
@FunctionalInterface
public interface HopSender {
RestClientResponseReader.RawResponse send(
URI target, HttpMethod method, Map<String, List<String>> headers, BodySource body);
}
/** Re-applies the profile's own origin allowlist to a hop target. */
@FunctionalInterface
public interface TargetGuard {
void requireAllowed(URI target, HttpFailureMetadata metadata);
}
private final RedirectEvaluator evaluator;
private final SensitiveHeaderStripper headerStripper;
private final TargetGuard targetGuard;
public BlockingRedirectCoordinator(
RedirectEvaluator evaluator,
SensitiveHeaderStripper headerStripper,
TargetGuard targetGuard) {
this.evaluator = Objects.requireNonNull(evaluator, "redirect evaluator");
this.headerStripper = Objects.requireNonNull(headerStripper, "sensitive header stripper");
this.targetGuard = Objects.requireNonNull(targetGuard, "target guard");
}
public RestClientResponseReader.RawResponse follow(
RestClientResponseReader.RawResponse initial,
RedirectPolicy policy,
PreparedTarget initialTarget,
HttpMethod method,
BodySource body,
Map<String, List<String>> headers,
HopSender sender,
HttpFailureMetadata metadata) {
if (policy.callerManaged()) {
// A Dynamic Target owns its own hop validation; following here would skip it.
return initial;
}
RestClientResponseReader.RawResponse response = initial;
PreparedTarget currentTarget = initialTarget;
Map<String, List<String>> currentHeaders = headers;
HttpMethod currentMethod = method;
BodySource currentBody = body;
for (int hop = 0; isRedirect(response.status()); hop++) {
Optional<String> location = response.firstHeader("Location");
if (location.isEmpty()) {
return response;
}
URI target = currentTarget.uri().resolve(location.get());
RedirectContext context =
RedirectContext.of(policy, hop, response.status(), currentBody, currentTarget, target);
RedirectDecision decision = evaluator.evaluate(context);
if (decision instanceof RedirectDecision.Reject reject) {
throw new HttpRedirectRejectedException("redirect rejected: " + reject.code(), metadata);
}
RedirectDecision.Follow follow = (RedirectDecision.Follow) decision;
// The redirect policy decides whether a hop is permissible in shape; the profile decides
// whether its destination is permissible at all. Only the first check existed, so an upstream
// could redirect a trusted profile to an origin its allowlist excluded.
targetGuard.requireAllowed(follow.target(), metadata);
if (follow.crossOrigin()) {
currentHeaders = headerStripper.stripForCrossOrigin(currentHeaders);
}
// 303 explicitly converts to GET, which means dropping the body as well as changing the
// method. Changing only the method sent the original payload as a GET body to a destination
// the upstream chose. 301/302 keep the method because the platform refuses to guess a rewrite
// the caller did not ask for.
if (response.status() == 303) {
currentMethod = HttpMethod.GET;
currentBody = EmptyBody.instance();
}
currentTarget = PreparedTarget.of(follow.target(), currentTarget.uriTemplate());
response = sender.send(follow.target(), currentMethod, currentHeaders, currentBody);
}
return response;
}
private boolean isRedirect(int status) {
return status == 301 || status == 302 || status == 303 || status == 307 || status == 308;
}
}
@@ -0,0 +1,169 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.BlockingStreamingResponse;
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.resilience.DeadlineGuard;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.springframework.web.client.RestClient;
/**
* Streaming download for the blocking stack (design §23.2, §23.3).
*
* <p>The status is validated before any body byte is handed over, and retries are already
* impossible once the caller reads: the platform hands out an {@link BlockingStreamingResponse}
* rather than a raw stream so the connection is released on every exit path.
*/
public final class BlockingStreamingGateway {
private final ClientRuntimeRegistry runtimes;
public BlockingStreamingGateway(ClientRuntimeRegistry runtimes) {
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
}
public BlockingStreamingResponse download(
ClientProfileName profileName, HttpOperation operation) {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(operation, "operation");
ClientRuntimeLease lease = runtimes.acquire(profileName);
boolean handedOff = false;
try {
if (!(lease.runtime() instanceof BlockingClientRuntime runtime)) {
throw new IllegalStateException(
"profile " + profileName.value() + " is not configured for the blocking api");
}
HttpFailureMetadata metadata =
HttpFailureMetadata.validation(
profileName,
operation.operationName(),
operation.method(),
operation.uriTemplate(),
operation.body().replayability());
PreparedOperation prepared = runtime.targetPolicy().prepare(operation);
ResponseSizeLimiter limiter =
new ResponseSizeLimiter(
prepared.maxResponseWireBytes(), prepared.maxResponseDecodedBytes(), metadata);
// A streaming download is a call, not an exemption. This path used to apply target policy and
// the wire-byte limit and nothing else: no credential was resolved, no admission permit
// taken,
// no deadline checked, no breaker or rate limiter entered. A profile whose upstream was down
// could therefore be hammered indefinitely through its streaming surface while its
// non-streaming surface sat behind an open circuit.
new DeadlineGuard(runtime.support().clock())
.requireTimeRemaining(
runtime
.support()
.deadlineCalculator()
.effective(
operation.deadline(),
runtime.profile().timeout().totalCall(),
runtime.support().clock()),
metadata);
RequestCredentials credentials =
runtime
.credentialProvider()
.resolve(
new CredentialRequest(
runtime.name(),
operation.operationName(),
runtime.profile().authentication(),
prepared.target().uri(),
java.util.Optional.empty(),
runtime.profile().tls().keyMaterialReference(),
false));
Map<String, List<String>> headers = new LinkedHashMap<>(prepared.headers());
credentials.headers().forEach((name, value) -> headers.put(name, List.of(value)));
RestClient.RequestBodySpec spec =
runtime
.restClient()
.method(org.springframework.http.HttpMethod.valueOf(operation.method().name()))
.uri(prepared.target().uri());
headers.forEach((name, values) -> values.forEach(value -> spec.header(name, value)));
// The admission permit is held for the life of the stream, not just the request: a streamed
// body occupies a connection until the caller closes it, and counting only the handshake made
// the limiter blind to exactly the calls that hold resources longest.
AutoCloseable admission = runtime.admissionLimiter().admit(metadata);
BlockingStreamingResponse response;
try {
response =
spec.exchange(
(request, rawResponse) -> {
int status = rawResponse.getStatusCode().value();
Map<String, List<String>> responseHeaders = new LinkedHashMap<>();
rawResponse
.getHeaders()
.forEach(
(name, values) ->
responseHeaders.put(name, List.copyOf(new ArrayList<>(values))));
if (status < 200 || status >= 300) {
// Status is validated before any byte is delivered, so a failed download never
// becomes a half-consumed stream the caller has to reason about.
rawResponse.close();
throw new HttpRemoteErrorException(
"streaming download returned an error status",
metadata
.withStatus(new HttpStatus(status))
.withEvidence(ExecutionEvidence.RESPONSE_RECEIVED));
}
InputStream bounded =
new CountingBoundedInputStream(
rawResponse.getBody(), limiter::recordWireBytes);
return new DefaultBlockingStreamingResponse(
new HttpStatus(status), responseHeaders, bounded, rawResponse::close);
},
false);
} catch (RuntimeException failure) {
closeQuietly(admission);
throw failure;
}
BlockingStreamingResponse wrapped =
new DefaultBlockingStreamingResponse(
response.status(),
response.headers(),
response.body(),
() -> {
response.close();
closeQuietly(admission);
lease.close();
});
handedOff = true;
return wrapped;
} finally {
if (!handedOff) {
lease.close();
}
}
}
/** A permit release must not mask the failure that is already propagating. */
private static void closeQuietly(AutoCloseable closeable) {
try {
closeable.close();
} catch (Exception ignored) {
// The admission limiter's release cannot fail meaningfully; swallowing keeps the original
// failure attributable.
}
}
}
@@ -0,0 +1,304 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAmbiguousExecutionException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
import dev.caskeleton.adapter.outbound.httpclient.auth.UnauthorizedRetryContext;
import dev.caskeleton.adapter.outbound.httpclient.observation.LogicalCallObservation;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptBudgetCalculator;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
import dev.caskeleton.adapter.outbound.httpclient.resilience.BlockingLogicalCall;
import dev.caskeleton.adapter.outbound.httpclient.resilience.BlockingRetryCoordinator;
import dev.caskeleton.adapter.outbound.httpclient.resilience.Deadline;
import dev.caskeleton.adapter.outbound.httpclient.resilience.DeadlineGuard;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryAllowed;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryContext;
import dev.caskeleton.adapter.outbound.httpclient.resilience.Sleeper;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
/**
* H2 Generic Exchange over the blocking stack (design §9.3, §7.1).
*
* <p>The logical call is assembled here and then handed to the retry coordinator: lease → admission
* → target policy → deadline → credentials → attempts. Every one of those is a place a caller could
* otherwise bypass a profile guarantee, which is why none of them are optional.
*/
public final class DefaultGenericHttpGateway implements GenericHttpGateway {
private final ClientRuntimeRegistry runtimes;
private final BlockingAttemptExecutor executor;
public DefaultGenericHttpGateway(
ClientRuntimeRegistry runtimes, BlockingAttemptExecutor executor) {
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
this.executor = Objects.requireNonNull(executor, "attempt executor");
}
@Override
public <T> HttpCallResult<T> exchange(
ClientProfileName profileName, HttpOperation operation, ResponseType<T> responseType) {
return exchange(profileName, operation, responseType, StatusHandlingPolicy.THROW_ON_ERROR);
}
@Override
public <T> HttpCallResult<T> exchange(
ClientProfileName profileName,
HttpOperation operation,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy) {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(operation, "operation");
Objects.requireNonNull(responseType, "response type");
try (ClientRuntimeLease lease = runtimes.acquire(profileName)) {
BlockingClientRuntime runtime = requireBlockingRuntime(lease);
BlockingExecutionSupport support = runtime.support();
HttpFailureMetadata metadata =
HttpFailureMetadata.validation(
profileName,
operation.operationName(),
operation.method(),
operation.uriTemplate(),
operation.body().replayability());
try (AutoCloseable admission = runtime.admissionLimiter().admit(metadata);
AutoCloseable context =
BlockingOperationContext.set(profileName, operation.operationName())) {
PreparedOperation prepared = runtime.targetPolicy().prepare(operation);
Deadline deadline =
support
.deadlineCalculator()
.effective(
operation.deadline(), runtime.profile().timeout().totalCall(), support.clock());
new DeadlineGuard(support.clock()).requireTimeRemaining(deadline, metadata);
LogicalCallObservation observation =
LogicalCallObservation.start(
support.meterRegistry(),
support.tagPolicy(),
profileName,
operation.operationName(),
operation.method().name(),
operation.uriTemplate());
BlockingRetryCoordinator coordinator =
new BlockingRetryCoordinator(
support.eligibilityEngine(),
runtime.newBackoff(),
runtime.retryBudget(),
Sleeper.threadSleep(),
support.clock());
GenericLogicalCall<T> call =
new GenericLogicalCall<>(
runtime,
prepared,
responseType,
statusHandlingPolicy,
deadline,
metadata,
observation);
try {
HttpCallResult<T> result = coordinator.execute(call);
observation.stop(Optional.of(result.status()), result.evidence(), "success");
return result;
} catch (HttpAmbiguousExecutionException ambiguous) {
observation.recordAmbiguous();
observation.stop(Optional.empty(), ambiguous.metadata().evidence(), "ambiguous");
throw ambiguous;
} catch (HttpClientException failure) {
observation.stop(failure.metadata().status(), failure.metadata().evidence(), "failure");
throw failure;
}
} catch (RuntimeException failure) {
throw failure;
} catch (Exception unexpected) {
throw new IllegalStateException("outbound http call could not complete", unexpected);
}
}
}
private BlockingClientRuntime requireBlockingRuntime(ClientRuntimeLease lease) {
if (lease.runtime() instanceof BlockingClientRuntime blocking) {
return blocking;
}
throw new IllegalStateException(
"profile " + lease.runtime().name().value() + " is not configured for the blocking api");
}
/** Bridges one prepared operation to the retry coordinator (design §7.1 steps 8-13). */
private final class GenericLogicalCall<T> implements BlockingLogicalCall<T> {
private final BlockingClientRuntime runtime;
private final PreparedOperation prepared;
private final ResponseType<T> responseType;
private final StatusHandlingPolicy statusHandlingPolicy;
private final Deadline deadline;
private final HttpFailureMetadata metadata;
private final LogicalCallObservation observation;
private final Instant startedAt;
private RequestCredentials credentials;
private int credentialRefreshes;
private GenericLogicalCall(
BlockingClientRuntime runtime,
PreparedOperation prepared,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy,
Deadline deadline,
HttpFailureMetadata metadata,
LogicalCallObservation observation) {
this.runtime = runtime;
this.prepared = prepared;
this.responseType = responseType;
this.statusHandlingPolicy = statusHandlingPolicy;
this.deadline = deadline;
this.metadata = metadata;
this.observation = observation;
this.startedAt = runtime.support().clock().instant();
this.credentials = resolveCredentials(false);
}
@Override
public AttemptOutcome<T> attempt(int attemptNumber) {
if (!runtime.acceptsNewAttempts() && attemptNumber > 1) {
throw new HttpRateLimitRejectedException(
"runtime is draining and refuses new attempts", metadata);
}
// Checked before every attempt, not only before the first. The deadline used to be verified
// once at the start of the logical call, so a retry could begin after the budget had already
// expired and run to its own transport timeout — the caller's deadline was a suggestion the
// second attempt onwards ignored. AttemptBudgetCalculator and DeadlineGuard both existed for
// this and neither was on the execution path.
BlockingExecutionSupport attemptSupport = runtime.support();
DeadlineGuard guard = new DeadlineGuard(attemptSupport.clock());
guard.requireTimeRemaining(deadline, metadata.withAttempt(attemptNumber));
guard.requireAttemptBudget(
new AttemptBudgetCalculator(attemptSupport.clock())
.nextAttempt(
deadline, Duration.ZERO, attemptSupport.minimumAttemptBudget(), Duration.ZERO),
metadata.withAttempt(attemptNumber));
return executor.execute(
runtime,
prepared,
responseType,
statusHandlingPolicy,
credentials,
attemptNumber,
startedAt,
metadata.withAttempt(attemptNumber));
}
@Override
public RetryContext context(AttemptOutcome<T> outcome, int attemptNumber) {
BlockingExecutionSupport support = runtime.support();
boolean unauthorized = outcome.status().map(status -> status.value() == 401).orElse(false);
boolean refreshAllowed =
unauthorized
&& support
.unauthorizedRetryPolicy()
.mayRetry(
new UnauthorizedRetryContext(
prepared.operation().idempotency(),
prepared.operation().body().replayability(),
credentialRefreshes,
true));
if (refreshAllowed) {
runtime.credentialProvider().invalidate(credentialRequest(false));
credentials = resolveCredentials(true);
credentialRefreshes++;
}
return new RetryContext(
prepared.operation().idempotency(),
prepared.operation().idempotencyKey(),
prepared.idempotencyKeySent(),
prepared.operation().body().replayability(),
outcome.evidence(),
outcome.failureCategory(),
outcome.status(),
outcome.retryAfter(),
attemptNumber,
runtime.profile().retry().maxAttempts(),
outcome.firstByteDelivered(),
deadline.remaining(support.clock()),
support.minimumAttemptBudget(),
runtime.retryBudget().snapshot(),
support.transientServerErrorStatuses(),
refreshAllowed,
!runtime.acceptsNewAttempts());
}
@Override
public Deadline deadline() {
return deadline;
}
@Override
public HttpCallResult<T> finish(AttemptOutcome<T> outcome, int attemptNumber) {
return outcome
.result()
.map(
value ->
new HttpCallResult<>(
value.status(),
value.headers(),
value.body(),
attemptNumber,
Duration.between(startedAt, runtime.support().clock().instant()),
value.evidence(),
value.remoteProblem()))
.orElseThrow(() -> outcome.failure().orElseThrow());
}
@Override
public HttpClientException ambiguous(AttemptOutcome<T> outcome, int attemptNumber) {
return new HttpAmbiguousExecutionException(
"request was sent but the remote outcome is unknown",
metadata.withAttempt(attemptNumber).withEvidence(ExecutionEvidence.SENT_NO_RESPONSE));
}
@Override
public HttpClientException retryExhausted(int attemptNumber) {
observation.recordRetryExhausted();
return new HttpRateLimitRejectedException(
"retry budget for this upstream is exhausted", metadata.withAttempt(attemptNumber));
}
@Override
public void onRetryGranted(RetryAllowed allowed, int attemptNumber) {
observation.recordRetry(allowed.reason());
}
private RequestCredentials resolveCredentials(boolean forceRefresh) {
return runtime.credentialProvider().resolve(credentialRequest(forceRefresh));
}
private CredentialRequest credentialRequest(boolean forceRefresh) {
return new CredentialRequest(
runtime.name(),
prepared.operation().operationName(),
runtime.profile().authentication(),
prepared.target().uri(),
Optional.empty(),
runtime.profile().tls().keyMaterialReference(),
forceRefresh);
}
}
}
@@ -0,0 +1,206 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpSerializationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ByteArrayResponseType;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ClassResponseType;
import dev.caskeleton.adapter.outbound.httpclient.api.result.EmptyResponseType;
import dev.caskeleton.adapter.outbound.httpclient.api.result.GenericResponseType;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.profile.ResponseLimits;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
/**
* Reads and decodes a response inside the profile's byte budget (design §23.2).
*
* <p>The body is bounded while it is read and then decoded from memory by a Spring message
* converter. Doing the bounding first is what makes a decompression bomb or an unexpectedly huge
* payload a rejected request instead of an out-of-memory error.
*/
public final class RestClientResponseReader {
private final List<HttpMessageConverter<?>> converters;
public RestClientResponseReader() {
this(defaultConverters());
}
public RestClientResponseReader(List<HttpMessageConverter<?>> converters) {
this.converters = List.copyOf(Objects.requireNonNull(converters, "message converters"));
}
public static List<HttpMessageConverter<?>> defaultConverters() {
List<HttpMessageConverter<?>> converters = new ArrayList<>();
converters.add(new ByteArrayHttpMessageConverter());
converters.add(new StringHttpMessageConverter());
converters.add(new FormHttpMessageConverter());
converters.add(new JacksonJsonHttpMessageConverter());
return List.copyOf(converters);
}
/**
* Bounded snapshot of one HTTP response.
*
* <p>A final class rather than a record: the body is an array and must be copied on construction
* and on every access so a caller cannot mutate a buffer the retry path may re-read.
*/
public static final class RawResponse {
private final int status;
private final Map<String, List<String>> headers;
private final byte[] body;
public RawResponse(int status, Map<String, List<String>> headers, byte[] body) {
this.status = status;
this.headers = Map.copyOf(Objects.requireNonNull(headers, "headers"));
this.body = Objects.requireNonNull(body, "body").clone();
}
public int status() {
return status;
}
public Map<String, List<String>> headers() {
return headers;
}
public byte[] body() {
return body.clone();
}
public int bodyLength() {
return body.length;
}
public java.util.Optional<String> firstHeader(String name) {
return headers.entrySet().stream()
.filter(entry -> entry.getKey().equalsIgnoreCase(name))
.flatMap(entry -> entry.getValue().stream())
.findFirst();
}
}
public RawResponse readBounded(
int status, HttpHeaders headers, InputStream body, ResponseSizeLimiter limiter)
throws IOException {
try (InputStream counted = new CountingBoundedInputStream(body, limiter::recordWireBytes)) {
byte[] bytes = counted.readAllBytes();
limiter.recordDecodedBytes(bytes.length);
return new RawResponse(status, toMap(headers), bytes);
}
}
private static Map<String, List<String>> toMap(HttpHeaders headers) {
Map<String, List<String>> copy = new java.util.LinkedHashMap<>();
headers.forEach((name, values) -> copy.put(name, List.copyOf(values)));
return copy;
}
public void requireAllowedContentType(
RawResponse response, ResponseLimits limits, HttpFailureMetadata metadata) {
if (response.bodyLength() == 0) {
return;
}
String contentType = response.firstHeader(HttpHeaders.CONTENT_TYPE).orElse("");
if (contentType.isEmpty() || limits.permits(contentType)) {
return;
}
throw new HttpTargetRejectedException(
"response content type is not permitted by the profile", metadata);
}
@SuppressWarnings("unchecked")
public <T> T decode(
RawResponse response, ResponseType<T> responseType, HttpFailureMetadata metadata) {
if (responseType instanceof EmptyResponseType) {
return null;
}
if (responseType instanceof ByteArrayResponseType) {
return (T) response.body();
}
MediaType contentType =
response
.firstHeader(HttpHeaders.CONTENT_TYPE)
.map(MediaType::parseMediaType)
.orElse(MediaType.APPLICATION_OCTET_STREAM);
Type target = responseType.type();
try {
for (HttpMessageConverter<?> converter : converters) {
if (responseType instanceof ClassResponseType<T> classType
&& converter.canRead(classType.rawType(), contentType)) {
HttpMessageConverter<T> typed = (HttpMessageConverter<T>) converter;
return typed.read(classType.rawType(), inputMessage(response, contentType));
}
if (responseType instanceof GenericResponseType
&& converter instanceof GenericHttpMessageConverter<?> generic
&& generic.canRead(target, null, contentType)) {
GenericHttpMessageConverter<T> typed = (GenericHttpMessageConverter<T>) generic;
return typed.read(target, null, inputMessage(response, contentType));
}
}
} catch (IOException | RuntimeException failure) {
throw new HttpSerializationException("response body could not be decoded", metadata, failure);
}
throw new HttpSerializationException(
"no message converter can read the declared response type", metadata);
}
/**
* Convenience for the typed client, which knows its target as a {@link
* ParameterizedTypeReference}.
*/
@SuppressWarnings("unchecked")
public static <T> ResponseType<T> responseTypeOf(ParameterizedTypeReference<T> reference) {
Type type = reference.getType();
if (type == Void.class || type == void.class) {
return (ResponseType<T>) ResponseType.empty();
}
if (type == byte[].class) {
return (ResponseType<T>) ResponseType.ofBytes();
}
// A non-generic reference must become a ClassResponseType. Wrapping it as a generic one looked
// harmless but sent every plain DTO down the GenericHttpMessageConverter branch, where the
// converters this reader holds decline to read it — so a typed client that declared
// `UserResponse` failed with "no message converter can read the declared response type".
if (type instanceof Class<?> rawType) {
return (ResponseType<T>) ResponseType.of(rawType);
}
return new GenericResponseType<>(type);
}
private HttpInputMessage inputMessage(RawResponse response, MediaType contentType) {
HttpHeaders headers = new HttpHeaders();
response.headers().forEach(headers::addAll);
headers.setContentType(contentType);
byte[] body = response.body();
return new HttpInputMessage() {
@Override
public InputStream getBody() {
return new ByteArrayInputStream(body);
}
@Override
public HttpHeaders getHeaders() {
return headers;
}
};
}
}
@@ -0,0 +1,144 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialProviderRegistry;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeFactory;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptResiliencePipeline;
import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ExponentialFullJitterBackoff;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRegistry;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget;
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportProvider;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportCapabilityValidator;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
import java.time.Duration;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
import java.util.random.RandomGenerator;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
/**
* Builds a blocking runtime generation for a profile (design §26.1).
*
* <p>Capability validation runs before any client exists, so a profile whose transport cannot
* honour it fails at startup rather than at the first production request.
*/
public final class RestClientRuntimeFactory implements ClientRuntimeFactory {
private final Map<TransportType, BlockingTransportProvider> providers =
new EnumMap<>(TransportType.class);
private final TransportCapabilityValidator capabilityValidator =
new TransportCapabilityValidator();
private final ResilienceRegistry resilienceRegistry;
private final CredentialProviderRegistry credentialProviders;
private final BlockingExecutionSupport support;
private final TransportLifecycleListener lifecycleListener;
private final RandomGenerator random;
public RestClientRuntimeFactory(
Map<TransportType, BlockingTransportProvider> providers,
ResilienceRegistry resilienceRegistry,
CredentialProviderRegistry credentialProviders,
BlockingExecutionSupport support,
TransportLifecycleListener lifecycleListener,
RandomGenerator random) {
Objects.requireNonNull(providers, "transport providers").forEach(this.providers::put);
this.resilienceRegistry = Objects.requireNonNull(resilienceRegistry, "resilience registry");
this.credentialProviders = Objects.requireNonNull(credentialProviders, "credential providers");
this.support = Objects.requireNonNull(support, "execution support");
this.lifecycleListener = Objects.requireNonNull(lifecycleListener, "lifecycle listener");
this.random = Objects.requireNonNull(random, "random generator");
}
@Override
public ClientRuntime create(ClientProfile profile, RuntimeGeneration generation) {
Objects.requireNonNull(profile, "profile");
BlockingTransportProvider provider = providers.get(profile.transport());
if (provider == null) {
throw new HttpConfigurationException(
"no blocking transport provider is registered for " + profile.transport(),
HttpFailureMetadata.startup(profile.name()));
}
capabilityValidator.validate(profile, provider.capabilities());
ClientHttpRequestFactory requestFactory =
provider.create(profile, generation, lifecycleListener);
RestClient restClient =
RestClient.builder()
.requestFactory(requestFactory)
.baseUrl(profile.baseUrl().toString())
.build();
AttemptResiliencePipeline pipeline = newResiliencePipeline(profile);
RetryBudget retryBudget =
profile
.retry()
.budget()
.map(
name ->
resilienceRegistry.retryBudget(
name, retryCapacity(profile), Duration.ofMinutes(1)))
.orElseGet(RetryBudget::unlimited);
Supplier<BackoffStrategy> backoffFactory =
() ->
new ExponentialFullJitterBackoff(
profile.retry().baseBackoff(),
profile.retry().maxBackoff(),
profile.retry().jitter(),
profile.retry().retryAfter(),
random);
return new BlockingClientRuntime(
profile,
generation,
() -> provider.close(profile, generation),
restClient,
provider.id(),
provider.failureClassifier(),
pipeline,
resilienceRegistry.admission(
profile.name(),
profile.pool().maxPendingAcquires() + profile.pool().maxTotalConnections()),
retryBudget,
backoffFactory,
credentialProviders.require(profile.name(), profile.authentication().type()),
support);
}
/**
* Retry capacity is derived from the pool budget rather than invented: a retry storm is bounded
* by what the upstream can absorb, and the pool is the only declared statement of that.
*/
private long retryCapacity(ClientProfile profile) {
return Math.max(1L, profile.pool().maxTotalConnections() / 10L);
}
/**
* Builds the guard chain with its rejection metrics attached.
*
* <p>The three local back-pressure signals — circuit open, rate limited, bulkhead full — were
* declared in the metric vocabulary and emitted by nothing, so a saturated client looked exactly
* like a healthy one on a dashboard.
*/
private AttemptResiliencePipeline newResiliencePipeline(ClientProfile profile) {
dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptCircuitBreaker breaker =
resilienceRegistry.circuitBreaker(profile.name());
return new AttemptResiliencePipeline(
breaker,
resilienceRegistry.rateLimiter(profile.name()),
resilienceRegistry.bulkhead(profile.name(), profile.pool().maxTotalConnections()),
() -> HttpFailureMetadata.startup(profile.name()),
dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRejectionRecorder
.micrometer(support.meterRegistry(), profile.name(), breaker::state));
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.adapter.outbound.httpclient.security;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* An operation that has passed target, header, and body-size policy and is ready to execute (design
* §12).
*
* <p>Nothing downstream re-derives a URL, re-adds a header, or re-checks a limit: the executor may
* only use what this record already approved.
*/
public record PreparedOperation(
HttpOperation operation,
PreparedTarget target,
Map<String, List<String>> headers,
long maxRequestBytes,
long maxResponseWireBytes,
long maxResponseDecodedBytes) {
public PreparedOperation {
Objects.requireNonNull(operation, "operation");
Objects.requireNonNull(target, "target");
Objects.requireNonNull(headers, "headers");
headers = Map.copyOf(headers);
}
/**
* Whether the operation's idempotency key is present in the headers that will be sent.
*
* <p>Read by the retry decision instead of {@code operation.idempotencyKey().isPresent()}. The
* distinction is the whole point: a key the caller supplied but the platform never wrote gives
* the upstream nothing to deduplicate against, so a repeat is a duplicate side effect rather than
* a safe retry.
*
* @return {@code true} when a key exists and a header carries its exact value
*/
public boolean idempotencyKeySent() {
return operation
.idempotencyKey()
.map(
key ->
headers.values().stream()
.flatMap(List::stream)
.anyMatch(value -> value.equals(key.value())))
.orElse(false);
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.outbound.httpclient.security;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Removes credentials when a redirect crosses an origin (design §12.4, §22.4).
*
* <p>Forwarding {@code Authorization} to a redirect target is a credential disclosure to whoever
* controls that target, which for a Dynamic Target is by definition not us.
*/
public final class SensitiveHeaderStripper {
private static final Set<String> ALWAYS_STRIPPED =
Set.of("authorization", "proxy-authorization", "cookie", "set-cookie");
/** Conventional API-key header names, stripped even when a profile names a different one. */
private static final Set<String> CONVENTIONAL_API_KEY_HEADERS = Set.of("x-api-key", "api-key");
private final Set<String> additionalSensitiveHeaders;
private SensitiveHeaderStripper(Set<String> additionalSensitiveHeaders) {
this.additionalSensitiveHeaders = additionalSensitiveHeaders;
}
public static SensitiveHeaderStripper standard() {
return new SensitiveHeaderStripper(CONVENTIONAL_API_KEY_HEADERS);
}
/**
* Adds profile-specific credential headers to the conventional set.
*
* <p>It adds rather than replaces, which its name always claimed and its behaviour did not. A
* profile that named a custom API-key header — {@code X-Client-Key}, say — produced a stripper
* that dropped only that one and forwarded {@code X-Api-Key} across an origin boundary, so
* configuring a custom header made the default headers <em>less</em> protected than leaving it
* alone.
*
* @param headerNames additional credential-bearing header names, case-insensitive
* @return a stripper covering the conventional names plus these
*/
public static SensitiveHeaderStripper withAdditional(Set<String> headerNames) {
Objects.requireNonNull(headerNames, "additional sensitive header names");
Set<String> lower =
java.util.stream.Stream.concat(
CONVENTIONAL_API_KEY_HEADERS.stream(),
headerNames.stream().map(name -> name.toLowerCase(Locale.ROOT)))
.collect(Collectors.toUnmodifiableSet());
return new SensitiveHeaderStripper(lower);
}
public Map<String, List<String>> stripForCrossOrigin(Map<String, List<String>> headers) {
Map<String, List<String>> retained = new LinkedHashMap<>();
headers.forEach(
(name, values) -> {
String lower = name.toLowerCase(Locale.ROOT);
if (!ALWAYS_STRIPPED.contains(lower) && !additionalSensitiveHeaders.contains(lower)) {
retained.put(name, List.copyOf(values));
}
});
return Map.copyOf(retained);
}
}
@@ -0,0 +1,78 @@
package dev.caskeleton.adapter.outbound.httpclient.security;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeFactory;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import java.time.Duration;
import java.util.Objects;
/**
* Rotates certificates and secrets by building a new runtime generation (design §7.2, §21.1).
*
* <p>Rotation is a generation swap rather than an in-place mutation because a connection pool holds
* sockets that were established under the previous identity: replacing the material without
* replacing the pool leaves live connections authenticated by a certificate that is meant to be
* gone.
*/
public final class TlsRuntimeRotationCoordinator {
private final ClientRuntimeRegistry registry;
private final ClientRuntimeFactory runtimeFactory;
private final Duration drainTimeout;
public TlsRuntimeRotationCoordinator(
ClientRuntimeRegistry registry, ClientRuntimeFactory runtimeFactory, Duration drainTimeout) {
this.registry = Objects.requireNonNull(registry, "runtime registry");
this.runtimeFactory = Objects.requireNonNull(runtimeFactory, "runtime factory");
this.drainTimeout = Objects.requireNonNull(drainTimeout, "drain timeout");
}
/**
* Rotates the profiles that actually use the rotated identity.
*
* <p>The identity argument used to be required and then ignored: every registered profile was
* rotated whatever certificate had changed. Rotating a runtime is not free — it discards a warm
* pool, forces fresh handshakes, and drains connections mid-flight — so a single certificate
* renewal caused a connection storm across every upstream the service talks to, most of which had
* nothing to do with that certificate. It also hid the real failure: if the rotation was wrong,
* every profile degraded at once and nothing pointed at the cause.
*
* <p>A profile participates when its TLS settings name the rotated identity, either as the key
* material reference or as the TLS profile id. A profile with no client certificate cannot be
* affected by a client-certificate rotation at all.
*
* @param identity the certificate identity that changed
* @return the profiles that were rotated, so a caller can log or assert on the blast radius
*/
public java.util.List<ClientProfileName> rotate(ClientCertificateIdentity identity) {
Objects.requireNonNull(identity, "client certificate identity");
java.util.List<ClientProfileName> rotated = new java.util.ArrayList<>();
for (ClientProfileName name : registry.names()) {
if (participatesIn(name, identity)) {
rotateProfile(name);
rotated.add(name);
}
}
return java.util.List.copyOf(rotated);
}
/** Whether this profile's TLS material is the one that rotated. */
private boolean participatesIn(ClientProfileName name, ClientCertificateIdentity identity) {
dev.caskeleton.adapter.outbound.httpclient.profile.TlsSettings tls =
registry.current(name).profile().tls();
if (tls.keyMaterialReference().isEmpty()) {
// No client certificate: a client-certificate rotation cannot reach this profile.
return false;
}
return tls.keyMaterialReference().filter(identity.value()::equals).isPresent()
|| tls.profileId().filter(identity.value()::equals).isPresent();
}
public void rotateProfile(ClientProfileName name) {
ClientRuntime previous = registry.current(name);
ClientRuntime replacement =
runtimeFactory.create(previous.profile(), previous.generation().next());
registry.swap(name, replacement, drainTimeout);
}
}
@@ -0,0 +1,176 @@
package dev.caskeleton.adapter.outbound.httpclient.security;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.IdempotencyKeyRequirement;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/**
* Turns a caller's operation into a {@link PreparedOperation} for a Trusted profile (design §12.1).
*
* <p>An absolute URI is rejected here rather than sanitised: H2 exists to vary method, relative
* path, query, approved headers, and body — not the destination. Changing the destination is what
* H3 is for, and H3 has its own policy, credentials, and DNS validation.
*/
public final class TrustedTargetPolicy {
private final ClientProfile profile;
private final UriTemplateExpander expander;
private final BodyLimitPolicy bodyLimitPolicy;
public TrustedTargetPolicy(ClientProfile profile) {
this.profile = Objects.requireNonNull(profile, "profile");
this.expander = new UriTemplateExpander(profile.baseUrl());
this.bodyLimitPolicy = BodyLimitPolicy.maxRequestBytes(profile.request().maxBodyBytes());
}
public ClientProfile profile() {
return profile;
}
/**
* Prepares an operation, deriving its idempotency-key requirement from the operation itself.
*
* <p>Deriving rather than defaulting to {@link IdempotencyKeyRequirement#none()} is deliberate: a
* default of "no key" silently dropped a key the caller had registered, and the request went out
* without the one header that would have let the upstream deduplicate it.
*
* @param operation the caller's operation
* @return the prepared operation, with the key rendered as a header when one exists
*/
public PreparedOperation prepare(HttpOperation operation) {
return prepare(operation, requirementFor(operation));
}
/**
* The requirement an operation implies.
*
* @param operation the operation to inspect
* @return {@code required} under the standard header name when the operation carries a key
*/
public static IdempotencyKeyRequirement requirementFor(HttpOperation operation) {
return operation.idempotencyKey().isPresent()
? IdempotencyKeyRequirement.required("Idempotency-Key")
: IdempotencyKeyRequirement.none();
}
public PreparedOperation prepare(
HttpOperation operation, IdempotencyKeyRequirement idempotencyKeyRequirement) {
Objects.requireNonNull(operation, "operation");
HttpFailureMetadata metadata =
HttpFailureMetadata.validation(
profile.name(),
operation.operationName(),
operation.method(),
operation.uriTemplate(),
operation.body().replayability());
requireRelativeTemplate(operation.uriTemplate(), metadata);
PreparedTarget target = expander.expand(operation.uriTemplate(), operation.uriVariables());
requireAllowedOrigin(target, metadata);
Map<String, List<String>> headers =
HeaderPolicy.forOperation(idempotencyKeyRequirement, false)
.validate(operation.headers(), metadata);
headers = withIdempotencyKey(operation, idempotencyKeyRequirement, headers, metadata);
bodyLimitPolicy.validate(operation.body(), metadata);
return new PreparedOperation(
operation,
target,
headers,
profile.request().maxBodyBytes(),
profile.response().maxWireBytes(),
profile.response().maxDecodedBytes());
}
/**
* Renders the operation's idempotency key as the header the upstream will actually receive.
*
* <p>The platform owns this header. Before, the key was carried on the operation, checked for
* presence by the retry engine, and never written to the wire: the upstream saw no key, could not
* deduplicate, and the platform meanwhile treated a repeat as contractually safe. A duplicated
* payment is the shape of that bug.
*
* <p>A caller-supplied value for the same header is refused rather than merged. Two keys for one
* request is a contradiction, and silently preferring either one would decide on the caller's
* behalf which request the upstream is allowed to deduplicate against.
*/
private Map<String, List<String>> withIdempotencyKey(
HttpOperation operation,
IdempotencyKeyRequirement requirement,
Map<String, List<String>> headers,
HttpFailureMetadata metadata) {
if (operation.idempotencyKey().isEmpty()) {
return headers;
}
if (!requirement.required()) {
throw new HttpTargetRejectedException(
"the operation carries an idempotency key but its descriptor does not register one, so "
+ "the platform has no header to send it in",
metadata);
}
String headerName = requirement.headerName();
boolean callerSupplied =
headers.keySet().stream()
.anyMatch(
name -> name.toLowerCase(Locale.ROOT).equals(headerName.toLowerCase(Locale.ROOT)));
if (callerSupplied) {
throw new HttpTargetRejectedException(
"header " + headerName + " is owned by the platform and must not be supplied by a caller",
metadata);
}
Map<String, List<String>> merged = new LinkedHashMap<>(headers);
merged.put(headerName, List.of(operation.idempotencyKey().orElseThrow().value()));
return Map.copyOf(merged);
}
private void requireRelativeTemplate(String uriTemplate, HttpFailureMetadata metadata) {
if (uriTemplate.isEmpty()) {
throw new HttpTargetRejectedException("uri template must not be empty", metadata);
}
String lower = uriTemplate.toLowerCase(Locale.ROOT);
if (lower.startsWith("//") || lower.contains("://")) {
throw new HttpTargetRejectedException(
"a trusted generic exchange accepts only a profile-relative uri template", metadata);
}
if (!uriTemplate.startsWith("/")) {
throw new HttpTargetRejectedException(
"uri template must start with '/' relative to the profile base url", metadata);
}
}
/**
* Re-applies the profile's origin allowlist to a target the caller did not choose.
*
* <p>Public because a redirect hop needs it. The coordinator used to evaluate a hop against the
* redirect policy alone — hop count, cross-origin flag, method rewrite — and never against the
* profile's own allowed hosts and ports. An upstream could therefore redirect a trusted profile
* to any origin the redirect policy tolerated, including one the operator had explicitly excluded
* from the allowlist, and the platform would follow it.
*
* @param target the hop target
* @param metadata failure metadata for the rejection
* @throws HttpTargetRejectedException when the host or port is not on the profile's allowlist
*/
public void requireAllowedTarget(java.net.URI target, HttpFailureMetadata metadata) {
requireAllowedOrigin(PreparedTarget.of(target, target.getPath()), metadata);
}
private void requireAllowedOrigin(PreparedTarget target, HttpFailureMetadata metadata) {
if (!profile.allowedHosts().isEmpty() && !profile.allowedHosts().contains(target.host())) {
throw new HttpTargetRejectedException(
"target host is not on the profile allowlist", metadata);
}
if (!profile.allowedPorts().isEmpty() && !profile.allowedPorts().contains(target.port())) {
throw new HttpTargetRejectedException(
"target port is not on the profile allowlist", metadata);
}
}
}
@@ -0,0 +1,115 @@
package dev.caskeleton.adapter.outbound.httpclient.service;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.restclient.GenericHttpGateway;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/**
* Builds validated typed clients over a profile's immutable RestClient (design §26.3).
*
* <p>Interfaces are scanned and validated at creation, and the resulting proxy is cached per
* (profile, interface): building it per call would re-run reflection on every request and lose the
* startup-failure guarantee.
*/
public final class DefaultHttpServiceRegistry implements HttpServiceRegistry {
private final ClientRuntimeRegistry runtimes;
private final GenericHttpGateway gateway;
private final ServiceOperationDescriptorScanner scanner;
private final OperationContextHolder contextHolder;
private final Map<String, Object> clients = new ConcurrentHashMap<>();
public DefaultHttpServiceRegistry(ClientRuntimeRegistry runtimes, GenericHttpGateway gateway) {
this(
runtimes,
gateway,
new ServiceOperationDescriptorScanner(),
OperationContextHolder.instance());
}
public DefaultHttpServiceRegistry(
ClientRuntimeRegistry runtimes,
GenericHttpGateway gateway,
ServiceOperationDescriptorScanner scanner,
OperationContextHolder contextHolder) {
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
this.gateway = Objects.requireNonNull(gateway, "generic http gateway");
this.scanner = Objects.requireNonNull(scanner, "descriptor scanner");
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
}
@Override
public <T> T client(Class<T> serviceType) {
return client(scanner.profileOf(serviceType), serviceType);
}
@Override
public <T> T client(ClientProfileName profileName, Class<T> serviceType) {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(serviceType, "service type");
String key = profileName.value() + '|' + serviceType.getName();
return serviceType.cast(
clients.computeIfAbsent(key, ignored -> build(profileName, serviceType)));
}
private <T> T build(ClientProfileName profileName, Class<T> serviceType) {
List<ServiceOperationDescriptor> descriptors = scanner.scan(serviceType);
ClientProfileName declared = scanner.profileOf(serviceType);
if (!declared.equals(profileName)) {
throw new HttpConfigurationException(
"interface "
+ serviceType.getName()
+ " declares profile "
+ declared.value()
+ " but was requested for "
+ profileName.value(),
HttpFailureMetadata.startup(profileName));
}
if (descriptors.stream().anyMatch(ServiceOperationDescriptor::reactive)) {
throw new HttpConfigurationException(
"interface " + serviceType.getName() + " is reactive; use the reactive registry",
HttpFailureMetadata.startup(profileName));
}
try (ClientRuntimeLease lease = runtimes.acquire(profileName)) {
if (!(lease.runtime() instanceof BlockingClientRuntime)) {
throw new HttpConfigurationException(
"profile " + profileName.value() + " is not configured for the blocking api",
HttpFailureMetadata.startup(profileName));
}
}
// Deliberately not RestClientAdapter over the profile's RestClient. That adapter reaches the
// network directly, so a typed call bypassed target policy, credentials, admission, deadline,
// resilience, byte limits, stable error mapping and observation — every guarantee the profile
// exists to provide. Routing through the gateway makes the typed surface the same code path as
// the generic one rather than a parallel one that happens to look similar.
T springProxy =
HttpServiceProxyFactory.builderFor(
new KernelHttpExchangeAdapter(gateway, profileName, contextHolder))
.build()
.createClient(serviceType);
Map<Method, ServiceOperationDescriptor> byMethod = new LinkedHashMap<>();
descriptors.forEach(descriptor -> byMethod.put(descriptor.method(), descriptor));
InvocationHandler handler =
new BlockingServiceInvocationHandler(springProxy, profileName, byMethod, contextHolder);
return serviceType.cast(
Proxy.newProxyInstance(
serviceType.getClassLoader(), new Class<?>[] {serviceType}, handler));
}
}
@@ -0,0 +1,108 @@
package dev.caskeleton.adapter.outbound.httpclient.service;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveHttpGateway;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/**
* Builds validated reactive typed clients over a profile's immutable WebClient (design §26.3).
*
* <p>An interface that is entirely blocking is rejected here for the same reason its mirror image
* is rejected by the blocking registry: one interface, one execution model.
*/
public final class DefaultReactiveHttpServiceRegistry implements ReactiveHttpServiceRegistry {
/**
* Only reached by a caller that blocks on a reactive typed method, which the platform forbids.
*/
private static final Duration BLOCK_TIMEOUT = Duration.ofSeconds(30);
private final ClientRuntimeRegistry runtimes;
private final ReactiveHttpGateway gateway;
private final ServiceOperationDescriptorScanner scanner;
private final OperationContextHolder contextHolder;
private final Map<String, Object> clients = new ConcurrentHashMap<>();
public DefaultReactiveHttpServiceRegistry(
ClientRuntimeRegistry runtimes, ReactiveHttpGateway gateway) {
this(
runtimes,
gateway,
new ServiceOperationDescriptorScanner(),
OperationContextHolder.instance());
}
public DefaultReactiveHttpServiceRegistry(
ClientRuntimeRegistry runtimes,
ReactiveHttpGateway gateway,
ServiceOperationDescriptorScanner scanner,
OperationContextHolder contextHolder) {
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
this.gateway = Objects.requireNonNull(gateway, "reactive http gateway");
this.scanner = Objects.requireNonNull(scanner, "descriptor scanner");
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
}
@Override
public <T> T client(Class<T> serviceType) {
return client(scanner.profileOf(serviceType), serviceType);
}
@Override
public <T> T client(ClientProfileName profileName, Class<T> serviceType) {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(serviceType, "service type");
String key = profileName.value() + '|' + serviceType.getName();
return serviceType.cast(
clients.computeIfAbsent(key, ignored -> build(profileName, serviceType)));
}
private <T> T build(ClientProfileName profileName, Class<T> serviceType) {
List<ServiceOperationDescriptor> descriptors = scanner.scan(serviceType);
if (descriptors.stream().anyMatch(descriptor -> !descriptor.reactive())) {
throw new HttpConfigurationException(
"interface " + serviceType.getName() + " is blocking; use the blocking registry",
HttpFailureMetadata.startup(profileName));
}
try (ClientRuntimeLease lease = runtimes.acquire(profileName)) {
if (!(lease.runtime() instanceof ReactiveClientRuntime)) {
throw new HttpConfigurationException(
"profile " + profileName.value() + " is not configured for the reactive api",
HttpFailureMetadata.startup(profileName));
}
}
// Not WebClientAdapter over the raw WebClient: that reached the network with no target policy,
// credential, admission, deadline, resilience, byte limit, stable error mapping or observation.
T springProxy =
HttpServiceProxyFactory.builderFor(
new ReactiveKernelHttpExchangeAdapter(
gateway, profileName, contextHolder, BLOCK_TIMEOUT))
.build()
.createClient(serviceType);
Map<Method, ServiceOperationDescriptor> byMethod = new LinkedHashMap<>();
descriptors.forEach(descriptor -> byMethod.put(descriptor.method(), descriptor));
InvocationHandler handler =
new ReactiveServiceInvocationHandler(springProxy, profileName, byMethod, contextHolder);
return serviceType.cast(
Proxy.newProxyInstance(
serviceType.getClassLoader(), new Class<?>[] {serviceType}, handler));
}
}
@@ -0,0 +1,211 @@
package dev.caskeleton.adapter.outbound.httpclient.service;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey;
import dev.caskeleton.adapter.outbound.httpclient.api.OperationName;
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
import dev.caskeleton.adapter.outbound.httpclient.api.body.ObjectBody;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.restclient.GenericHttpGateway;
import dev.caskeleton.adapter.outbound.httpclient.restclient.RestClientResponseReader;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.service.invoker.HttpExchangeAdapter;
import org.springframework.web.service.invoker.HttpRequestValues;
/**
* Runs every typed-client call through the platform kernel instead of a raw Spring client.
*
* <p>The typed registries used to hand {@code RestClientAdapter.create(runtime.restClient())} to
* Spring's proxy factory, which meant an {@code @HttpExchange} method reached the network through
* the profile's {@code RestClient} and nothing else. It passed no trusted-target policy, resolved
* no credential, took no admission permit, respected no total deadline, entered no circuit breaker,
* rate limiter or bulkhead, produced no evidence-based retry, applied no request or response byte
* limit, mapped no stable exception, and recorded no logical or attempt observation. The proxy did
* publish an operation descriptor into a thread local, but no code on the execution path read it —
* so the descriptor described a call the platform was not making.
*
* <p>This adapter is the seam that closes that. Spring still owns argument binding and response
* decoding, which is where its annotation model earns its place; the exchange itself is translated
* into an {@link HttpOperation} and handed to {@link GenericHttpGateway}, the same entry the
* generic H2 surface uses. Everything listed above therefore applies to a typed client because it
* is the same code path, not because it was reimplemented alongside.
*
* <p>The descriptor comes from {@link OperationContextHolder}, bound by the invocation handler for
* the duration of the call. A call with no binding is refused rather than executed with invented
* defaults: a typed method whose descriptor the scanner never produced has no declared idempotency,
* and guessing one is exactly the decision this platform exists to stop callers making by accident.
*/
public final class KernelHttpExchangeAdapter implements HttpExchangeAdapter {
private final GenericHttpGateway gateway;
private final ClientProfileName profileName;
private final OperationContextHolder contextHolder;
public KernelHttpExchangeAdapter(
GenericHttpGateway gateway,
ClientProfileName profileName,
OperationContextHolder contextHolder) {
this.gateway = Objects.requireNonNull(gateway, "generic http gateway");
this.profileName = Objects.requireNonNull(profileName, "profile name");
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
}
/**
* Request attributes are not supported.
*
* <p>They are a Spring-side side channel into the underlying client, and the platform's whole
* position is that the underlying client is not reachable from a caller.
*/
@Override
public boolean supportsRequestAttributes() {
return false;
}
@Override
public void exchange(HttpRequestValues values) {
execute(values, ResponseType.empty());
}
@Override
public HttpHeaders exchangeForHeaders(HttpRequestValues values) {
return headersOf(execute(values, ResponseType.empty()));
}
@Override
public <T> T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
return execute(values, responseTypeOf(bodyType)).body();
}
@Override
public ResponseEntity<Void> exchangeForBodilessEntity(HttpRequestValues values) {
HttpCallResult<Void> result = execute(values, ResponseType.empty());
return ResponseEntity.status(statusOf(result)).headers(headersOf(result)).build();
}
@Override
public <T> ResponseEntity<T> exchangeForEntity(
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
HttpCallResult<T> result = execute(values, responseTypeOf(bodyType));
return ResponseEntity.status(statusOf(result)).headers(headersOf(result)).body(result.body());
}
private <T> HttpCallResult<T> execute(HttpRequestValues values, ResponseType<T> responseType) {
ServiceOperationDescriptor descriptor = requireDescriptor();
return gateway.exchange(profileName, operationOf(values, descriptor), responseType);
}
private ServiceOperationDescriptor requireDescriptor() {
return contextHolder
.current()
.filter(binding -> binding.clientName().equals(profileName))
.map(OperationContextHolder.Binding::descriptor)
.orElseThrow(
() ->
new HttpConfigurationException(
"a typed client call for profile "
+ profileName.value()
+ " reached the platform without a registered operation descriptor",
HttpFailureMetadata.startup(profileName)));
}
/**
* Translates Spring's request values into a platform operation.
*
* <p>The URI template is carried through unexpanded. Spring hands over both the template and its
* variables, and keeping them separate is what lets observability tags stay low-cardinality and
* lets the trusted-target policy expand the path itself against the profile's base URL.
*/
private HttpOperation operationOf(
HttpRequestValues values, ServiceOperationDescriptor descriptor) {
String uriTemplate = values.getUriTemplate();
if (uriTemplate == null) {
// A pre-expanded URI would let a typed method choose its own destination, which is what H3
// and its separate policy exist for.
throw new HttpConfigurationException(
"typed operation "
+ descriptor.operationName().value()
+ " must declare a profile-relative uri template rather than an absolute url",
HttpFailureMetadata.startup(profileName));
}
org.springframework.http.HttpMethod method = values.getHttpMethod();
if (method == null) {
throw new HttpConfigurationException(
"typed operation " + descriptor.operationName().value() + " declares no http method",
HttpFailureMetadata.startup(profileName));
}
Map<String, List<String>> headers = new LinkedHashMap<>();
Optional<IdempotencyKey> idempotencyKey = Optional.empty();
String keyHeader = descriptor.idempotencyKeyRequirement().headerName();
// headerSet() rather than entrySet(): Spring 7's HttpHeaders is no longer a MultiValueMap.
for (Map.Entry<String, List<String>> header : values.getHeaders().headerSet()) {
if (descriptor.idempotencyKeyRequirement().required()
&& header.getKey().equalsIgnoreCase(keyHeader)) {
// Lifted out of the headers and onto the operation, so the trusted-target policy renders it
// and the retry decision can see that it was actually sent.
idempotencyKey = Optional.of(new IdempotencyKey(header.getValue().getFirst()));
continue;
}
headers.put(header.getKey(), List.copyOf(new ArrayList<>(header.getValue())));
}
if (descriptor.idempotencyKeyRequirement().required() && idempotencyKey.isEmpty()) {
throw new HttpConfigurationException(
"typed operation "
+ descriptor.operationName().value()
+ " requires an idempotency key but the call supplied no "
+ keyHeader
+ " header",
HttpFailureMetadata.startup(profileName));
}
return new HttpOperation(
descriptor.operationName(),
HttpMethod.valueOf(method.name()),
uriTemplate,
Map.copyOf(values.getUriVariables()),
Map.copyOf(headers),
bodyOf(values),
descriptor.idempotency(),
idempotencyKey,
Optional.empty());
}
private BodySource bodyOf(HttpRequestValues values) {
Object body = values.getBodyValue();
return body == null ? EmptyBody.instance() : ObjectBody.json(body);
}
private <T> ResponseType<T> responseTypeOf(ParameterizedTypeReference<T> bodyType) {
return RestClientResponseReader.responseTypeOf(bodyType);
}
private HttpStatusCode statusOf(HttpCallResult<?> result) {
return HttpStatusCode.valueOf(result.status().value());
}
private HttpHeaders headersOf(HttpCallResult<?> result) {
HttpHeaders headers = new HttpHeaders();
result.headers().forEach((name, valueList) -> valueList.forEach(v -> headers.add(name, v)));
return headers;
}
/** The operation name a call is executing, for tests and diagnostics. */
OperationName currentOperation() {
return requireDescriptor().operationName();
}
}
@@ -0,0 +1,272 @@
package dev.caskeleton.adapter.outbound.httpclient.service;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey;
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
import dev.caskeleton.adapter.outbound.httpclient.api.body.ObjectBody;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.restclient.RestClientResponseReader;
import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveHttpGateway;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.service.invoker.HttpRequestValues;
import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* The reactive twin of {@link KernelHttpExchangeAdapter}.
*
* <p>The reactive typed registry had the same defect as the blocking one, with the same
* consequence: {@code WebClientAdapter.create(runtime.webClient())} handed Spring the profile's raw
* {@code WebClient}, so a reactive typed call reached the network with none of the platform's
* guarantees. It also put a descriptor into the Reactor context that no code on the execution path
* read.
*
* <p>Routing through {@link ReactiveHttpGateway} makes the reactive typed surface the same
* execution path as the reactive generic one. The subscription is where the work happens, so the
* descriptor is captured at assembly time — the thread that assembles a {@code Mono} is not
* necessarily the thread that subscribes to it, and reading a thread local at subscription would
* find nothing.
*/
public final class ReactiveKernelHttpExchangeAdapter implements ReactorHttpExchangeAdapter {
private final ReactiveHttpGateway gateway;
private final ClientProfileName profileName;
private final OperationContextHolder contextHolder;
private final Duration blockTimeout;
public ReactiveKernelHttpExchangeAdapter(
ReactiveHttpGateway gateway,
ClientProfileName profileName,
OperationContextHolder contextHolder,
Duration blockTimeout) {
this.gateway = Objects.requireNonNull(gateway, "reactive http gateway");
this.profileName = Objects.requireNonNull(profileName, "profile name");
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
this.blockTimeout = Objects.requireNonNull(blockTimeout, "block timeout");
}
@Override
public boolean supportsRequestAttributes() {
return false;
}
@Override
public ReactiveAdapterRegistry getReactiveAdapterRegistry() {
return ReactiveAdapterRegistry.getSharedInstance();
}
@Override
public Duration getBlockTimeout() {
return blockTimeout;
}
@Override
public Mono<Void> exchangeForMono(HttpRequestValues values) {
return execute(values, ResponseType.empty()).then();
}
@Override
public Mono<HttpHeaders> exchangeForHeadersMono(HttpRequestValues values) {
return execute(values, ResponseType.empty()).map(this::headersOf);
}
@Override
public <T> Mono<T> exchangeForBodyMono(
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
return execute(values, RestClientResponseReader.responseTypeOf(bodyType))
.mapNotNull(HttpCallResult::body);
}
/**
* A {@code Flux} return type is served by decoding the whole body and then emitting its elements.
*
* <p>It is deliberately not a streaming subscription. A streamed response cannot be bounded by
* the profile's decoded-byte limit or retried on evidence, and a typed method that looks like an
* ordinary call must not quietly acquire different safety properties because its return type is a
* {@code Flux}. Genuine streaming has its own gateway, with its own contract.
*/
@Override
public <T> Flux<T> exchangeForBodyFlux(
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
return execute(values, RestClientResponseReader.<T>responseTypeOf(bodyType))
.flatMapMany(result -> elementsOf(result.body()));
}
@Override
public Mono<ResponseEntity<Void>> exchangeForBodilessEntityMono(HttpRequestValues values) {
return execute(values, ResponseType.empty())
.map(result -> ResponseEntity.status(statusOf(result)).headers(headersOf(result)).build());
}
@Override
public <T> Mono<ResponseEntity<T>> exchangeForEntityMono(
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
return execute(values, RestClientResponseReader.responseTypeOf(bodyType))
.map(
result ->
ResponseEntity.status(statusOf(result))
.headers(headersOf(result))
.body(result.body()));
}
@Override
public <T> Mono<ResponseEntity<Flux<T>>> exchangeForEntityFlux(
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
return execute(values, RestClientResponseReader.<T>responseTypeOf(bodyType))
.map(
result ->
ResponseEntity.status(statusOf(result))
.headers(headersOf(result))
.body(elementsOf(result.body())));
}
/** Blocking-surface members of the interface are unreachable for a reactive typed client. */
@Override
public void exchange(HttpRequestValues values) {
exchangeForMono(values).block(blockTimeout);
}
@Override
public HttpHeaders exchangeForHeaders(HttpRequestValues values) {
return exchangeForHeadersMono(values).block(blockTimeout);
}
@Override
public <T> T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
return exchangeForBodyMono(values, bodyType).block(blockTimeout);
}
@Override
public ResponseEntity<Void> exchangeForBodilessEntity(HttpRequestValues values) {
return exchangeForBodilessEntityMono(values).block(blockTimeout);
}
@Override
public <T> ResponseEntity<T> exchangeForEntity(
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
return exchangeForEntityMono(values, bodyType).block(blockTimeout);
}
/**
* Resolves the descriptor now and defers the call.
*
* <p>The descriptor lookup is eager on purpose: it reads a thread local the invocation handler
* bound around the assembly of this {@code Mono}, and by the time anything subscribes that
* binding is gone.
*/
private <T> Mono<HttpCallResult<T>> execute(
HttpRequestValues values, ResponseType<T> responseType) {
ServiceOperationDescriptor descriptor = requireDescriptor();
HttpOperation operation = operationOf(values, descriptor);
return Mono.defer(() -> gateway.exchange(profileName, operation, responseType));
}
private ServiceOperationDescriptor requireDescriptor() {
return contextHolder
.current()
.filter(binding -> binding.clientName().equals(profileName))
.map(OperationContextHolder.Binding::descriptor)
.orElseThrow(
() ->
new HttpConfigurationException(
"a reactive typed client call for profile "
+ profileName.value()
+ " reached the platform without a registered operation descriptor",
HttpFailureMetadata.startup(profileName)));
}
private HttpOperation operationOf(
HttpRequestValues values, ServiceOperationDescriptor descriptor) {
String uriTemplate = values.getUriTemplate();
if (uriTemplate == null) {
throw new HttpConfigurationException(
"typed operation "
+ descriptor.operationName().value()
+ " must declare a profile-relative uri template rather than an absolute url",
HttpFailureMetadata.startup(profileName));
}
org.springframework.http.HttpMethod method = values.getHttpMethod();
if (method == null) {
throw new HttpConfigurationException(
"typed operation " + descriptor.operationName().value() + " declares no http method",
HttpFailureMetadata.startup(profileName));
}
Map<String, List<String>> headers = new LinkedHashMap<>();
Optional<IdempotencyKey> idempotencyKey = Optional.empty();
String keyHeader = descriptor.idempotencyKeyRequirement().headerName();
for (Map.Entry<String, List<String>> header : values.getHeaders().headerSet()) {
if (descriptor.idempotencyKeyRequirement().required()
&& header.getKey().equalsIgnoreCase(keyHeader)) {
idempotencyKey = Optional.of(new IdempotencyKey(header.getValue().getFirst()));
continue;
}
headers.put(header.getKey(), List.copyOf(new ArrayList<>(header.getValue())));
}
if (descriptor.idempotencyKeyRequirement().required() && idempotencyKey.isEmpty()) {
throw new HttpConfigurationException(
"typed operation "
+ descriptor.operationName().value()
+ " requires an idempotency key but the call supplied no "
+ keyHeader
+ " header",
HttpFailureMetadata.startup(profileName));
}
return new HttpOperation(
descriptor.operationName(),
HttpMethod.valueOf(method.name()),
uriTemplate,
Map.copyOf(values.getUriVariables()),
Map.copyOf(headers),
bodyOf(values),
descriptor.idempotency(),
idempotencyKey,
Optional.empty());
}
private BodySource bodyOf(HttpRequestValues values) {
Object body = values.getBodyValue();
return body == null ? EmptyBody.instance() : ObjectBody.json(body);
}
@SuppressWarnings("unchecked")
private <T> Flux<T> elementsOf(Object body) {
if (body == null) {
return Flux.empty();
}
if (body instanceof Collection<?> elements) {
return Flux.fromIterable((Collection<T>) elements);
}
return Flux.just((T) body);
}
private HttpStatusCode statusOf(HttpCallResult<?> result) {
return HttpStatusCode.valueOf(result.status().value());
}
private HttpHeaders headersOf(HttpCallResult<?> result) {
HttpHeaders headers = new HttpHeaders();
result.headers().forEach((name, valueList) -> valueList.forEach(v -> headers.add(name, v)));
return headers;
}
}
@@ -0,0 +1,79 @@
package dev.caskeleton.adapter.outbound.httpclient.service;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.Objects;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* Attaches the operation descriptor to the Reactor Context of the returned publisher (design §26.3
* step 7).
*
* <p>A non-reactive return value is rejected rather than adapted: adapting it would mean blocking
* somewhere, and design §18.2 forbids that on this path.
*/
public final class ReactiveServiceInvocationHandler implements InvocationHandler {
private final Object delegate;
private final ClientProfileName profileName;
private final Map<Method, ServiceOperationDescriptor> descriptors;
private final OperationContextHolder contextHolder;
public ReactiveServiceInvocationHandler(
Object delegate,
ClientProfileName profileName,
Map<Method, ServiceOperationDescriptor> descriptors,
OperationContextHolder contextHolder) {
this.delegate = Objects.requireNonNull(delegate, "delegate proxy");
this.profileName = Objects.requireNonNull(profileName, "profile name");
this.descriptors = Map.copyOf(Objects.requireNonNull(descriptors, "descriptors"));
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
}
/**
* Binds the descriptor for the duration of assembly, then attaches it to the returned publisher.
*
* <p>Two bindings for two readers. The Reactor context reaches operators that run at subscription
* time; the thread-local reaches the exchange adapter, which translates the call into a platform
* operation while this method is still on the stack. Only the second one existed as a consumer
* before — and nothing read it, because the raw {@code WebClient} was doing the work.
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
ServiceOperationDescriptor descriptor = descriptors.get(method);
Object result;
if (descriptor == null) {
result = invokeDelegate(method, args);
} else {
try (AutoCloseable binding = contextHolder.bind(profileName, descriptor)) {
result = invokeDelegate(method, args);
}
}
if (descriptor == null) {
return result;
}
if (result instanceof Mono<?> mono) {
return mono.contextWrite(context -> context.put(ReactiveOperationContext.KEY, descriptor));
}
if (result instanceof Flux<?> flux) {
return flux.contextWrite(context -> context.put(ReactiveOperationContext.KEY, descriptor));
}
throw new HttpConfigurationException(
"reactive service method must return Mono or Flux",
HttpFailureMetadata.startup(profileName));
}
private Object invokeDelegate(Method method, Object[] args) throws Throwable {
try {
return method.invoke(delegate, args);
} catch (InvocationTargetException invocationFailure) {
throw invocationFailure.getCause();
}
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.httpclient.transport;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import org.springframework.http.client.ClientHttpRequestFactory;
/**
* Blocking transport SPI (design §13.1).
*
* <p>The provider returns a Spring {@link ClientHttpRequestFactory} and never the native engine
* client: design D-04 and §9.5 make native access an internal concern of this package.
*
* <p>Resources belong to a <em>generation</em>, not to a profile. Keying them by profile name meant
* a rotation overwrote the map entry with the new generation's client, and the old generation's
* closer — which runs after its drain completes — then closed the <em>replacement</em> while the
* connections it was supposed to release stayed open. Every rotation leaked one pool and broke the
* live one.
*/
public interface BlockingTransportProvider {
TransportId id();
BlockingTransportCapabilities capabilities();
ClientHttpRequestFactory create(
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener);
TransportFailureClassifier failureClassifier();
/** Releases the engine resources created for exactly this profile generation. */
void close(ClientProfile profile, RuntimeGeneration generation);
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.httpclient.transport;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import org.springframework.http.client.reactive.ClientHttpConnector;
/**
* Reactive transport SPI (design §13.2).
*
* <p>Returns a Spring {@link ClientHttpConnector}; the native reactive client stays internal.
*
* <p>Resources are owned per generation, for the reason spelled out on {@link
* BlockingTransportProvider}: a profile-keyed map lets a rotation's closer release the generation
* that replaced it.
*/
public interface ReactiveTransportProvider {
TransportId id();
ReactiveTransportCapabilities capabilities();
ClientHttpConnector create(
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener);
TransportFailureClassifier failureClassifier();
void close(ClientProfile profile, RuntimeGeneration generation);
}
@@ -0,0 +1,90 @@
package dev.caskeleton.adapter.outbound.httpclient.transport;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol;
import java.util.ArrayList;
import java.util.List;
/**
* Startup guard that fails when a transport is weaker than the profile it was selected for (design
* §13.3, Task 8).
*
* <p>Messages name profile settings and capability names only — never a URL, address, or secret.
*/
public final class TransportCapabilityValidator {
public void validate(ClientProfile profile, BlockingTransportCapabilities capabilities) {
List<String> missing = new ArrayList<>();
collectProtocolGaps(profile, capabilities.protocols(), missing);
if (profile.pool().requiresRoutePool() && !capabilities.routeScopedPool()) {
missing.add("route pool");
}
if (profile.pool().requiresBoundedPendingQueue()
&& !capabilities.boundedPendingAcquireQueue()) {
missing.add("bounded pending acquire queue");
}
if (profile.proxy().enabled() && !capabilities.proxySupport()) {
missing.add("proxy");
}
if (profile.tls().mutualTls() && !capabilities.mutualTls()) {
missing.add("mutual TLS");
}
if (profile.mode() == ClientMode.DYNAMIC && !capabilities.dynamicTargetStable()) {
missing.add("validated DNS pinning for dynamic targets");
}
if (profile.mode() == ClientMode.DYNAMIC && !capabilities.validatedDnsPinning()) {
// `validatedDnsPinning` was declared on every capability record and read by nothing. It is
// the capability that decides whether the SSRF address validation survives to the socket, so
// a transport that does not have it cannot serve a dynamic target no matter what its
// `dynamicTargetStable` flag says — the two were being conflated.
missing.add("call-scoped validated DNS pinning");
}
reject(profile, missing);
}
public void validate(ClientProfile profile, ReactiveTransportCapabilities capabilities) {
List<String> missing = new ArrayList<>();
collectProtocolGaps(profile, capabilities.protocols(), missing);
if (profile.pool().requiresRoutePool() && !capabilities.routeScopedPool()) {
missing.add("route pool");
}
if (profile.pool().requiresBoundedPendingQueue()
&& !capabilities.boundedPendingAcquireQueue()) {
missing.add("bounded pending acquire queue");
}
if (profile.proxy().enabled() && !capabilities.proxySupport()) {
missing.add("proxy");
}
if (profile.tls().mutualTls() && !capabilities.mutualTls()) {
missing.add("mutual TLS");
}
if (profile.mode() == ClientMode.DYNAMIC && !capabilities.dynamicTargetStable()) {
missing.add("validated DNS pinning for dynamic targets");
}
reject(profile, missing);
}
private void collectProtocolGaps(
ClientProfile profile, java.util.Set<HttpProtocol> supported, List<String> missing) {
for (HttpProtocol protocol : profile.protocols()) {
if (!supported.contains(protocol)) {
missing.add(protocol.name());
}
}
}
private void reject(ClientProfile profile, List<String> missing) {
if (missing.isEmpty()) {
return;
}
throw new HttpConfigurationException(
"transport capability is weaker than profile "
+ profile.name().value()
+ " requires: "
+ String.join(", ", missing),
HttpFailureMetadata.startup(profile.name()));
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.httpclient.transport;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import java.util.Objects;
/**
* Identifies the engine resources belonging to one profile generation.
*
* <p>Transport providers used to key their pools and clients by profile name alone. A rotation
* creates generation N+1 while generation N is still draining, so the new entry overwrote the old
* one; when N's drain finished and its closer ran, it looked up the profile name and closed N+1 —
* the generation that was serving traffic — while N's sockets stayed open. Each rotation therefore
* leaked a pool and broke the live client, which is the opposite of what draining is for.
*
* @param profileName the profile the resources serve
* @param generation the generation that owns them
*/
public record TransportResourceKey(ClientProfileName profileName, RuntimeGeneration generation) {
public TransportResourceKey {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(generation, "runtime generation");
}
}
@@ -0,0 +1,330 @@
package dev.caskeleton.adapter.outbound.httpclient.webclient;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAmbiguousExecutionException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
import dev.caskeleton.adapter.outbound.httpclient.observation.LogicalCallObservation;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
import dev.caskeleton.adapter.outbound.httpclient.resilience.Deadline;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ReactiveLogicalCall;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ReactiveRetryCoordinator;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryAllowed;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryContext;
import dev.caskeleton.adapter.outbound.httpclient.restclient.StatusHandlingPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import java.time.Duration;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import reactor.core.publisher.Mono;
/**
* Reactive H2 gateway (design §9.3, §26.2).
*
* <p>Runtime leases are acquired and released with {@code Mono.usingWhen} so a generation cannot be
* closed underneath an in-flight subscription, and operation metadata travels in the Reactor
* Context rather than a ThreadLocal — which would be wrong the moment the pipeline switches
* threads.
*/
public final class DefaultReactiveHttpGateway implements ReactiveHttpGateway {
private final ClientRuntimeRegistry runtimes;
private final ReactiveAttemptExecutor executor;
public DefaultReactiveHttpGateway(
ClientRuntimeRegistry runtimes, ReactiveAttemptExecutor executor) {
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
this.executor = Objects.requireNonNull(executor, "reactive attempt executor");
}
@Override
public <T> Mono<HttpCallResult<T>> exchange(
ClientProfileName profileName, HttpOperation operation, ResponseType<T> responseType) {
return exchange(
profileName,
operation,
Optional.empty(),
responseType,
StatusHandlingPolicy.THROW_ON_ERROR);
}
public <T> Mono<HttpCallResult<T>> exchange(
ClientProfileName profileName,
HttpOperation operation,
Optional<ReactiveBodySource> reactiveBody,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy) {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(operation, "operation");
Objects.requireNonNull(responseType, "response type");
return Mono.usingWhen(
Mono.fromSupplier(() -> runtimes.acquire(profileName)),
lease -> execute(lease, operation, reactiveBody, responseType, statusHandlingPolicy),
lease -> Mono.fromRunnable(lease::close),
(lease, failure) -> Mono.fromRunnable(lease::close),
lease -> Mono.fromRunnable(lease::close));
}
private <T> Mono<HttpCallResult<T>> execute(
ClientRuntimeLease lease,
HttpOperation operation,
Optional<ReactiveBodySource> reactiveBody,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy) {
if (!(lease.runtime() instanceof ReactiveClientRuntime runtime)) {
return Mono.error(
new IllegalStateException(
"profile "
+ lease.runtime().name().value()
+ " is not configured for the reactive api"));
}
HttpFailureMetadata metadata =
HttpFailureMetadata.validation(
runtime.name(),
operation.operationName(),
operation.method(),
operation.uriTemplate(),
operation.body().replayability());
return Mono.usingWhen(
Mono.fromSupplier(() -> runtime.admissionLimiter().admit(metadata)),
admission ->
runCall(runtime, operation, reactiveBody, responseType, statusHandlingPolicy, metadata),
admission -> Mono.fromRunnable(() -> closeQuietly(admission)),
(admission, failure) -> Mono.fromRunnable(() -> closeQuietly(admission)),
admission -> Mono.fromRunnable(() -> closeQuietly(admission)));
}
private <T> Mono<HttpCallResult<T>> runCall(
ReactiveClientRuntime runtime,
HttpOperation operation,
Optional<ReactiveBodySource> reactiveBody,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy,
HttpFailureMetadata metadata) {
PreparedOperation prepared = runtime.targetPolicy().prepare(operation);
Deadline deadline =
runtime
.support()
.deadlineCalculator()
.effective(
operation.deadline(),
runtime.profile().timeout().totalCall(),
runtime.support().clock());
LogicalCallObservation observation =
LogicalCallObservation.start(
runtime.support().meterRegistry(),
runtime.support().tagPolicy(),
runtime.name(),
operation.operationName(),
operation.method().name(),
operation.uriTemplate());
ReactiveRetryCoordinator coordinator =
new ReactiveRetryCoordinator(
runtime.support().eligibilityEngine(),
runtime.newBackoff(),
runtime.retryBudget(),
runtime.support().clock());
return runtime
.credentialProvider()
.resolve(credentialRequest(runtime, prepared, false))
.defaultIfEmpty(RequestCredentials.none())
.flatMap(
credentials ->
coordinator.execute(
new ReactiveGatewayCall<>(
runtime,
prepared,
reactiveBody,
responseType,
statusHandlingPolicy,
deadline,
metadata,
observation,
credentials)))
.doOnSuccess(
result ->
observation.stop(
Optional.ofNullable(result).map(HttpCallResult::status),
result == null ? ExecutionEvidence.NOT_SENT : result.evidence(),
"success"))
.doOnError(
failure -> {
if (failure instanceof HttpAmbiguousExecutionException ambiguous) {
observation.recordAmbiguous();
observation.stop(Optional.empty(), ambiguous.metadata().evidence(), "ambiguous");
} else if (failure instanceof HttpClientException stable) {
observation.stop(
stable.metadata().status(), stable.metadata().evidence(), "failure");
}
})
.contextWrite(
context ->
context.put(
ReactiveOperationContextKeys.OPERATION_NAME,
operation.operationName().value()));
}
private void closeQuietly(AutoCloseable closeable) {
try {
closeable.close();
} catch (Exception ignored) {
// Releasing an admission permit cannot fail meaningfully.
}
}
private CredentialRequest credentialRequest(
ReactiveClientRuntime runtime, PreparedOperation prepared, boolean forceRefresh) {
return new CredentialRequest(
runtime.name(),
prepared.operation().operationName(),
runtime.profile().authentication(),
prepared.target().uri(),
Optional.empty(),
runtime.profile().tls().keyMaterialReference(),
forceRefresh);
}
/** Reactor Context keys used to carry operation metadata without a ThreadLocal. */
public static final class ReactiveOperationContextKeys {
public static final String OPERATION_NAME = "httpclient.operationName";
private ReactiveOperationContextKeys() {}
}
private final class ReactiveGatewayCall<T> implements ReactiveLogicalCall<T> {
private final ReactiveClientRuntime runtime;
private final PreparedOperation prepared;
private final Optional<ReactiveBodySource> reactiveBody;
private final ResponseType<T> responseType;
private final StatusHandlingPolicy statusHandlingPolicy;
private final Deadline deadline;
private final HttpFailureMetadata metadata;
private final LogicalCallObservation observation;
private final RequestCredentials credentials;
private final Instant startedAt;
private ReactiveGatewayCall(
ReactiveClientRuntime runtime,
PreparedOperation prepared,
Optional<ReactiveBodySource> reactiveBody,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy,
Deadline deadline,
HttpFailureMetadata metadata,
LogicalCallObservation observation,
RequestCredentials credentials) {
this.runtime = runtime;
this.prepared = prepared;
this.reactiveBody = reactiveBody;
this.responseType = responseType;
this.statusHandlingPolicy = statusHandlingPolicy;
this.deadline = deadline;
this.metadata = metadata;
this.observation = observation;
this.credentials = credentials;
this.startedAt = runtime.support().clock().instant();
}
@Override
public Mono<AttemptOutcome<T>> attempt(int attemptNumber) {
if (!runtime.acceptsNewAttempts() && attemptNumber > 1) {
return Mono.error(
new HttpRateLimitRejectedException(
"runtime is draining and refuses new attempts", metadata));
}
return executor.execute(
runtime,
prepared,
reactiveBody,
responseType,
statusHandlingPolicy,
credentials,
attemptNumber,
startedAt,
metadata.withAttempt(attemptNumber));
}
@Override
public RetryContext context(AttemptOutcome<T> outcome, int attemptNumber) {
return new RetryContext(
prepared.operation().idempotency(),
prepared.operation().idempotencyKey(),
prepared.idempotencyKeySent(),
reactiveBody
.map(ReactiveBodySource::replayability)
.orElseGet(() -> prepared.operation().body().replayability()),
outcome.evidence(),
outcome.failureCategory(),
outcome.status(),
outcome.retryAfter(),
attemptNumber,
runtime.profile().retry().maxAttempts(),
outcome.firstByteDelivered(),
deadline.remaining(runtime.support().clock()),
runtime.support().minimumAttemptBudget(),
runtime.retryBudget().snapshot(),
runtime.support().transientServerErrorStatuses(),
false,
!runtime.acceptsNewAttempts());
}
@Override
public Deadline deadline() {
return deadline;
}
@Override
public Mono<HttpCallResult<T>> finish(AttemptOutcome<T> outcome, int attemptNumber) {
return outcome
.result()
.map(
value ->
Mono.just(
new HttpCallResult<>(
value.status(),
value.headers(),
value.body(),
attemptNumber,
Duration.between(startedAt, runtime.support().clock().instant()),
value.evidence(),
value.remoteProblem())))
.orElseGet(() -> Mono.error(outcome.failure().orElseThrow()));
}
@Override
public HttpClientException ambiguous(AttemptOutcome<T> outcome, int attemptNumber) {
return new HttpAmbiguousExecutionException(
"request was sent but the remote outcome is unknown",
metadata.withAttempt(attemptNumber).withEvidence(ExecutionEvidence.SENT_NO_RESPONSE));
}
@Override
public HttpClientException retryExhausted(int attemptNumber) {
observation.recordRetryExhausted();
return new HttpRateLimitRejectedException(
"retry budget for this upstream is exhausted", metadata.withAttempt(attemptNumber));
}
@Override
public void onRetryGranted(RetryAllowed allowed, int attemptNumber) {
observation.recordRetry(allowed.reason());
}
}
}
@@ -0,0 +1,183 @@
package dev.caskeleton.adapter.outbound.httpclient.webclient;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ClassResponseType;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
/**
* Bounded SSE client (design §23.4).
*
* <p>Three budgets are kept apart deliberately: a setup deadline for establishing the stream, an
* idle timeout for silence once it is open, and an optional maximum lifetime. Reconnects consume
* the retry budget like any other physical attempt, and cancelling the subscription stops both the
* stream and any pending reconnect.
*/
public final class DefaultReactiveSseGateway implements ReactiveSseGateway {
private final ClientRuntimeRegistry runtimes;
public DefaultReactiveSseGateway(ClientRuntimeRegistry runtimes) {
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
}
@Override
public <T> Flux<ServerSentEvent<T>> connect(
ClientProfileName profileName, SseOperation operation, ResponseType<T> eventType) {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(operation, "sse operation");
Objects.requireNonNull(eventType, "event type");
AtomicReference<String> lastEventId = new AtomicReference<>();
return Flux.usingWhen(
Mono.fromSupplier(() -> runtimes.acquire(profileName)),
lease -> stream(lease, operation, eventType, lastEventId),
lease -> Mono.fromRunnable(lease::close),
(lease, failure) -> Mono.fromRunnable(lease::close),
lease -> Mono.fromRunnable(lease::close));
}
private <T> Flux<ServerSentEvent<T>> stream(
ClientRuntimeLease lease,
SseOperation operation,
ResponseType<T> eventType,
AtomicReference<String> lastEventId) {
if (!(lease.runtime() instanceof ReactiveClientRuntime runtime)) {
return Flux.error(
new IllegalStateException(
"profile "
+ lease.runtime().name().value()
+ " is not configured for the reactive api"));
}
HttpFailureMetadata metadata =
HttpFailureMetadata.validation(
runtime.name(),
operation.operationName(),
dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod.GET,
operation.uriTemplate(),
BodyReplayability.REPLAYABLE);
Flux<ServerSentEvent<T>> events =
open(runtime, operation, eventType, lastEventId)
.timeout(
operation.streamingIdleTimeout(),
Flux.error(new SseIdleTimeoutException(operation.operationName(), metadata)))
.doOnNext(event -> rememberEventId(event, lastEventId));
Flux<ServerSentEvent<T>> bounded =
operation.maxStreamDuration().map(events::take).orElse(events);
return operation.reconnectPolicy().enabled()
? bounded.retryWhen(reconnectSpec(runtime, operation))
: bounded;
}
private <T> Flux<ServerSentEvent<T>> open(
ReactiveClientRuntime runtime,
SseOperation operation,
ResponseType<T> eventType,
AtomicReference<String> lastEventId) {
// Through the profile's target policy, not around it. This used to expand the template and go
// straight to the WebClient, so an SSE subscription reached its destination with no
// relative-only
// check, no host or port allowlist, no header policy and no credential — the one long-lived
// connection type in the platform was also the least governed. The policy rejects an absolute
// template and an off-allowlist origin exactly as it does for an ordinary call.
HttpOperation subscribeOperation =
HttpOperation.get(
operation.operationName(), operation.uriTemplate(), operation.uriVariables());
PreparedOperation prepared = runtime.targetPolicy().prepare(subscribeOperation);
java.net.URI uri = prepared.target().uri();
RequestCredentials credentials =
runtime
.credentialProvider()
.resolve(
new CredentialRequest(
runtime.name(),
operation.operationName(),
runtime.profile().authentication(),
uri,
java.util.Optional.empty(),
runtime.profile().tls().keyMaterialReference(),
false))
.block(operation.setupDeadline());
var request = runtime.webClient().get().uri(uri).accept(MediaType.TEXT_EVENT_STREAM);
for (Map.Entry<String, List<String>> header : prepared.headers().entrySet()) {
for (String value : header.getValue()) {
request = request.header(header.getKey(), value);
}
}
if (credentials != null) {
for (Map.Entry<String, String> credential : credentials.headers().entrySet()) {
request = request.header(credential.getKey(), credential.getValue());
}
}
if (operation.reconnectPolicy().sendLastEventId() && lastEventId.get() != null) {
request = request.header("Last-Event-ID", lastEventId.get());
}
return request
.retrieve()
.bodyToFlux(serverSentEventType(eventType))
.timeout(
operation.setupDeadline(),
Flux.error(
new SseIdleTimeoutException(
operation.operationName(), HttpFailureMetadata.startup(runtime.name()))))
.onErrorResume(
java.util.concurrent.TimeoutException.class,
failure ->
Flux.error(
new SseIdleTimeoutException(
operation.operationName(), HttpFailureMetadata.startup(runtime.name()))));
}
private <T> ParameterizedTypeReference<ServerSentEvent<T>> serverSentEventType(
ResponseType<T> eventType) {
java.lang.reflect.Type eventElementType =
eventType instanceof ClassResponseType<T> classType
? classType.rawType()
: eventType.type();
java.lang.reflect.Type sseType =
org.springframework.core.ResolvableType.forClassWithGenerics(
ServerSentEvent.class,
org.springframework.core.ResolvableType.forType(eventElementType))
.getType();
return ParameterizedTypeReference.<ServerSentEvent<T>>forType(sseType);
}
private <T> void rememberEventId(ServerSentEvent<T> event, AtomicReference<String> lastEventId) {
if (event.id() != null) {
lastEventId.set(event.id());
}
}
private Retry reconnectSpec(ReactiveClientRuntime runtime, SseOperation operation) {
SseReconnectPolicy policy = operation.reconnectPolicy();
Duration backoff =
policy.reconnectBackoff().isZero() ? Duration.ofMillis(50) : policy.reconnectBackoff();
return Retry.fixedDelay(policy.maxReconnects(), backoff)
.filter(failure -> runtime.retryBudget().tryConsume())
.transientErrors(true);
}
}
@@ -0,0 +1,184 @@
package dev.caskeleton.adapter.outbound.httpclient.webclient;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingAttemptExecutor;
import dev.caskeleton.adapter.outbound.httpclient.restclient.RestClientResponseReader;
import dev.caskeleton.adapter.outbound.httpclient.restclient.StatusHandlingPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
/**
* Executes one physical reactive attempt (design §26.2).
*
* <p>Result and error mapping are shared with the blocking path so both produce identical stable
* exceptions and metadata. Discarded buffers are released explicitly: a cancelled or errored
* reactive pipeline drops elements silently, and a dropped {@code DataBuffer} is leaked memory.
*/
public final class ReactiveAttemptExecutor {
private final WebClientBodyWriter bodyWriter = new WebClientBodyWriter();
private final WebClientResponseMapper responseMapper = new WebClientResponseMapper();
public <T> Mono<AttemptOutcome<T>> execute(
ReactiveClientRuntime runtime,
PreparedOperation prepared,
Optional<ReactiveBodySource> reactiveBody,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy,
RequestCredentials credentials,
int attemptNumber,
Instant startedAt,
HttpFailureMetadata baseMetadata) {
Objects.requireNonNull(runtime, "runtime");
return Mono.defer(
() -> {
// Query-parameter credentials are applied here, as they are on the blocking path.
// An API_KEY_QUERY profile resolved its credential, the reactive executor read only
// the header map, and the request went out with the key missing — an authentication
// mechanism that worked on one API and silently did not on the other.
WebClient.RequestBodySpec spec =
runtime
.webClient()
.method(
org.springframework.http.HttpMethod.valueOf(
prepared.operation().method().name()))
.uri(withCredentialQuery(prepared.target().uri(), credentials));
headers(prepared, credentials)
.forEach((name, values) -> spec.header(name, values.toArray(String[]::new)));
WebClient.RequestHeadersSpec<?> request =
bodyWriter.write(
spec,
prepared.operation().body(),
reactiveBody,
runtime.bodyLimitPolicy(),
baseMetadata);
return request.exchangeToMono(
response ->
responseMapper.readBounded(
response, Math.toIntExact(prepared.maxResponseWireBytes())));
})
.map(
response ->
mapResponse(
runtime,
responseType,
statusHandlingPolicy,
attemptNumber,
startedAt,
baseMetadata,
response))
.onErrorResume(failure -> Mono.just(mapFailure(runtime, baseMetadata, failure)))
.doOnDiscard(DataBuffer.class, DataBufferUtils::release);
}
private <T> AttemptOutcome<T> mapResponse(
ReactiveClientRuntime runtime,
ResponseType<T> responseType,
StatusHandlingPolicy statusHandlingPolicy,
int attemptNumber,
Instant startedAt,
HttpFailureMetadata baseMetadata,
RestClientResponseReader.RawResponse response) {
Duration elapsed = Duration.between(startedAt, runtime.support().clock().instant());
try {
HttpCallResult<T> result =
runtime
.support()
.responseMapper()
.map(
response,
responseType,
runtime.profile().response(),
statusHandlingPolicy,
attemptNumber,
elapsed,
baseMetadata);
return AttemptOutcome.succeeded(result);
} catch (HttpRemoteErrorException remoteError) {
return AttemptOutcome.failed(
remoteError,
FailureCategory.REMOTE_STATUS,
BlockingAttemptExecutor.retryAfter(response),
false);
} catch (HttpClientException stable) {
return AttemptOutcome.failed(stable, categoryOf(stable), Optional.empty(), false);
}
}
private <T> AttemptOutcome<T> mapFailure(
ReactiveClientRuntime runtime, HttpFailureMetadata baseMetadata, Throwable failure) {
if (failure instanceof HttpClientException stable) {
return AttemptOutcome.failed(stable, categoryOf(stable), Optional.empty(), false);
}
TransportFailure classified =
runtime.failureClassifier().classify(failure, baseMetadata.stage());
HttpFailureMetadata metadata =
baseMetadata.withEvidence(classified.evidence()).withStage(classified.stage());
HttpClientException mapped =
runtime.support().exceptionMapper().map(classified, metadata, failure);
return AttemptOutcome.failed(mapped, classified.category(), Optional.empty(), false);
}
private Map<String, List<String>> headers(
PreparedOperation prepared, RequestCredentials credentials) {
Map<String, List<String>> merged = new LinkedHashMap<>(prepared.headers());
credentials.headers().forEach((name, value) -> merged.put(name, List.of(value)));
return merged;
}
private FailureCategory categoryOf(HttpClientException failure) {
return switch (failure.getClass().getSimpleName()) {
case "HttpDnsException" -> FailureCategory.DNS;
case "HttpPoolAcquireTimeoutException" -> FailureCategory.POOL_ACQUIRE_TIMEOUT;
case "HttpConnectException" -> FailureCategory.CONNECT;
case "HttpProxyException" -> FailureCategory.PROXY;
case "HttpTlsException" -> FailureCategory.TLS_PERMANENT;
case "HttpRequestWriteException" -> FailureCategory.REQUEST_WRITE;
case "HttpResponseTimeoutException" -> FailureCategory.RESPONSE_TIMEOUT;
case "HttpResponseTruncatedException" -> FailureCategory.RESPONSE_TRUNCATED;
case "HttpResponseTooLargeException" -> FailureCategory.RESPONSE_TOO_LARGE;
case "HttpSerializationException" -> FailureCategory.SERIALIZATION;
case "HttpTargetRejectedException" -> FailureCategory.TARGET_REJECTED;
case "HttpRedirectRejectedException" -> FailureCategory.REDIRECT_REJECTED;
case "HttpAuthenticationException" -> FailureCategory.AUTHENTICATION;
case "HttpDeadlineExceededException" -> FailureCategory.DEADLINE_EXCEEDED;
case "HttpConfigurationException" -> FailureCategory.CONFIGURATION;
default -> FailureCategory.UNKNOWN;
};
}
/**
* Appends query-parameter credentials to the target.
*
* <p>The blocking executor has always done this; the reactive one read only the header map, so an
* {@code API_KEY_QUERY} profile authenticated on one API surface and not on the other.
*/
private java.net.URI withCredentialQuery(java.net.URI uri, RequestCredentials credentials) {
if (credentials.queryParameters().isEmpty()) {
return uri;
}
org.springframework.web.util.UriComponentsBuilder builder =
org.springframework.web.util.UriComponentsBuilder.fromUri(uri);
credentials.queryParameters().forEach(builder::queryParam);
return builder.build(true).toUri();
}
}
@@ -0,0 +1,111 @@
package dev.caskeleton.adapter.outbound.httpclient.webclient;
import dev.caskeleton.adapter.outbound.httpclient.auth.ReactiveRequestCredentialProvider;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy;
import dev.caskeleton.adapter.outbound.httpclient.resilience.LogicalAdmissionLimiter;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget;
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingExecutionSupport;
import dev.caskeleton.adapter.outbound.httpclient.security.BodyLimitPolicy;
import dev.caskeleton.adapter.outbound.httpclient.security.TrustedTargetPolicy;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
import java.util.Objects;
import java.util.function.Supplier;
import org.springframework.web.reactive.function.client.WebClient;
/**
* One immutable reactive generation (design §7.2, §26.2).
*
* <p>Reactive runtimes deliberately do not expose an attempt bulkhead built on a thread pool:
* design §18.2 requires semaphore-style concurrency here, because wrapping an event loop in a
* thread pool destroys the property that makes it useful.
*/
public final class ReactiveClientRuntime extends ClientRuntime {
private final WebClient webClient;
private final TransportId transportId;
private final TransportFailureClassifier failureClassifier;
private final TrustedTargetPolicy targetPolicy;
private final BodyLimitPolicy bodyLimitPolicy;
private final LogicalAdmissionLimiter admissionLimiter;
private final RetryBudget retryBudget;
private final Supplier<BackoffStrategy> backoffFactory;
private final ReactiveRequestCredentialProvider credentialProvider;
private final BlockingExecutionSupport support;
public ReactiveClientRuntime(
ClientProfile profile,
RuntimeGeneration generation,
Runnable resourceCloser,
WebClient webClient,
TransportId transportId,
TransportFailureClassifier failureClassifier,
LogicalAdmissionLimiter admissionLimiter,
RetryBudget retryBudget,
Supplier<BackoffStrategy> backoffFactory,
ReactiveRequestCredentialProvider credentialProvider,
BlockingExecutionSupport support) {
super(profile, generation, resourceCloser);
this.webClient = Objects.requireNonNull(webClient, "web client");
this.transportId = Objects.requireNonNull(transportId, "transport id");
this.failureClassifier = Objects.requireNonNull(failureClassifier, "failure classifier");
this.admissionLimiter = Objects.requireNonNull(admissionLimiter, "admission limiter");
this.retryBudget = Objects.requireNonNull(retryBudget, "retry budget");
this.backoffFactory = Objects.requireNonNull(backoffFactory, "backoff factory");
this.credentialProvider = Objects.requireNonNull(credentialProvider, "credential provider");
this.support = Objects.requireNonNull(support, "execution support");
this.targetPolicy = new TrustedTargetPolicy(profile);
this.bodyLimitPolicy = BodyLimitPolicy.maxRequestBytes(profile.request().maxBodyBytes());
}
/**
* The engine client, visible only inside this package.
*
* <p>Public exposure let a caller bypass every platform guarantee, which is what the reactive
* typed registry did. See {@code BlockingClientRuntime#restClient()} for the full reasoning.
*
* @return the profile's immutable {@code WebClient}
*/
WebClient webClient() {
return webClient;
}
public TransportId transportId() {
return transportId;
}
public TransportFailureClassifier failureClassifier() {
return failureClassifier;
}
public TrustedTargetPolicy targetPolicy() {
return targetPolicy;
}
public BodyLimitPolicy bodyLimitPolicy() {
return bodyLimitPolicy;
}
public LogicalAdmissionLimiter admissionLimiter() {
return admissionLimiter;
}
public RetryBudget retryBudget() {
return retryBudget;
}
public BackoffStrategy newBackoff() {
return backoffFactory.get();
}
public ReactiveRequestCredentialProvider credentialProvider() {
return credentialProvider;
}
public BlockingExecutionSupport support() {
return support;
}
}
@@ -0,0 +1,107 @@
package dev.caskeleton.adapter.outbound.httpclient.webclient;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.restclient.ResponseSizeLimiter;
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
import java.util.Objects;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/**
* Reactive streaming download (design §23.2, §23.3, D-12).
*
* <p>The status is checked before the body is exposed, the byte budget is enforced as buffers flow,
* and the first delivered buffer permanently disables transparent retry for the call.
*/
public final class ReactiveStreamingGateway {
private final ClientRuntimeRegistry runtimes;
public ReactiveStreamingGateway(ClientRuntimeRegistry runtimes) {
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
}
public Flux<DataBuffer> download(ClientProfileName profileName, HttpOperation operation) {
return download(profileName, operation, new FirstByteDeliveryGuard());
}
public Flux<DataBuffer> download(
ClientProfileName profileName, HttpOperation operation, FirstByteDeliveryGuard guard) {
Objects.requireNonNull(profileName, "profile name");
Objects.requireNonNull(operation, "operation");
Objects.requireNonNull(guard, "first byte guard");
return Flux.usingWhen(
Mono.fromSupplier(() -> runtimes.acquire(profileName)),
lease -> stream(lease, operation, guard),
lease -> Mono.fromRunnable(lease::close),
(lease, failure) -> Mono.fromRunnable(lease::close),
lease -> Mono.fromRunnable(lease::close));
}
private Flux<DataBuffer> stream(
ClientRuntimeLease lease, HttpOperation operation, FirstByteDeliveryGuard guard) {
if (!(lease.runtime() instanceof ReactiveClientRuntime runtime)) {
return Flux.error(
new IllegalStateException(
"profile "
+ lease.runtime().name().value()
+ " is not configured for the reactive api"));
}
HttpFailureMetadata metadata =
HttpFailureMetadata.validation(
runtime.name(),
operation.operationName(),
operation.method(),
operation.uriTemplate(),
operation.body().replayability());
PreparedOperation prepared = runtime.targetPolicy().prepare(operation);
ResponseSizeLimiter limiter =
new ResponseSizeLimiter(
prepared.maxResponseWireBytes(), prepared.maxResponseDecodedBytes(), metadata);
// The prepared headers are actually sent. They were computed and then dropped: the request was
// built from the URI alone, so the header policy ran, produced an approved set, and the wire
// saw
// none of it — no content negotiation, no correlation header, and no credential.
var request =
runtime
.webClient()
.method(org.springframework.http.HttpMethod.valueOf(operation.method().name()))
.uri(prepared.target().uri());
for (var header : prepared.headers().entrySet()) {
for (String value : header.getValue()) {
request = request.header(header.getKey(), value);
}
}
return request
.exchangeToFlux(
response -> {
int status = response.statusCode().value();
if (status < 200 || status >= 300) {
return response
.releaseBody()
.thenMany(
Flux.error(
new HttpRemoteErrorException(
"streaming download returned an error status",
metadata
.withStatus(new HttpStatus(status))
.withEvidence(ExecutionEvidence.RESPONSE_RECEIVED))));
}
return BoundedDataBufferFlux.bound(
response.bodyToFlux(DataBuffer.class), limiter, guard);
})
.doOnDiscard(DataBuffer.class, DataBufferUtils::release);
}
}
@@ -0,0 +1,98 @@
package dev.caskeleton.adapter.outbound.httpclient.webclient;
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
import dev.caskeleton.adapter.outbound.httpclient.api.body.ByteArrayBody;
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
import dev.caskeleton.adapter.outbound.httpclient.api.body.ObjectBody;
import dev.caskeleton.adapter.outbound.httpclient.api.body.OneShotStreamBody;
import dev.caskeleton.adapter.outbound.httpclient.api.body.ReopenableStreamBody;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRequestWriteException;
import dev.caskeleton.adapter.outbound.httpclient.security.BodyLimitPolicy;
import java.io.IOException;
import java.util.Optional;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
/**
* Writes a body onto a WebClient request (design §10.2, §23.1).
*
* <p>A reopenable body is opened per attempt so a retry sends the same bytes rather than an
* already-drained stream.
*/
public final class WebClientBodyWriter {
public WebClient.RequestHeadersSpec<?> write(
WebClient.RequestBodySpec spec,
BodySource body,
Optional<ReactiveBodySource> reactiveBody,
BodyLimitPolicy bodyLimitPolicy,
HttpFailureMetadata metadata) {
if (reactiveBody.isPresent()) {
ReactiveBodySource reactive = reactiveBody.get();
// A reactive body used to return here before any limit was applied, so the whole
// request-size policy was opt-out: publish the body as a Flux and the profile's
// max-body-bytes stopped existing. The known length is checked up front when the source
// declares one, and the emitted bytes are counted as they go when it does not — an
// unbounded publisher is exactly the case a byte ceiling is for.
reactive
.knownLength()
.ifPresent(
declared -> {
if (declared > bodyLimitPolicy.limit()) {
throw new HttpRequestWriteException(
"reactive request body declares "
+ declared
+ " bytes, over the profile limit of "
+ bodyLimitPolicy.limit(),
metadata);
}
});
spec.contentType(reactive.mediaType());
java.util.concurrent.atomic.AtomicLong written = new java.util.concurrent.atomic.AtomicLong();
return spec.body(
BodyInserters.fromDataBuffers(
reactor.core.publisher.Flux.from(reactive.publisherFactory().get())
.cast(DataBuffer.class)
.doOnNext(
buffer ->
bodyLimitPolicy.recordWrittenBytes(
written.addAndGet(buffer.readableByteCount()), metadata))));
}
bodyLimitPolicy.validate(body, metadata);
if (body instanceof EmptyBody) {
return spec;
}
spec.contentType(mediaType(body));
if (body instanceof ObjectBody objectBody) {
return spec.bodyValue(objectBody.value());
}
if (body instanceof ByteArrayBody byteArrayBody) {
return spec.bodyValue(byteArrayBody.bytes());
}
if (body instanceof ReopenableStreamBody reopenable) {
try {
return spec.body(
BodyInserters.fromResource(new InputStreamResource(reopenable.opener().get())));
} catch (IOException failure) {
throw new HttpRequestWriteException("request body could not be opened", metadata, failure);
}
}
if (body instanceof OneShotStreamBody oneShot) {
return spec.body(BodyInserters.fromResource(new InputStreamResource(oneShot.stream())));
}
throw new IllegalStateException("unsupported body source: " + body.getClass().getName());
}
private MediaType mediaType(BodySource body) {
String declared = body.mediaType();
return declared.isBlank()
? MediaType.APPLICATION_OCTET_STREAM
: MediaType.parseMediaType(declared);
}
}
@@ -0,0 +1,123 @@
package dev.caskeleton.adapter.outbound.httpclient.webclient;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
import dev.caskeleton.adapter.outbound.httpclient.auth.ReactiveRequestCredentialProvider;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeFactory;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType;
import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ExponentialFullJitterBackoff;
import dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRegistry;
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget;
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingExecutionSupport;
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportProvider;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportCapabilityValidator;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
import java.time.Duration;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
import java.util.random.RandomGenerator;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
/**
* Builds a reactive runtime generation for a profile (design §26.2).
*
* <p>The codec in-memory limit is derived from the profile's decoded-byte budget rather than left
* at the framework default, so an oversized response is rejected by the same number the profile
* declares.
*/
public final class WebClientRuntimeFactory implements ClientRuntimeFactory {
private final Map<TransportType, ReactiveTransportProvider> providers =
new EnumMap<>(TransportType.class);
private final TransportCapabilityValidator capabilityValidator =
new TransportCapabilityValidator();
private final ResilienceRegistry resilienceRegistry;
private final ReactiveRequestCredentialProvider credentialProvider;
private final BlockingExecutionSupport support;
private final TransportLifecycleListener lifecycleListener;
private final RandomGenerator random;
public WebClientRuntimeFactory(
Map<TransportType, ReactiveTransportProvider> providers,
ResilienceRegistry resilienceRegistry,
ReactiveRequestCredentialProvider credentialProvider,
BlockingExecutionSupport support,
TransportLifecycleListener lifecycleListener,
RandomGenerator random) {
Objects.requireNonNull(providers, "reactive transport providers").forEach(this.providers::put);
this.resilienceRegistry = Objects.requireNonNull(resilienceRegistry, "resilience registry");
this.credentialProvider = Objects.requireNonNull(credentialProvider, "credential provider");
this.support = Objects.requireNonNull(support, "execution support");
this.lifecycleListener = Objects.requireNonNull(lifecycleListener, "lifecycle listener");
this.random = Objects.requireNonNull(random, "random generator");
}
@Override
public ClientRuntime create(ClientProfile profile, RuntimeGeneration generation) {
Objects.requireNonNull(profile, "profile");
ReactiveTransportProvider provider = providers.get(profile.transport());
if (provider == null) {
throw new HttpConfigurationException(
"no reactive transport provider is registered for " + profile.transport(),
HttpFailureMetadata.startup(profile.name()));
}
capabilityValidator.validate(profile, provider.capabilities());
ClientHttpConnector connector = provider.create(profile, generation, lifecycleListener);
WebClient webClient =
WebClient.builder()
.clientConnector(connector)
.baseUrl(profile.baseUrl().toString())
.codecs(
configurer ->
configurer
.defaultCodecs()
.maxInMemorySize(
Math.toIntExact(
Math.min(profile.response().maxDecodedBytes(), Integer.MAX_VALUE))))
.build();
RetryBudget retryBudget =
profile
.retry()
.budget()
.map(
name ->
resilienceRegistry.retryBudget(
name,
Math.max(1L, profile.pool().maxTotalConnections() / 10L),
Duration.ofMinutes(1)))
.orElseGet(RetryBudget::unlimited);
Supplier<BackoffStrategy> backoffFactory =
() ->
new ExponentialFullJitterBackoff(
profile.retry().baseBackoff(),
profile.retry().maxBackoff(),
profile.retry().jitter(),
profile.retry().retryAfter(),
random);
return new ReactiveClientRuntime(
profile,
generation,
() -> provider.close(profile, generation),
webClient,
provider.id(),
provider.failureClassifier(),
resilienceRegistry.admission(
profile.name(),
profile.pool().maxPendingAcquires() + profile.pool().maxTotalConnections()),
retryBudget,
backoffFactory,
credentialProvider,
support);
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.adapter.outbound.httpclient.apache;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
import dev.caskeleton.adapter.outbound.httpclient.testkit.NoopLifecycleListener;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
class ApacheBlockingTransportProviderTest {
@Test
void sendsRequestThroughConfiguredFactory() throws Exception {
try (MockHttpServer server = MockHttpServer.start()) {
server.enqueueJson(200, "{\"value\":1}");
ClientProfile profile = ClientProfiles.apache(server.uri("/"));
ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider();
try {
ClientHttpRequestFactory factory =
provider.create(
profile,
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
NoopLifecycleListener.INSTANCE);
RestClient client = RestClient.builder().requestFactory(factory).build();
String body = client.get().uri(server.uri("/value")).retrieve().body(String.class);
assertThat(body).contains("value");
assertThat(server.takeRequest(Duration.ofSeconds(2)).path()).isEqualTo("/value");
assertThat(provider.leasedConnections(profile.name())).isZero();
} finally {
provider.close(
profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1));
}
}
}
@Test
void neverFollowsRedirectsAtTheEngineLevel() throws Exception {
try (MockHttpServer server = MockHttpServer.start()) {
server.enqueueRedirect(302, "/moved");
ClientProfile profile = ClientProfiles.apache(server.uri("/"));
ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider();
try {
RestClient client =
RestClient.builder()
.requestFactory(
provider.create(
profile,
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
NoopLifecycleListener.INSTANCE))
.build();
int status =
client
.get()
.uri(server.uri("/start"))
.exchange((request, response) -> response.getStatusCode().value());
assertThat(status).isEqualTo(302);
assertThat(server.requestCount()).isEqualTo(1);
} finally {
provider.close(
profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1));
}
}
}
@Test
void declaresRouteScopedPoolAndDynamicTargetCapability() {
assertThat(new ApacheBlockingTransportProvider().capabilities().routeScopedPool()).isTrue();
assertThat(new ApacheBlockingTransportProvider().capabilities().dynamicTargetStable()).isTrue();
assertThat(new ApacheBlockingTransportProvider().id().value()).isEqualTo("apache");
}
}
@@ -0,0 +1,93 @@
package dev.caskeleton.adapter.outbound.httpclient.apache;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.PoolSettings;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
import dev.caskeleton.adapter.outbound.httpclient.testkit.NoopLifecycleListener;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.springframework.web.client.RestClient;
class ApachePoolSaturationTest {
@Test
void poolAcquireTimeoutIsClassifiedAsNotSent() throws Exception {
ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider();
ExecutorService executor = Executors.newFixedThreadPool(2);
try (MockHttpServer server = MockHttpServer.start()) {
server.enqueueDelayedBody(200, "{\"slow\":true}", Duration.ofSeconds(2));
server.enqueueJson(200, "{\"fast\":true}");
ClientProfile profile = singleConnectionProfile(server.uri("/"));
RestClient client =
RestClient.builder()
.requestFactory(
provider.create(
profile,
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
NoopLifecycleListener.INSTANCE))
.build();
CountDownLatch firstStarted = new CountDownLatch(1);
executor.execute(
() -> {
firstStarted.countDown();
try {
client.get().uri(server.uri("/slow")).retrieve().body(String.class);
} catch (RuntimeException ignored) {
// The holding request is only needed to occupy the single pooled connection.
}
});
assertThat(firstStarted.await(2, TimeUnit.SECONDS)).isTrue();
Thread.sleep(200);
Throwable captured = null;
try {
client.get().uri(server.uri("/fast")).retrieve().body(String.class);
} catch (RuntimeException saturated) {
captured = saturated;
}
assertThat(captured).isNotNull();
TransportFailure failure =
new ApacheFailureClassifier().classify(captured, AttemptStage.POOL_ACQUIRE);
assertThat(failure.stage()).isEqualTo(AttemptStage.POOL_ACQUIRE);
assertThat(failure.evidence()).isEqualTo(ExecutionEvidence.NOT_SENT);
provider.close(
profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1));
} finally {
executor.shutdownNow();
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
}
}
private static ClientProfile singleConnectionProfile(java.net.URI baseUrl) {
return ClientProfiles.builder("saturated")
.baseUrl(baseUrl)
.pool(
new PoolSettings(
1,
1,
1,
Duration.ofMillis(100),
Duration.ofSeconds(30),
Duration.ofMinutes(5),
Duration.ofSeconds(5),
Duration.ofSeconds(15),
Duration.ofSeconds(5),
false,
false))
.build();
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.adapter.outbound.httpclient.api.body;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Replayability is a property of the value, not a promise in a javadoc.
*
* <p>Every {@code ObjectBody} used to report {@code REPLAYABLE}. A caller who reused a builder or
* kept a reference to a list therefore got a retry that re-encoded the value <em>as it was at retry
* time</em> — different bytes, same idempotency key, which is precisely what a replay must never
* be.
*/
class ObjectBodyReplayabilityTest {
private record ImmutableOrder(String id, int quantity) {}
private record OrderWithMutableLines(String id, List<String> lines) {}
@Test
@DisplayName("a record of immutable components replays")
void anImmutableRecordReplays() {
assertThat(ObjectBody.json(new ImmutableOrder("order-1", 2)).replayability())
.isEqualTo(BodyReplayability.REPLAYABLE);
assertThat(ObjectBody.json("plain-string").replayability())
.isEqualTo(BodyReplayability.REPLAYABLE);
assertThat(ObjectBody.json(List.of("a", "b")).replayability())
.isEqualTo(BodyReplayability.REPLAYABLE);
assertThat(ObjectBody.json(Map.of("k", 1)).replayability())
.isEqualTo(BodyReplayability.REPLAYABLE);
}
@Test
@DisplayName("a value the caller can still mutate does not replay")
void aMutableValueIsOneShot() {
assertThat(ObjectBody.json(new ArrayList<>(List.of("a"))).replayability())
.isEqualTo(BodyReplayability.ONE_SHOT);
assertThat(ObjectBody.json(new LinkedHashMap<>(Map.of("k", 1))).replayability())
.isEqualTo(BodyReplayability.ONE_SHOT);
}
/** A record is not a guarantee if one of its components is a list the caller still holds. */
@Test
@DisplayName("a record wrapping a mutable component does not replay")
void aRecordWrappingMutableStateIsOneShot() {
assertThat(
ObjectBody.json(new OrderWithMutableLines("order-1", new ArrayList<>(List.of("line"))))
.replayability())
.isEqualTo(BodyReplayability.ONE_SHOT);
assertThat(
ObjectBody.json(new OrderWithMutableLines("order-1", List.of("line"))).replayability())
.isEqualTo(BodyReplayability.REPLAYABLE);
}
/**
* An arbitrary bean cannot be certified, so it is one-shot rather than optimistically replayed.
*/
@Test
@DisplayName("an uninspectable value is one-shot")
void anArbitraryBeanIsOneShot() {
assertThat(ObjectBody.json(new Object()).replayability()).isEqualTo(BodyReplayability.ONE_SHOT);
}
}
@@ -0,0 +1,157 @@
package dev.caskeleton.adapter.outbound.httpclient.architecture;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
/**
* The stable API must not leak an engine (design D-04, §9.5, §31).
*
* <p>These rules are what make "native engine access is internal" enforceable. Without them the
* boundary survives only as long as everyone remembers it.
*/
@Tag("httpclient-spring62-surface")
class PublicApiArchitectureTest {
private static final JavaClasses PLATFORM =
new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("dev.caskeleton.adapter.outbound.httpclient");
@Test
void publicApiDoesNotExposeNativeEnginesOrUnsafeBuilders() {
ArchRuleDefinition.noClasses()
.that()
.resideInAPackage("..outbound.httpclient.api..")
.should()
.dependOnClassesThat()
.resideInAnyPackage(
"org.apache.hc..",
"reactor.netty..",
"org.eclipse.jetty..",
"java.net.http..",
"io.github.resilience4j..",
"org.springframework..")
.as("the core api must stay free of engine and framework types (design §8)")
.check(PLATFORM);
}
@Test
void noPublicMethodReturnsANativeEngineClient() {
ArchRuleDefinition.noMethods()
.that()
.arePublic()
.and()
.areDeclaredInClassesThat()
.resideOutsideOfPackages(
"..outbound.httpclient.apache..",
"..outbound.httpclient.jdk..",
"..outbound.httpclient.reactor..",
"..outbound.httpclient.http3..")
.should()
.haveRawReturnType(
com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage(
"org.apache.hc..", "reactor.netty..", "org.eclipse.jetty..", "java.net.http.."))
.as("no application-facing method may hand out a native engine client (design §9.5)")
.check(PLATFORM);
}
@Test
void theSpring7ServiceGroupApiIsUsedOnlyInItsOwnPackage() {
ArchRuleDefinition.noClasses()
.that()
.resideOutsideOfPackage("..outbound.httpclient.spring7..")
.should()
.dependOnClassesThat()
.resideInAPackage("org.springframework.web.service.registry..")
.as("Spring 7-only APIs stay in the optional module (design D-17)")
.check(PLATFORM);
}
/**
* Spring's own clients are as dangerous as a native engine, and were not covered.
*
* <p>The rule above blocks Apache, Netty, Jetty and the JDK client. It said nothing about {@code
* RestClient} and {@code WebClient} — and those were exactly what the runtimes handed out
* publicly, and exactly what the typed registries used to reach the network with none of the
* platform's guarantees. A caller holding one of them bypasses target policy, credentials,
* admission, deadline, resilience, byte limits, error mapping and observation just as completely
* as one holding an Apache client.
*/
@Test
void noPublicMethodHandsOutASpringHttpClient() {
ArchRuleDefinition.noMethods()
.that()
.arePublic()
.and()
.areDeclaredInClassesThat()
.resideInAPackage("..outbound.httpclient..")
.and()
// The migration package exists to hand a RestClient to code being moved off RestTemplate;
// that is its stated purpose, and it is not a platform execution path.
.areDeclaredInClassesThat()
.resideOutsideOfPackage("..outbound.httpclient.migration..")
.should()
.haveRawReturnType(
com.tngtech.archunit.core.domain.JavaClass.Predicates.assignableTo(
org.springframework.web.client.RestClient.class)
.or(
com.tngtech.archunit.core.domain.JavaClass.Predicates.assignableTo(
org.springframework.web.reactive.function.client.WebClient.class)))
.as("a Spring client reaches the network with no platform policy applied (design §9.5)")
.check(PLATFORM);
}
/**
* The engine clients stay inside the two packages that drive them.
*
* <p>Confining the type, not just the accessor, is what stops the next gateway from constructing
* its own client and calling it a shortcut.
*/
@Test
void springHttpClientsAreConfinedToTheirExecutionPackages() {
ArchRuleDefinition.noClasses()
.that()
.resideInAPackage("..outbound.httpclient..")
.and()
.resideOutsideOfPackages(
"..outbound.httpclient.restclient..",
"..outbound.httpclient.webclient..",
"..outbound.httpclient.migration..",
"..outbound.httpclient.spring7..")
.should()
.dependOnClassesThat()
.areAssignableTo(org.springframework.web.client.RestClient.class)
.as("RestClient is driven only by the blocking execution package")
.check(PLATFORM);
ArchRuleDefinition.noClasses()
.that()
.resideInAPackage("..outbound.httpclient..")
.and()
.resideOutsideOfPackages(
"..outbound.httpclient.webclient..", "..outbound.httpclient.spring7..")
.should()
.dependOnClassesThat()
.areAssignableTo(org.springframework.web.reactive.function.client.WebClient.class)
.as("WebClient is driven only by the reactive execution package")
.check(PLATFORM);
}
@Test
void theCoreApiCarriesNoMutableBuilderSurface() {
ArchRuleDefinition.noMethods()
.that()
.areDeclaredInClassesThat()
.resideInAPackage("..outbound.httpclient..")
.and()
.arePublic()
.should()
.haveNameMatching("mutableBuilder|nativeApacheClient|nativeJdkClient|nativeReactorClient")
.as("the explicitly forbidden signatures in design §9.5 must not exist")
.check(PLATFORM);
}
}
@@ -0,0 +1,217 @@
package dev.caskeleton.adapter.outbound.httpclient.contract;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientApiType;
import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType;
import dev.caskeleton.adapter.outbound.httpclient.testkit.BlockingTransportContract;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
import dev.caskeleton.adapter.outbound.httpclient.testkit.HttpClientContract;
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ReactiveTestGateways;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ReactiveTransportContract;
import dev.caskeleton.adapter.outbound.httpclient.testkit.TestGateways;
import java.lang.reflect.Method;
import java.net.URI;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.condition.EnabledIf;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
/**
* One semantic contract, executed against every selected Stable transport (design §28.2, §33).
*
* <p>Transport selection is explicit and fail-closed in two directions, and both are load-bearing.
*
* <p>The first is that an empty or unknown selection is an error, so the suite cannot report
* success because it silently ran against nothing.
*
* <p>The second is why the blocking and reactive contracts are separate containers. They used to
* live side by side, with the blocking ones parameterized over a source that filtered the selection
* down to {@code apache} and {@code jdk}. Under the CI matrix's {@code reactor}-only row that
* filter produced an empty stream, and JUnit fails a {@code @ParameterizedTest} with no arguments
* as a configuration error — so the reactor row could never be green, and the failure said nothing
* about transports. Each container is now enabled only when the selection actually contains a
* transport it can certify, and each asserts afterwards that it ran the number of invocations that
* selection implies. A container that is disabled contributes nothing; a container that is enabled
* and ran fewer contracts than expected fails.
*/
@Tag("httpclient-contract")
class AllStableTransportsContractTest {
private static final List<String> BLOCKING_TRANSPORTS = List.of("apache", "jdk");
private static final List<String> REACTIVE_TRANSPORTS = List.of("reactor");
static Stream<String> blockingTransports() {
return HttpClientContract.selectedTransports().stream().filter(BLOCKING_TRANSPORTS::contains);
}
static Stream<String> reactiveTransports() {
return HttpClientContract.selectedTransports().stream().filter(REACTIVE_TRANSPORTS::contains);
}
static boolean anyBlockingTransportSelected() {
return blockingTransports().findAny().isPresent();
}
static boolean anyReactiveTransportSelected() {
return reactiveTransports().findAny().isPresent();
}
@Test
void theSelectedTransportSetIsExplicit() {
assertThat(HttpClientContract.selectedTransports()).isNotEmpty();
}
/**
* No selected transport may fall between the two containers.
*
* <p>A Stable transport added to the support matrix but to neither container would otherwise be
* certified by nothing while the suite stayed green.
*/
@Test
void everySelectedTransportIsClaimedByExactlyOneContainer() {
assertThat(Stream.concat(blockingTransports(), reactiveTransports()).toList())
.as("every selected transport must belong to the blocking or the reactive container")
.containsExactlyInAnyOrderElementsOf(HttpClientContract.selectedTransports());
}
private static TestGateways.Harness blockingHarness(String transport, URI baseUrl) {
TransportType type = "jdk".equals(transport) ? TransportType.JDK : TransportType.APACHE;
return TestGateways.forProfile(
ClientProfiles.builder("users")
.baseUrl(baseUrl)
.transport(type)
.api(ClientApiType.REST_CLIENT)
.build());
}
/** Counts the {@code @ParameterizedTest} methods a container declares. */
private static long declaredContracts(Class<?> container) {
return Stream.of(container.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(ParameterizedTest.class))
.map(Method::getName)
.distinct()
.count();
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@EnabledIf(
"dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest"
+ "#anyBlockingTransportSelected")
class BlockingContract {
private final AtomicInteger executed = new AtomicInteger();
@AfterAll
void everySelectedBlockingTransportRanEveryContract() {
long expected = declaredContracts(BlockingContract.class) * blockingTransports().count();
assertThat(executed.get())
.as(
"each selected blocking transport must run each declared contract; a container that "
+ "is enabled but under-runs is an empty success")
.isEqualTo((int) expected);
}
@ParameterizedTest
@MethodSource(
"dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest"
+ "#blockingTransports")
void methodAndTemplateEncodingIsIdentical(String transport) throws Exception {
try (MockHttpServer server = MockHttpServer.start();
TestGateways.Harness harness = blockingHarness(transport, server.uri("/"))) {
BlockingTransportContract.methodAndTemplateEncoding(harness, server);
}
executed.incrementAndGet();
}
@ParameterizedTest
@MethodSource(
"dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest"
+ "#blockingTransports")
void notSentConnectFailureHasSameStableMetadata(String transport) {
BlockingTransportContract.connectFailureIsProvenNotSent(
baseUrl -> blockingHarness(transport, baseUrl));
executed.incrementAndGet();
}
@ParameterizedTest
@MethodSource(
"dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest"
+ "#blockingTransports")
void absoluteUriIsRejectedOnEveryTransport(String transport) throws Exception {
try (MockHttpServer server = MockHttpServer.start();
TestGateways.Harness harness = blockingHarness(transport, server.uri("/"))) {
BlockingTransportContract.absoluteUriIsRejectedBeforeAnyRequest(harness, server);
}
executed.incrementAndGet();
}
@ParameterizedTest
@MethodSource(
"dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest"
+ "#blockingTransports")
void errorStatusMapsToTheSameStableException(String transport) throws Exception {
try (MockHttpServer server = MockHttpServer.start();
TestGateways.Harness harness = blockingHarness(transport, server.uri("/"))) {
BlockingTransportContract.errorStatusBecomesAStableException(harness, server);
}
executed.incrementAndGet();
}
}
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@EnabledIf(
"dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest"
+ "#anyReactiveTransportSelected")
class ReactiveContract {
private final AtomicInteger executed = new AtomicInteger();
@AfterAll
void everySelectedReactiveTransportRanEveryContract() {
long expected = declaredContracts(ReactiveContract.class) * reactiveTransports().count();
assertThat(executed.get())
.as("each selected reactive transport must run each declared contract")
.isEqualTo((int) expected);
}
@ParameterizedTest
@MethodSource(
"dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest"
+ "#reactiveTransports")
void typedResultAndTemplateEncodingIsIdentical(String transport) throws Exception {
try (MockHttpServer server = MockHttpServer.start();
ReactiveTestGateways.Harness harness = reactiveHarness(transport, server.uri("/"))) {
ReactiveTransportContract.typedResultAndTemplateEncoding(harness, server);
}
executed.incrementAndGet();
}
@ParameterizedTest
@MethodSource(
"dev.caskeleton.adapter.outbound.httpclient.contract.AllStableTransportsContractTest"
+ "#reactiveTransports")
void notSentConnectFailureHasSameStableMetadata(String transport) {
ReactiveTransportContract.connectFailureIsProvenNotSent(
baseUrl -> reactiveHarness(transport, baseUrl));
executed.incrementAndGet();
}
private ReactiveTestGateways.Harness reactiveHarness(String transport, URI baseUrl) {
if (!"reactor".equals(transport)) {
throw new IllegalStateException("unsupported reactive contract transport: " + transport);
}
return ReactiveTestGateways.reactor(baseUrl);
}
}
}
@@ -0,0 +1,86 @@
package dev.caskeleton.adapter.outbound.httpclient.contract;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.api.OperationName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
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.ResponseType;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
import dev.caskeleton.adapter.outbound.httpclient.testkit.TestGateways;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ToxiproxyFixture;
import dev.caskeleton.adapter.outbound.httpclient.testkit.UserResponse;
import eu.rekawek.toxiproxy.Proxy;
import java.net.URI;
import java.time.Duration;
import java.util.Map;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.utility.DockerImageName;
/**
* TCP-level faults against a real upstream (design §28.3).
*
* <p>The lane is fail-closed: selecting it without Docker throws rather than skipping, because a
* fault suite that never injected a fault proves nothing.
*/
@Tag("httpclient-fault")
class FailureInjectionContractTest {
private static final DockerImageName UPSTREAM_IMAGE =
// Overridable so a deployment can pin a digest. Hard-coding `:latest` meant the fault suite's
// upstream could change between two runs of the same commit, so a red build might be
// someone else's image push rather than this repository's regression — the one thing a
// fault-injection lane must never be ambiguous about. The default stays `:latest` because no
// digest can be verified from here; `-Dhttpclient.fault.httpbin.image=<repo>@sha256:<digest>`
// makes the run reproducible, and docs/httpclient/operations.md records that CI should set
// it.
DockerImageName.parse(
System.getProperty("httpclient.fault.httpbin.image", "kennethreitz/httpbin:latest"))
.asCompatibleSubstituteFor("kennethreitz/httpbin");
@Test
@SuppressWarnings("resource")
void aResetPeerProducesStableEvidenceRatherThanAGenericFailure() throws Exception {
ToxiproxyFixture.requireAvailable();
try (Network network = Network.newNetwork();
GenericContainer<?> upstream =
new GenericContainer<>(UPSTREAM_IMAGE)
.withNetwork(network)
.withNetworkAliases("upstream")
.withExposedPorts(80)) {
upstream.start();
try (ToxiproxyFixture toxiproxy = ToxiproxyFixture.start(network)) {
Proxy proxy = toxiproxy.proxyTo("upstream", "upstream", 80);
toxiproxy.resetPeer(proxy, Duration.ofMillis(50));
URI baseUrl =
URI.create("http://" + toxiproxy.proxiedHost() + ":" + toxiproxy.proxiedPort() + "/");
try (TestGateways.Harness harness =
TestGateways.forProfile(ClientProfiles.builder("faulty").baseUrl(baseUrl).build())) {
HttpClientException failure =
org.assertj.core.api.Assertions.catchThrowableOfType(
HttpClientException.class,
() ->
harness
.gateway()
.exchange(
harness.profile().name(),
HttpOperation.get(new OperationName("get-user"), "/get", Map.of()),
ResponseType.of(UserResponse.class)));
assertThat(failure).isNotNull();
assertThat(failure.metadata().evidence())
.isIn(
ExecutionEvidence.NOT_SENT,
ExecutionEvidence.SENT_NO_RESPONSE,
ExecutionEvidence.PARTIAL_RESPONSE);
assertThat(failure.getMessage()).doesNotContain("http://");
}
}
}
}
}
@@ -0,0 +1,175 @@
package dev.caskeleton.adapter.outbound.httpclient.contract;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.httpclient.apache.ApacheBlockingTransportProvider;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.jdk.JdkClientFactory;
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.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 dev.caskeleton.adapter.outbound.httpclient.transport.TransportCapabilityValidator;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
/**
* What each transport actually negotiates on the wire (design §6.2, §24.1).
*
* <p>The protocol is read from the client after a real TLS handshake, not from configuration. A
* fixture server's recorded request line is not usable for this: MockWebServer renders an HTTP/2
* stream with an HTTP/1.1-style request line, so asserting on it would report every connection as
* HTTP/1.1 regardless of the truth.
*
* <p>These tests exist because the support matrix is a claim about this platform, and a claim that
* is not measured is a claim that drifts.
*/
@Tag("httpclient-contract")
class NegotiatedProtocolContractTest {
@Test
void theJdkTransportNegotiatesHttp2WhenTheProfileAsksForIt() throws Exception {
TlsFixture fixture = TlsFixture.trusted();
try (MockHttpServer server = MockHttpServer.startTlsWithHttp2(fixture.serverSocketFactory())) {
server.enqueueJson(200, "{}");
ClientProfile profile =
ClientProfiles.builder("h2")
.baseUrl(server.uri("/"))
.transport(TransportType.JDK)
.protocols(Set.of(HttpProtocol.HTTP_2, HttpProtocol.HTTP_1_1))
.build();
HttpClient client =
new JdkClientFactory().create(profile, Optional.of(TlsMaterials.trustOnly(fixture)));
try {
HttpResponse<String> response =
client.send(
HttpRequest.newBuilder(server.uri("/p")).GET().build(),
HttpResponse.BodyHandlers.ofString());
assertThat(response.version()).isEqualTo(HttpClient.Version.HTTP_2);
} finally {
client.close();
}
}
}
@Test
void theJdkTransportStaysOnHttp11WhenTheProfileSaysSo() throws Exception {
TlsFixture fixture = TlsFixture.trusted();
try (MockHttpServer server = MockHttpServer.startTlsWithHttp2(fixture.serverSocketFactory())) {
server.enqueueJson(200, "{}");
ClientProfile profile =
ClientProfiles.builder("h1")
.baseUrl(server.uri("/"))
.transport(TransportType.JDK)
.protocols(Set.of(HttpProtocol.HTTP_1_1))
.build();
HttpClient client =
new JdkClientFactory().create(profile, Optional.of(TlsMaterials.trustOnly(fixture)));
try {
HttpResponse<String> response =
client.send(
HttpRequest.newBuilder(server.uri("/p")).GET().build(),
HttpResponse.BodyHandlers.ofString());
// The server offers HTTP/2; the profile is what keeps the connection on HTTP/1.1.
assertThat(response.version()).isEqualTo(HttpClient.Version.HTTP_1_1);
} finally {
client.close();
}
}
}
@Test
void theReactorTransportNegotiatesHttp2() throws Exception {
TlsFixture fixture = TlsFixture.trusted();
try (MockHttpServer server = MockHttpServer.startTlsWithHttp2(fixture.serverSocketFactory())) {
server.enqueueJson(200, "{}");
ClientProfile profile =
ClientProfiles.builder("h2")
.baseUrl(server.uri("/"))
.transport(TransportType.REACTOR_NETTY)
.api(ClientApiType.WEB_CLIENT)
.protocols(Set.of(HttpProtocol.HTTP_2, HttpProtocol.HTTP_1_1))
.build();
var pool = new ReactorConnectionProviderFactory().create(profile);
try {
String version =
new ReactorHttpClientFactory()
.create(
profile, pool, Optional.of(TlsMaterials.trustOnly(fixture)), Optional.empty())
.get()
.uri(server.uri("/p").toString())
.response((response, bytes) -> Mono.just(response.version().text()))
.blockLast(Duration.ofSeconds(10));
assertThat(version).isEqualTo("HTTP/2.0");
} finally {
pool.disposeLater().block(Duration.ofSeconds(5));
}
}
}
@Test
void anApacheProfileCannotClaimHttp2() {
ClientProfile profile =
ClientProfiles.builder("h2-on-apache")
.transport(TransportType.APACHE)
.protocols(Set.of(HttpProtocol.HTTP_2, HttpProtocol.HTTP_1_1))
.build();
// Apache implements HTTP/2 in its async client; the classic client Spring drives cannot. The
// capability says so, so the profile fails at startup instead of silently running HTTP/1.1
// while the support matrix claims otherwise.
assertThatThrownBy(
() ->
new TransportCapabilityValidator()
.validate(profile, new ApacheBlockingTransportProvider().capabilities()))
.isInstanceOf(HttpConfigurationException.class)
.hasMessageContaining("HTTP_2");
}
@Test
void theApacheClassicClientCannotSpeakPriorKnowledgeH2c() throws Exception {
try (MockHttpServer server = MockHttpServer.startHttp2PriorKnowledge()) {
server.enqueueJson(200, "{}");
ClientProfile profile = ClientProfiles.builder("h2c").baseUrl(server.uri("/")).build();
ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider();
try {
assertThatThrownBy(
() ->
org.springframework.web.client.RestClient.builder()
.requestFactory(
provider.create(
profile,
new dev.caskeleton.adapter.outbound.httpclient.profile
.RuntimeGeneration(1),
dev.caskeleton.adapter.outbound.httpclient.testkit
.NoopLifecycleListener.INSTANCE))
.build()
.get()
.uri(server.uri("/p"))
.retrieve()
.body(String.class))
.isInstanceOf(RuntimeException.class);
} finally {
provider.close(
profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1));
}
}
}
}
@@ -0,0 +1,253 @@
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException;
import dev.caskeleton.adapter.outbound.httpclient.testkit.DynamicTargets;
import java.net.InetAddress;
import java.net.URI;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class DynamicTargetSecurityTest {
@ParameterizedTest
@ValueSource(
strings = {
"http://127.0.0.1/a",
"https://[::1]/a",
"https://169.254.169.254/latest/meta-data",
"file:///etc/passwd",
"https://user:pass@example.com/a"
})
void rejectsForbiddenTargets(String raw) {
DynamicTargetPolicy policy = DynamicTargets.publicHttpsOnly();
assertThatThrownBy(
() -> {
CanonicalTarget canonical = DynamicTargets.prepare(policy, URI.create(raw));
DynamicTargets.resolvesTo(canonical.host(), canonical.host()).pin(canonical);
})
.isInstanceOf(HttpTargetRejectedException.class);
}
@Test
void rejectsDnsAnswerWhenAnyAddressIsPrivate() {
ValidatedDnsResolver resolver =
DynamicTargets.resolvesTo("mixed.test", "93.184.216.34", "10.0.0.4");
assertThatThrownBy(() -> resolver.resolve("mixed.test"))
.isInstanceOf(HttpTargetRejectedException.class);
assertThat(resolver.approvedAddresses("mixed.test")).isEmpty();
}
@Test
void acceptsAFullyPublicAnswerAndRetainsItForPinning() {
ValidatedDnsResolver resolver =
DynamicTargets.resolvesTo("public.test", "93.184.216.34", "8.8.8.8");
assertThat(resolver.resolve("public.test")).hasSize(2);
assertThat(resolver.approvedAddresses("public.test")).hasSize(2);
}
@Test
void normalizesIpv4MappedIpv6BeforeClassifying() throws Exception {
IpAddressClassifier classifier = new IpAddressClassifier();
InetAddress mapped = InetAddress.getByName("::ffff:127.0.0.1");
assertThat(classifier.forbidden(mapped)).isTrue();
assertThat(IpAddressClassifier.normalize(mapped).getHostAddress()).isEqualTo("127.0.0.1");
}
@Test
void blocksUniqueLocalCarrierGradeNatAndOrganisationRanges() throws Exception {
IpAddressClassifier classifier = new IpAddressClassifier(List.of("8.8.8.0/24"));
assertThat(classifier.forbidden(InetAddress.getByName("fd00::1"))).isTrue();
assertThat(classifier.forbidden(InetAddress.getByName("100.64.0.1"))).isTrue();
assertThat(classifier.forbidden(InetAddress.getByName("8.8.8.8"))).isTrue();
assertThat(classifier.forbidden(InetAddress.getByName("93.184.216.34"))).isFalse();
}
/**
* Special-purpose ranges a denylist has to remember, and kept forgetting.
*
* <p>Each of these was reachable before the classifier required global unicast: they are not
* loopback, link-local, site-local or multicast, so every JDK predicate the old check used
* answered "fine".
*/
@Test
void blocksTheSpecialPurposeRangesADenylistOmitted() throws Exception {
IpAddressClassifier classifier = new IpAddressClassifier();
assertThat(classifier.forbidden(InetAddress.getByName("192.0.2.1"))).isTrue();
assertThat(classifier.forbidden(InetAddress.getByName("198.51.100.1"))).isTrue();
assertThat(classifier.forbidden(InetAddress.getByName("203.0.113.1"))).isTrue();
assertThat(classifier.forbidden(InetAddress.getByName("198.18.0.1"))).isTrue();
assertThat(classifier.forbidden(InetAddress.getByName("240.0.0.1"))).isTrue();
assertThat(classifier.forbidden(InetAddress.getByName("192.0.0.1"))).isTrue();
assertThat(classifier.forbidden(InetAddress.getByName("0.1.2.3"))).isTrue();
assertThat(classifier.forbidden(InetAddress.getByName("2001:db8::1"))).isTrue();
// A genuinely routable address still passes, or the allowlist would be useless.
assertThat(classifier.forbidden(InetAddress.getByName("93.184.216.34"))).isFalse();
assertThat(classifier.forbidden(InetAddress.getByName("2606:2800:220:1::1"))).isFalse();
}
/**
* An operator's exclusion list is a security control, so a malformed entry fails loudly.
*
* <p>All three used to be accepted: {@code /33} produced a range that matched by accident, {@code
* -1} matched everything, and a hostname performed a DNS lookup at startup and froze the block to
* whatever it resolved to then.
*/
@Test
void refusesMalformedBlockedCidrs() {
assertThatThrownBy(() -> new IpAddressClassifier(List.of("10.0.0.0/33")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("prefix must be");
assertThatThrownBy(() -> new IpAddressClassifier(List.of("10.0.0.0/-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("prefix must be");
assertThatThrownBy(() -> new IpAddressClassifier(List.of("internal.example.com/24")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("not a hostname");
assertThatThrownBy(() -> new IpAddressClassifier(List.of("10.0.0.0")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("expected <address>/<prefix>");
}
@Test
void dnsRebindingCannotReuseAPreviouslyApprovedHost() {
ValidatedDnsResolver resolver =
DynamicTargets.resolvesTo(java.util.Map.of("rebind.test", List.of("10.0.0.9")));
assertThatThrownBy(() -> resolver.resolve("rebind.test"))
.isInstanceOf(HttpTargetRejectedException.class);
assertThat(resolver.approvedAddresses("rebind.test")).isEmpty();
}
@Test
void aDynamicPolicyNeverCarriesAnAllowAllDefault() {
DynamicTargetPolicy policy = DynamicTargets.publicHttpsOnly();
assertThat(policy.allowedSchemes()).containsExactly("https");
assertThat(policy.allowedPorts()).containsExactly(443);
assertThat(policy.tracePropagation()).isFalse();
assertThat(policy.maxRedirectHops()).isZero();
}
@Test
void credentialBindingAppliesOnlyToItsExactCanonicalHost() {
DynamicCredentialBinding binding =
DynamicCredentialBinding.httpsOn("partner.example.com", "X-Api-Key", "secret://partner");
CanonicalTarget matching =
DynamicTargets.prepare(
new DynamicTargetPolicy(
new DynamicTargetPolicyName("webhook"),
Set.of("https"),
Set.of(443),
Set.of(),
Set.of(),
0,
false,
List.of()),
URI.create("https://partner.example.com/hook"));
CanonicalTarget other =
DynamicTargets.prepare(
DynamicTargets.publicHttpsOnly(), URI.create("https://evil.test/hook"));
assertThat(binding.matches(matching)).isTrue();
assertThat(binding.matches(other)).isFalse();
}
/**
* The binding is to an origin, not a hostname.
*
* <p>Matching on host alone sent the credential to {@code http://partner.example.com} —
* plaintext, to anyone on the path — and to any other port the same host served.
*/
@Test
void credentialBindingDoesNotFollowTheHostAcrossSchemeOrPort() {
DynamicCredentialBinding binding =
DynamicCredentialBinding.httpsOn("partner.example.com", "X-Api-Key", "secret://partner");
assertThat(
binding.matches(
new CanonicalTarget("http", "partner.example.com", 80, "/hook", Optional.empty())))
.as("a plaintext downgrade must not receive the credential")
.isFalse();
assertThat(
binding.matches(
new CanonicalTarget(
"https", "partner.example.com", 8443, "/hook", Optional.empty())))
.as("a different port is a different service")
.isFalse();
}
/** The header name decides who reads the secret, so it is an allowlist rather than free text. */
@Test
void aCredentialBindingHeaderMustBeOnTheAllowlist() {
assertThatThrownBy(
() ->
DynamicCredentialBinding.httpsOn(
"partner.example.com", "X-Forwarded-Host", "secret://partner"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("not on the allowlist");
}
/**
* A suffix must match at a label boundary.
*
* <p>{@code endsWith} let {@code evil-example.com} satisfy an allowlist of {@code example.com} —
* a domain an attacker registers for exactly this reason.
*/
@Test
void aHostSuffixAllowlistMatchesOnlyWholeLabels() {
DynamicTargetPolicy policy =
new DynamicTargetPolicy(
new DynamicTargetPolicyName("webhook"),
Set.of("https"),
Set.of(443),
Set.of("example.com"),
Set.of(),
0,
false,
List.of());
assertThat(policy.hostAllowed("api.example.com")).isTrue();
assertThat(policy.hostAllowed("example.com")).isTrue();
assertThat(policy.hostAllowed("evil-example.com")).isFalse();
assertThat(policy.hostAllowed("exampleXcom")).isFalse();
}
/**
* The pin is what makes the address validation binding.
*
* <p>Without it the transport resolved the hostname a second time, so a DNS server that answered
* a public address to the validator and a link-local one to the socket won. The pin is scoped to
* one hop and removed afterwards, so nothing inherits an earlier hop's approval.
*/
@Test
void theApprovedAddressesAreVisibleOnlyInsideTheirOwnHop() throws Exception {
InetAddress approved = InetAddress.getByName("93.184.216.34");
assertThat(CallScopedDnsPin.active()).isFalse();
assertThat(CallScopedDnsPin.addressesFor("public.test")).isEmpty();
try (CallScopedDnsPin pin = CallScopedDnsPin.open("public.test", List.of(approved))) {
assertThat(pin).isNotNull();
assertThat(CallScopedDnsPin.active()).isTrue();
assertThat(CallScopedDnsPin.addressesFor("public.test")).containsExactly(approved);
assertThat(CallScopedDnsPin.addressesFor("PUBLIC.TEST")).containsExactly(approved);
// A host this hop did not validate gets nothing, so the transport must refuse rather than
// fall back to an unvalidated system lookup.
assertThat(CallScopedDnsPin.addressesFor("other.test")).isEmpty();
}
assertThat(CallScopedDnsPin.active()).isFalse();
assertThat(CallScopedDnsPin.addressesFor("public.test")).isEmpty();
}
@Test
void aPinNeedsAtLeastOneApprovedAddress() {
assertThatThrownBy(() -> CallScopedDnsPin.open("public.test", List.of()))
.isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,139 @@
package dev.caskeleton.adapter.outbound.httpclient.http3;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
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 dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
import dev.caskeleton.adapter.outbound.httpclient.testkit.NoopLifecycleListener;
import java.util.Set;
import org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3;
import org.junit.jupiter.api.Test;
class Http3OptInTest {
@Test
void rejectsHttp3WithoutExplicitAcknowledgement() {
ClientProfile profile = ClientProfiles.http3WithoutAcknowledgement();
assertThatThrownBy(
() ->
new JettyHttp3TransportProvider()
.create(
profile,
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
NoopLifecycleListener.INSTANCE))
.isInstanceOf(HttpConfigurationException.class)
.hasMessageContaining("experimental acknowledgement");
}
@Test
void rejectsAnIncorrectAcknowledgementString() {
ClientProfile profile =
ClientProfiles.builder("edge")
.protocols(Set.of(HttpProtocol.HTTP_3))
.acknowledgement("yes-please")
.build();
assertThatThrownBy(
() ->
new JettyHttp3TransportProvider()
.create(
profile,
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
NoopLifecycleListener.INSTANCE))
.isInstanceOf(HttpConfigurationException.class)
.hasMessageContaining("experimental acknowledgement");
}
@Test
void refusesDynamicTargets() {
ClientProfile profile =
ClientProfiles.builder("edge")
.mode(ClientMode.DYNAMIC)
.protocols(Set.of(HttpProtocol.HTTP_3))
.acknowledgement(Http3ExperimentalAcknowledgement.REQUIRED)
.build();
assertThatThrownBy(
() ->
new JettyHttp3TransportProvider()
.create(
profile,
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
NoopLifecycleListener.INSTANCE))
.isInstanceOf(HttpConfigurationException.class)
.hasMessageContaining("dynamic targets");
}
@Test
void capabilityReportDeclaresWhatItCannotProve() {
Http3CapabilityReport report = Http3CapabilityReport.detect();
assertThat(report.dynamicTargetSupported()).isFalse();
assertThat(report.unsupportedContracts()).contains("dynamic-target-pinning");
}
/**
* The probe must name the classes the provider constructs.
*
* <p>It used to look for {@code QuicClientConnectorConfigurator}, which this Jetty version does
* not ship, so it reported "no QUIC" on a classpath carrying the whole QUIC stack. A probe that
* cannot see the thing it is probing for is worse than no probe: it reads as a considered
* negative.
*/
@Test
void theCapabilityProbeSeesTheQuicStackThisBuildActuallyCarries() {
assertThat(Http3CapabilityReport.detect().quicNativeSupportPresent())
.as(
"the Jetty QUIC and HTTP/3 client artifacts are on this module's compile and runtime "
+ "classpath, so the probe must find them")
.isTrue();
}
/**
* Class presence is not interoperability.
*
* <p>Nothing in this build stands up an HTTP/3 server, so no negotiated {@code h3} exchange has
* been observed and the transport cannot be advertised beyond Experimental.
*/
@Test
void http3IsNeverPromotedWithoutWireProof() {
Http3CapabilityReport report = Http3CapabilityReport.detect();
assertThat(report.wireVerified()).isFalse();
assertThat(report.promotableToBeta()).isFalse();
assertThat(report.unsupportedContracts()).contains("negotiated-protocol-wire-proof");
}
/**
* The transport is QUIC-backed or it does not exist.
*
* <p>The provider used to build {@code HttpClientTransportOverHTTP}: TCP, HTTP/1.1, reported as
* HTTP/3. Every other guard in this class passed while it did so, which is why this assertion is
* about the constructed object rather than about configuration.
*/
@Test
void theProviderBuildsAQuicBackedTransport() {
ClientProfile profile =
ClientProfiles.builder("edge")
.protocols(Set.of(HttpProtocol.HTTP_3))
.acknowledgement(Http3ExperimentalAcknowledgement.REQUIRED)
.build();
assertThat(JettyHttp3TransportProvider.newHttp3Transport(profile))
.as("an experimental HTTP/3 profile must not be served over TCP HTTP/1.1")
.isInstanceOf(HttpClientTransportOverHTTP3.class);
assertThat(JettyHttp3TransportProvider.newHttp3Client(profile)).isNotNull();
assertThat(new JettyHttp3TransportProvider().configuredVersion())
.isEqualTo(org.eclipse.jetty.http.HttpVersion.HTTP_3);
}
@Test
void theAcknowledgementValueIsExact() {
assertThatThrownBy(() -> new Http3ExperimentalAcknowledgement("i_accept"))
.isInstanceOf(IllegalArgumentException.class);
assertThat(
new Http3ExperimentalAcknowledgement(Http3ExperimentalAcknowledgement.REQUIRED).value())
.isEqualTo("I_ACCEPT_HTTP3_EXPERIMENTAL_SEMANTICS");
}
}
@@ -0,0 +1,51 @@
package dev.caskeleton.adapter.outbound.httpclient.jdk;
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 org.junit.jupiter.api.Test;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
class JdkBlockingTransportProviderTest {
@Test
void performsHttp2CapableBlockingRequest() throws Exception {
try (MockHttpServer server = MockHttpServer.start()) {
server.enqueueJson(200, "{\"ok\":true}");
ClientProfile profile = ClientProfiles.jdk(server.uri("/"));
JdkBlockingTransportProvider provider = new JdkBlockingTransportProvider();
try {
ClientHttpRequestFactory factory =
provider.create(
profile,
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
NoopLifecycleListener.INSTANCE);
String body =
RestClient.builder()
.requestFactory(factory)
.build()
.get()
.uri(server.uri("/ok"))
.retrieve()
.body(String.class);
assertThat(body).contains("ok");
} finally {
provider.close(
profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1));
}
}
}
@Test
void declaresItsWeakerCapabilitiesHonestly() {
JdkBlockingTransportProvider provider = new JdkBlockingTransportProvider();
assertThat(provider.capabilities().routeScopedPool()).isFalse();
assertThat(provider.capabilities().boundedPendingAcquireQueue()).isFalse();
assertThat(provider.capabilities().dynamicTargetStable()).isFalse();
assertThat(provider.id().value()).isEqualTo("jdk");
}
}
@@ -0,0 +1,131 @@
package dev.caskeleton.adapter.outbound.httpclient.observation;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Every metric this platform names must be a metric it produces.
*
* <p>The vocabulary listed twenty-two names; twelve had a producer. The other ten were documented
* in the support matrix, discoverable by anyone building a dashboard, and flat zero forever — so a
* saturated bulkhead, an open breaker and a rate-limited client all charted as a healthy system,
* and the metrics that existed to explain an incident were the ones guaranteed to say nothing
* during it.
*
* <p>A name with no producer is worse than a missing name: the missing one prompts a question, and
* the empty one answers it wrongly. This test keeps the two sets equal by construction — either a
* constant has an emitter somewhere in the platform, or it is listed below as knowingly not yet
* emitted, with the reason.
*/
class DeclaredMetricsAreEmittedTest {
/**
* Names the platform declares but does not yet emit, each with the reason.
*
* <p>Every entry here needs transport-level instrumentation — Netty channel handlers or Apache
* connection callbacks that expose per-stage timings — which is a different piece of work from
* naming the metric. They are listed rather than quietly tolerated so the gap is visible in the
* one place a reader is already looking.
*/
private static final Set<String> NOT_YET_EMITTED =
Set.of(
// Requires per-request stage timings from each engine.
"POOL_ACQUIRE_DURATION",
"DNS_DURATION",
"CONNECT_DURATION",
"TLS_DURATION",
// Requires an in-flight gauge bound to the logical-call observation's lifecycle.
"ACTIVE",
// Requires the token loader to hold a MeterRegistry.
"OAUTH_REFRESH",
// Span names, consumed by a tracer rather than counted here.
"LOGICAL_CALL_SPAN",
"ATTEMPT_SPAN");
@Test
@DisplayName("every declared metric name is either emitted or listed as not yet emitted")
void everyDeclaredMetricHasAProducerOrAnAdmission() throws Exception {
String platformSources = readPlatformSources();
List<String> undeclaredGaps = new ArrayList<>();
for (Field constant : HttpClientObservationNames.class.getDeclaredFields()) {
if (!Modifier.isStatic(constant.getModifiers()) || constant.getType() != String.class) {
continue;
}
String name = constant.getName();
if (NOT_YET_EMITTED.contains(name)) {
continue;
}
// A producer is any reference to the constant outside the vocabulary class itself.
int references = countOccurrences(platformSources, "HttpClientObservationNames." + name);
if (references == 0) {
undeclaredGaps.add(name);
}
}
assertThat(undeclaredGaps)
.as(
"these metric names are declared and documented but nothing emits them; either wire a "
+ "producer or add them to NOT_YET_EMITTED with the reason")
.isEmpty();
}
/** The admission list must not outlive the gap: a wired metric has to leave it. */
@Test
@DisplayName("nothing sits in the not-yet-emitted list once it has a producer")
void theAdmissionListDoesNotHideWiredMetrics() throws Exception {
String platformSources = readPlatformSources();
for (String name : NOT_YET_EMITTED) {
assertThat(countOccurrences(platformSources, "HttpClientObservationNames." + name))
.as("%s now has a producer and must be removed from NOT_YET_EMITTED", name)
.isZero();
}
}
private static int countOccurrences(String haystack, String needle) {
int count = 0;
int index = haystack.indexOf(needle);
while (index >= 0) {
count++;
index = haystack.indexOf(needle, index + needle.length());
}
return count;
}
/** Reads the platform's production sources, excluding the vocabulary class's own declarations. */
private static String readPlatformSources() throws Exception {
Path main = repositoryRoot().resolve("src/adapter/outbound/httpclient/src/main/java");
StringBuilder all = new StringBuilder();
try (Stream<Path> files = Files.walk(main)) {
for (Path file : files.filter(path -> path.toString().endsWith(".java")).toList()) {
if (file.getFileName().toString().equals("HttpClientObservationNames.java")) {
continue;
}
all.append(Files.readString(file)).append('\n');
}
}
return all.toString();
}
private static Path repositoryRoot() {
Path candidate = Path.of(System.getProperty("user.dir")).toAbsolutePath();
for (int depth = 0; depth < 6 && candidate != null; depth++) {
if (Files.exists(candidate.resolve("src/adapter/outbound/httpclient/build.gradle"))) {
return candidate;
}
candidate = candidate.getParent();
}
throw new AssertionError("repository root not found from " + System.getProperty("user.dir"));
}
}
@@ -0,0 +1,205 @@
package dev.caskeleton.adapter.outbound.httpclient.resilience;
import static org.assertj.core.api.Assertions.assertThat;
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 dev.caskeleton.adapter.outbound.httpclient.testkit.RetryContexts;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class RetryEligibilityEngineTest {
private final RetryEligibilityEngine engine = new DefaultRetryEligibilityEngine();
@Test
void allowsGetAfterConnectFailure() {
assertThat(engine.decide(RetryContexts.getConnectFailure())).isInstanceOf(RetryAllowed.class);
}
@Test
void marksPostWithoutKeyAmbiguousAfterSend() {
assertThat(engine.decide(RetryContexts.postSentNoResponseWithoutKey()))
.isInstanceOf(AmbiguousFailure.class);
}
@Test
void allowsPostWithIdempotencyKeyAfterSend() {
assertThat(engine.decide(RetryContexts.postSentNoResponseWithKey()))
.isInstanceOf(RetryAllowed.class);
}
@Test
void deniesOneShotBodyEvenForPut() {
assertThat(engine.decide(RetryContexts.putOneShotNotSent())).isInstanceOf(RetryDenied.class);
}
@Test
void honorsRetryAfterOnlyInsideDeadline() {
assertThat(engine.decide(RetryContexts.rateLimitedBeyondDeadline()))
.isInstanceOf(RetryDenied.class);
RetryDecision allowed = engine.decide(RetryContexts.rateLimitedWithinDeadline());
assertThat(allowed).isInstanceOf(RetryAllowed.class);
assertThat(((RetryAllowed) allowed).retryAfter()).isPresent();
}
@Test
void deniesAfterFirstByteWasDelivered() {
assertThat(engine.decide(RetryContexts.firstByteDelivered()))
.isEqualTo(RetryDenied.responseAlreadyDelivered());
}
@Test
void deniesPermanentTlsFailure() {
assertThat(engine.decide(RetryContexts.permanentTlsFailure())).isInstanceOf(RetryDenied.class);
}
@Test
void deniesWhenBudgetIsExhaustedOrRuntimeIsDraining() {
assertThat(
engine.decide(RetryContexts.builder().budget(RetryBudgetSnapshot.exhausted()).build()))
.isEqualTo(RetryDenied.budgetExhausted());
assertThat(engine.decide(RetryContexts.builder().runtimeDraining(true).build()))
.isEqualTo(RetryDenied.runtimeDraining());
}
@Test
void deniesWhenAttemptsAreExhausted() {
assertThat(engine.decide(RetryContexts.builder().attempt(3).maxAttempts(3).build()))
.isEqualTo(RetryDenied.maxAttempts());
}
@ParameterizedTest
@ValueSource(ints = {408, 429, 502, 503, 504})
void allowsRetryableStatusesForSafeOperations(int status) {
assertThat(
engine.decide(RetryContexts.status(status, OperationIdempotency.STANDARD_IDEMPOTENT)))
.isInstanceOf(RetryAllowed.class);
}
@ParameterizedTest
@ValueSource(ints = {400, 403, 404, 409, 422})
void deniesNonRetryableClientStatuses(int status) {
assertThat(
engine.decide(RetryContexts.status(status, OperationIdempotency.STANDARD_IDEMPOTENT)))
.isEqualTo(RetryDenied.notRetryableStatus(status));
}
@Test
void deniesFiveHundredUnlessRegisteredTransient() {
assertThat(engine.decide(RetryContexts.status(500, OperationIdempotency.STANDARD_IDEMPOTENT)))
.isEqualTo(RetryDenied.notRetryableStatus(500));
assertThat(
engine.decide(
RetryContexts.builder()
.evidence(ExecutionEvidence.RESPONSE_RECEIVED)
.failureCategory(FailureCategory.REMOTE_STATUS)
.status(500)
.transientServerErrors(Set.of(500))
.build()))
.isInstanceOf(RetryAllowed.class);
}
@Test
void allowsFourHundredOneRefreshExactlyOnce() {
assertThat(engine.decide(RetryContexts.status(401, OperationIdempotency.STANDARD_IDEMPOTENT)))
.isInstanceOf(RetryAllowed.class);
assertThat(
engine.decide(
RetryContexts.builder()
.evidence(ExecutionEvidence.RESPONSE_RECEIVED)
.failureCategory(FailureCategory.REMOTE_STATUS)
.status(401)
.attempt(2)
.build()))
.isEqualTo(RetryDenied.notRetryableStatus(401));
}
@Test
void deniesFourHundredOneReplayForNonReplayableOrUnsafeOperations() {
assertThat(
engine.decide(
RetryContexts.builder()
.evidence(ExecutionEvidence.RESPONSE_RECEIVED)
.failureCategory(FailureCategory.REMOTE_STATUS)
.status(401)
.replayability(BodyReplayability.ONE_SHOT)
.build()))
.isEqualTo(RetryDenied.bodyNotReplayable());
assertThat(
engine.decide(
RetryContexts.builder()
.idempotency(OperationIdempotency.NON_IDEMPOTENT)
.evidence(ExecutionEvidence.RESPONSE_RECEIVED)
.failureCategory(FailureCategory.REMOTE_STATUS)
.status(401)
.build()))
.isEqualTo(RetryDenied.notRetryableStatus(401));
}
@Test
void deniesUnsafeFiveHundredTwoAsAmbiguousRatherThanRetry() {
assertThat(engine.decide(RetryContexts.status(503, OperationIdempotency.NON_IDEMPOTENT)))
.isInstanceOf(AmbiguousFailure.class);
}
/**
* 408, 425 and 429 are answers, not proof that nothing happened.
*
* <p>All three used to bypass the safety check entirely. A non-idempotent POST that a
* rate-limited upstream had already accepted was resent on a 429, which is how a duplicate charge
* happens under load — precisely when a rate limiter is most likely to answer.
*/
@Test
void answeredStatusesDoNotRetryANonIdempotentOperation() {
assertThat(engine.decide(answered(408, OperationIdempotency.NON_IDEMPOTENT)))
.isInstanceOf(AmbiguousFailure.class);
assertThat(engine.decide(answered(425, OperationIdempotency.NON_IDEMPOTENT)))
.isInstanceOf(AmbiguousFailure.class);
assertThat(engine.decide(answered(429, OperationIdempotency.NON_IDEMPOTENT)))
.isEqualTo(RetryDenied.notRetryableStatus(429));
}
@Test
void answeredStatusesStillRetryASafeOperation() {
assertThat(engine.decide(answered(408, OperationIdempotency.STANDARD_IDEMPOTENT)))
.isInstanceOf(RetryAllowed.class);
assertThat(engine.decide(answered(429, OperationIdempotency.STANDARD_IDEMPOTENT)))
.isInstanceOf(RetryAllowed.class);
}
/**
* A key the platform never sent buys nothing.
*
* <p>The upstream cannot deduplicate against a header it did not receive, so the operation is as
* unsafe to repeat as one with no key at all.
*/
@Test
void aKeyThatWasNeverSentDoesNotMakeARepeatSafe() {
assertThat(
engine.decide(
RetryContexts.builder()
.idempotency(OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED)
.idempotencyKey(
new dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey(
"order-1"))
.idempotencyKeySent(false)
.evidence(ExecutionEvidence.SENT_NO_RESPONSE)
.failureCategory(FailureCategory.RESPONSE_TIMEOUT)
.build()))
.isInstanceOf(AmbiguousFailure.class);
}
private static RetryContext answered(int status, OperationIdempotency idempotency) {
return RetryContexts.builder()
.idempotency(idempotency)
.evidence(ExecutionEvidence.RESPONSE_RECEIVED)
.failureCategory(FailureCategory.REMOTE_STATUS)
.status(status)
.build();
}
}
@@ -0,0 +1,175 @@
package dev.caskeleton.adapter.outbound.httpclient.restclient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.httpclient.api.OperationName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRedirectRejectedException;
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.profile.ClientProfile;
import dev.caskeleton.adapter.outbound.httpclient.profile.RedirectSettings;
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 org.junit.jupiter.api.Test;
class BlockingRedirectCoordinatorTest {
@Test
void refusesRedirectsWhenTheProfileDisablesThem() throws Exception {
try (MockHttpServer server = MockHttpServer.start();
TestGateways.Harness harness = TestGateways.apache(server.uri("/"))) {
server.enqueueRedirect(302, "/moved");
assertThatThrownBy(
() ->
harness
.gateway()
.exchange(
harness.profile().name(),
HttpOperation.get(new OperationName("get-user"), "/users/1", Map.of()),
ResponseType.of(UserResponse.class)))
.isInstanceOf(HttpRedirectRejectedException.class)
.hasMessageContaining("REDIRECT_DISABLED");
assertThat(server.requestCount()).isEqualTo(1);
}
}
@Test
void followsABoundedSameOriginHopWhenEnabled() throws Exception {
try (MockHttpServer server = MockHttpServer.start()) {
ClientProfile profile =
ClientProfiles.builder("redirecting")
.baseUrl(server.uri("/"))
.redirect(new RedirectSettings(true, 2, false))
.build();
try (TestGateways.Harness harness = TestGateways.forProfile(profile)) {
server.enqueueRedirect(302, "/users/2");
server.enqueueJson(200, "{\"id\":2,\"name\":\"moved\"}");
HttpCallResult<UserResponse> result =
harness
.gateway()
.exchange(
profile.name(),
HttpOperation.get(new OperationName("get-user"), "/users/1", Map.of()),
ResponseType.of(UserResponse.class));
assertThat(result.body().id()).isEqualTo(2);
assertThat(server.requestCount()).isEqualTo(2);
assertThat(server.takeRequest(Duration.ofSeconds(2)).path()).isEqualTo("/users/1");
assertThat(server.takeRequest(Duration.ofSeconds(2)).path()).isEqualTo("/users/2");
}
}
}
@Test
void stopsAtTheConfiguredHopLimit() throws Exception {
try (MockHttpServer server = MockHttpServer.start()) {
ClientProfile profile =
ClientProfiles.builder("looping")
.baseUrl(server.uri("/"))
.redirect(new RedirectSettings(true, 1, false))
.build();
try (TestGateways.Harness harness = TestGateways.forProfile(profile)) {
server.enqueueRedirect(302, "/hop-1");
server.enqueueRedirect(302, "/hop-2");
assertThatThrownBy(
() ->
harness
.gateway()
.exchange(
profile.name(),
HttpOperation.get(new OperationName("get-user"), "/users/1", Map.of()),
ResponseType.of(UserResponse.class)))
.isInstanceOf(HttpRedirectRejectedException.class)
.hasMessageContaining("MAX_HOPS");
}
}
}
/**
* A hop must satisfy the profile's own allowlist, not only the redirect policy.
*
* <p>The two were never both applied. The redirect policy judged hop count, origin change and
* method rewrite; nothing re-checked the destination against the hosts and ports the operator had
* allowed. An upstream could therefore move a trusted profile to an origin the allowlist excluded
* simply by answering 302.
*/
@Test
void aHopIsRejectedWhenTheProfileAllowlistExcludesIt() throws Exception {
try (MockHttpServer server = MockHttpServer.start()) {
ClientProfile profile =
ClientProfiles.builder("guarded")
.baseUrl(server.uri("/"))
.allowedHosts(java.util.Set.of("only-this-host.test"))
.redirect(new RedirectSettings(true, 2, true))
.build();
try (TestGateways.Harness harness = TestGateways.forProfile(profile)) {
server.enqueueRedirect(302, "/users/2");
assertThatThrownBy(
() ->
harness
.gateway()
.exchange(
profile.name(),
HttpOperation.get(new OperationName("get-user"), "/users/1", Map.of()),
ResponseType.of(UserResponse.class)))
.isInstanceOf(
dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException
.class)
.hasMessageContaining("allowlist");
}
}
}
/**
* 303 means "fetch this other thing with GET", which includes dropping the body.
*
* <p>Only the method was changed before, so the original payload was resent as the body of a GET
* to a destination the upstream had chosen.
*/
@Test
void aSeeOtherHopDropsTheRequestBody() throws Exception {
try (MockHttpServer server = MockHttpServer.start()) {
ClientProfile profile =
ClientProfiles.builder("see-other")
.baseUrl(server.uri("/"))
.redirect(new RedirectSettings(true, 2, false))
.build();
try (TestGateways.Harness harness = TestGateways.forProfile(profile)) {
server.enqueueRedirect(303, "/users/2");
server.enqueueJson(200, "{\"id\":2,\"name\":\"moved\"}");
harness
.gateway()
.exchange(
profile.name(),
new HttpOperation(
new OperationName("create-user"),
dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod.POST,
"/users",
Map.of(),
Map.of(),
dev.caskeleton.adapter.outbound.httpclient.api.body.ObjectBody.json(
Map.of("name", "grace")),
dev.caskeleton.adapter.outbound.httpclient.api.operation.OperationIdempotency
.NON_IDEMPOTENT,
java.util.Optional.empty(),
java.util.Optional.empty()),
ResponseType.of(UserResponse.class));
server.takeRequest(Duration.ofSeconds(2));
dev.caskeleton.adapter.outbound.httpclient.testkit.RecordedHttpRequest hop =
server.takeRequest(Duration.ofSeconds(2));
assertThat(hop.method()).isEqualTo("GET");
assertThat(hop.bodyUtf8()).isEmpty();
}
}
}
}
@@ -0,0 +1,172 @@
package dev.caskeleton.adapter.outbound.httpclient.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.httpclient.apache.ApacheBlockingTransportProvider;
import dev.caskeleton.adapter.outbound.httpclient.apache.ApacheFailureClassifier;
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.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 dev.caskeleton.adapter.outbound.httpclient.testkit.TlsFixture;
import dev.caskeleton.adapter.outbound.httpclient.testkit.TlsMaterials;
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
import java.net.URI;
import java.util.Optional;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.web.client.RestClient;
/**
* Real TLS and mTLS handshakes against a fixture server (design §21, §28.5).
*
* <p>Every case here runs an actual handshake through the production Apache transport and the
* production {@code TlsMaterialProvider}. Asserting TLS policy only against the validator would
* prove the rules are written down, not that they hold on the wire.
*/
@Tag("httpclient-security")
class MutualTlsHandshakeContractTest {
private static ClientProfile tlsProfile(URI baseUrl) {
return ClientProfiles.builder("partner").baseUrl(baseUrl).build();
}
@Test
void aClientCertificateSatisfiesAServerThatRequiresOne() throws Exception {
TlsFixture fixture = TlsFixture.trusted();
try (MockHttpServer server = MockHttpServer.startTls(fixture.serverSocketFactory(), true)) {
server.enqueueJson(200, "{\"id\":1,\"name\":\"mtls\"}");
ClientProfile profile = tlsProfile(server.uri("/"));
ApacheBlockingTransportProvider provider =
new ApacheBlockingTransportProvider(
Optional.empty(),
ignored -> Optional.of(TlsMaterials.mutual(fixture)),
ignored -> Optional.empty());
try {
String body =
RestClient.builder()
.requestFactory(
provider.create(
profile,
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
NoopLifecycleListener.INSTANCE))
.build()
.get()
.uri(server.uri("/users/1"))
.retrieve()
.body(String.class);
assertThat(body).contains("mtls");
} finally {
provider.close(
profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1));
}
}
}
@Test
void aMissingClientCertificateCannotReachTheUpstream() throws Exception {
TlsFixture fixture = TlsFixture.trusted();
try (MockHttpServer server = MockHttpServer.startTls(fixture.serverSocketFactory(), true)) {
server.enqueueJson(200, "{\"id\":1,\"name\":\"never\"}");
ClientProfile profile = tlsProfile(server.uri("/"));
ApacheBlockingTransportProvider provider =
new ApacheBlockingTransportProvider(
Optional.empty(),
ignored -> Optional.of(TlsMaterials.trustOnly(fixture)),
ignored -> Optional.empty());
try {
RestClient client =
RestClient.builder()
.requestFactory(
provider.create(
profile,
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
NoopLifecycleListener.INSTANCE))
.build();
// Under TLS 1.3 the client finishes its half of the handshake before the server rejects it,
// so the failure can surface either during the handshake or on the first read. Both are
// failures; what must never happen is the request being served.
assertThatThrownBy(
() -> client.get().uri(server.uri("/users/1")).retrieve().body(String.class))
.isInstanceOf(RuntimeException.class);
} finally {
provider.close(
profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1));
}
}
}
@Test
void aHostnameMismatchIsAPermanentTlsFailure() throws Exception {
TlsFixture fixture = TlsFixture.hostnameMismatch();
assertPermanentTlsFailure(fixture);
}
@Test
void anExpiredCertificateIsAPermanentTlsFailure() throws Exception {
TlsFixture fixture = TlsFixture.expired();
assertPermanentTlsFailure(fixture);
}
@Test
void anUntrustedAuthorityIsAPermanentTlsFailure() throws Exception {
TlsFixture serverFixture = TlsFixture.trusted();
TlsFixture unrelatedClientTrust = TlsFixture.trusted();
try (MockHttpServer server =
MockHttpServer.startTls(serverFixture.serverSocketFactory(), false)) {
server.enqueueJson(200, "{\"id\":1,\"name\":\"never\"}");
ClientProfile profile = tlsProfile(server.uri("/"));
Throwable captured = captureFailure(profile, server, unrelatedClientTrust);
assertPermanent(captured);
}
}
private void assertPermanentTlsFailure(TlsFixture fixture) throws Exception {
try (MockHttpServer server = MockHttpServer.startTls(fixture.serverSocketFactory(), false)) {
server.enqueueJson(200, "{\"id\":1,\"name\":\"never\"}");
ClientProfile profile = tlsProfile(server.uri("/"));
Throwable captured = captureFailure(profile, server, fixture);
assertPermanent(captured);
}
}
private Throwable captureFailure(ClientProfile profile, MockHttpServer server, TlsFixture trust) {
ApacheBlockingTransportProvider provider =
new ApacheBlockingTransportProvider(
Optional.empty(),
ignored -> Optional.of(TlsMaterials.trustOnly(trust)),
ignored -> Optional.empty());
try {
RestClient client =
RestClient.builder()
.requestFactory(
provider.create(
profile,
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
NoopLifecycleListener.INSTANCE))
.build();
try {
client.get().uri(server.uri("/users/1")).retrieve().body(String.class);
throw new AssertionError("the handshake was expected to fail");
} catch (RuntimeException failure) {
return failure;
}
} finally {
provider.close(
profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1));
}
}
private void assertPermanent(Throwable captured) {
TransportFailure classified =
new ApacheFailureClassifier().classify(captured, AttemptStage.TLS_HANDSHAKE);
assertThat(classified.evidence()).isEqualTo(ExecutionEvidence.NOT_SENT);
assertThat(classified.stage()).isEqualTo(AttemptStage.TLS_HANDSHAKE);
assertThat(classified.category()).isEqualTo(FailureCategory.TLS_PERMANENT);
assertThat(classified.category().permanent()).isTrue();
}
}
@@ -0,0 +1,114 @@
package dev.caskeleton.adapter.outbound.httpclient.security;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeState;
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class TlsRuntimeRotationCoordinatorTest {
/** A profile whose client certificate is the one that rotated. */
private static dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile usingCertificate(
String name, String keyMaterialReference) {
return ClientProfiles.builder(name)
.tls(
new dev.caskeleton.adapter.outbound.httpclient.profile.TlsSettings(
java.util.Optional.of(name),
java.util.Set.of("TLSv1.3"),
true,
false,
false,
java.util.Optional.empty(),
java.util.Optional.of(keyMaterialReference)))
.build();
}
@Test
void swapsRuntimeWhenCertificateIdentityChanges() {
AtomicInteger closedGenerations = new AtomicInteger();
ClientRuntime first =
new ClientRuntime(
usingCertificate("partner", "cert-v2"),
new RuntimeGeneration(1),
closedGenerations::incrementAndGet);
try (ClientRuntimeRegistry registry = new ClientRuntimeRegistry(Map.of(first.name(), first))) {
TlsRuntimeRotationCoordinator coordinator =
new TlsRuntimeRotationCoordinator(
registry,
(profile, generation) ->
new ClientRuntime(profile, generation, closedGenerations::incrementAndGet),
Duration.ofSeconds(1));
coordinator.rotate(new ClientCertificateIdentity("cert-v2"));
try (ClientRuntimeLease lease = registry.acquire(new ClientProfileName("partner"))) {
assertThat(lease.runtime().generation().value()).isEqualTo(2);
}
assertThat(first.state()).isEqualTo(ClientRuntimeState.CLOSED);
assertThat(closedGenerations.get()).isEqualTo(1);
}
}
/**
* A rotation touches only the profiles that use the rotated certificate.
*
* <p>The identity argument used to be required and then ignored: every registered profile was
* rotated whatever had changed. Rotating discards a warm pool and forces fresh handshakes, so one
* certificate renewal produced a connection storm across every upstream the service talks to —
* and if the rotation was wrong, everything degraded at once with nothing pointing at the cause.
*/
@Test
void rotationTouchesOnlyTheProfilesThatUseTheRotatedCertificate() {
AtomicInteger closedGenerations = new AtomicInteger();
ClientRuntime affected =
new ClientRuntime(
usingCertificate("partner", "cert-v2"),
new RuntimeGeneration(1),
closedGenerations::incrementAndGet);
ClientRuntime unrelatedCertificate =
new ClientRuntime(
usingCertificate("billing", "other-cert"),
new RuntimeGeneration(1),
closedGenerations::incrementAndGet);
ClientRuntime noCertificate =
new ClientRuntime(
ClientProfiles.builder("search").build(),
new RuntimeGeneration(1),
closedGenerations::incrementAndGet);
try (ClientRuntimeRegistry registry =
new ClientRuntimeRegistry(
Map.of(
affected.name(), affected,
unrelatedCertificate.name(), unrelatedCertificate,
noCertificate.name(), noCertificate))) {
TlsRuntimeRotationCoordinator coordinator =
new TlsRuntimeRotationCoordinator(
registry,
(profile, generation) ->
new ClientRuntime(profile, generation, closedGenerations::incrementAndGet),
Duration.ofSeconds(1));
assertThat(coordinator.rotate(new ClientCertificateIdentity("cert-v2")))
.containsExactly(new ClientProfileName("partner"));
assertThat(registry.current(new ClientProfileName("partner")).generation().value())
.isEqualTo(2);
assertThat(registry.current(new ClientProfileName("billing")).generation().value())
.as("a profile using a different certificate keeps its warm pool")
.isEqualTo(1);
assertThat(registry.current(new ClientProfileName("search")).generation().value())
.as("a profile with no client certificate cannot be affected at all")
.isEqualTo(1);
}
}
}
@@ -0,0 +1,161 @@
package dev.caskeleton.adapter.outbound.httpclient.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
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.ByteArrayBody;
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
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.operation.OperationIdempotency;
import dev.caskeleton.adapter.outbound.httpclient.api.result.IdempotencyKeyRequirement;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class TrustedRequestPolicyTest {
private final TrustedTargetPolicy policy =
new TrustedTargetPolicy(ClientProfiles.builder("payment").build());
@Test
void rejectsAbsoluteUriInTrustedGenericGateway() {
HttpOperation absolute =
HttpOperation.get(
new OperationName("probe"), "https://evil.example.com/payments", Map.of());
assertThatThrownBy(() -> policy.prepare(absolute))
.isInstanceOf(HttpTargetRejectedException.class);
assertThatThrownBy(
() ->
policy.prepare(
HttpOperation.get(new OperationName("probe"), "//evil.example.com", Map.of())))
.isInstanceOf(HttpTargetRejectedException.class);
}
@Test
void rejectsHeaderInjection() {
HeaderPolicy headerPolicy = HeaderPolicy.defaultPolicy();
assertThatThrownBy(() -> headerPolicy.validate(Map.of("X-Test", List.of("ok\r\nBad: x"))))
.isInstanceOf(HttpTargetRejectedException.class);
}
@Test
void rejectsPlatformOwnedHeaderOverride() {
HeaderPolicy headerPolicy = HeaderPolicy.defaultPolicy();
assertThatThrownBy(() -> headerPolicy.validate(Map.of("Authorization", List.of("Bearer x"))))
.isInstanceOf(HttpTargetRejectedException.class);
assertThatThrownBy(() -> headerPolicy.validate(Map.of("Host", List.of("other.example.com"))))
.isInstanceOf(HttpTargetRejectedException.class);
}
@Test
void allowsIdempotencyKeyOnlyWhenTheOperationDeclaresIt() {
assertThatThrownBy(
() ->
HeaderPolicy.defaultPolicy()
.validate(Map.of("Idempotency-Key", List.of("order-1"))))
.isInstanceOf(HttpTargetRejectedException.class);
assertThat(
HeaderPolicy.forOperation(IdempotencyKeyRequirement.required("Idempotency-Key"), false)
.validate(Map.of("Idempotency-Key", List.of("order-1"))))
.containsKey("Idempotency-Key");
}
@Test
void rejectsKnownBodyLargerThanProfileLimit() {
assertThatThrownBy(
() ->
BodyLimitPolicy.maxRequestBytes(4)
.validate(new ByteArrayBody(new byte[5], "application/octet-stream")))
.isInstanceOf(HttpConfigurationException.class);
}
@Test
void encodesTemplateVariablesPerComponent() {
HttpOperation operation =
HttpOperation.get(new OperationName("get-user"), "/users/{id}", Map.of("id", "a/b?c=d"));
PreparedOperation prepared = policy.prepare(operation);
assertThat(prepared.target().uri().getRawPath()).isEqualTo("/users/a%2Fb%3Fc%3Dd");
assertThat(prepared.target().uriTemplate()).isEqualTo("/users/{id}");
assertThat(prepared.target().host()).isEqualTo("payment.example.com");
}
@Test
void carriesProfileBudgetsForwardToTheExecutor() {
HttpOperation operation =
new HttpOperation(
new OperationName("create-payment"),
HttpMethod.POST,
"/payments",
Map.of(),
Map.of(),
EmptyBody.instance(),
OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED,
Optional.of(new IdempotencyKey("order-1")),
Optional.empty());
PreparedOperation prepared = policy.prepare(operation);
assertThat(prepared.maxRequestBytes()).isEqualTo(1024L * 1024);
assertThat(prepared.maxResponseDecodedBytes()).isEqualTo(10L * 1024 * 1024);
}
/**
* A registered key must reach the wire.
*
* <p>It used to be carried on the operation, consulted by the retry engine as proof that repeats
* were safe, and never written to a header. The upstream received no key, could not deduplicate,
* and the platform retried anyway — so a non-idempotent operation with a key was strictly more
* dangerous than one without.
*/
@Test
void aRegisteredIdempotencyKeyIsRenderedAsAHeader() {
PreparedOperation prepared = policy.prepare(keyedPayment());
assertThat(prepared.headers()).containsEntry("Idempotency-Key", java.util.List.of("order-1"));
assertThat(prepared.idempotencyKeySent()).isTrue();
}
/** Two keys for one request is a contradiction, not a merge. */
@Test
void aCallerSuppliedIdempotencyHeaderIsRefused() {
HttpOperation operation =
keyedPayment().withHeaders(Map.of("Idempotency-Key", java.util.List.of("caller-chosen")));
assertThatThrownBy(() -> policy.prepare(operation))
.isInstanceOf(
dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException.class)
.hasMessageContaining("owned by the platform");
}
/** A key with no requirement to carry it would be silently dropped. */
@Test
void aKeyWhoseRequirementWasNotRegisteredIsRefused() {
assertThatThrownBy(
() ->
policy.prepare(
keyedPayment(),
dev.caskeleton.adapter.outbound.httpclient.api.result.IdempotencyKeyRequirement
.none()))
.isInstanceOf(
dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException.class)
.hasMessageContaining("no header to send it in");
}
private static HttpOperation keyedPayment() {
return new HttpOperation(
new OperationName("create-payment"),
HttpMethod.POST,
"/payments",
Map.of(),
Map.of(),
EmptyBody.instance(),
OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED,
Optional.of(new IdempotencyKey("order-1")),
Optional.empty());
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.adapter.outbound.httpclient.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
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.UsersClient;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class BlockingHttpServiceRegistryTest {
@Test
void createsTypedProxyBoundToNamedProfile() throws Exception {
try (MockHttpServer server = MockHttpServer.start();
TestGateways.Harness harness =
TestGateways.forProfile(
ClientProfiles.builder("users").baseUrl(server.uri("/")).build())) {
server.enqueueJson(200, "{\"id\":7,\"name\":\"grace\"}");
HttpServiceRegistry registry =
new DefaultHttpServiceRegistry(harness.registry(), harness.gateway());
assertThat(registry.client(harness.profile().name(), UsersClient.class).get(7).id())
.isEqualTo(7);
assertThat(server.takeRequest(Duration.ofSeconds(2)).path()).isEqualTo("/users/7");
}
}
@Test
void operationContextIsEmptyAfterSuccessAndFailure() throws Exception {
try (MockHttpServer server = MockHttpServer.start();
TestGateways.Harness harness =
TestGateways.forProfile(
ClientProfiles.builder("users").baseUrl(server.uri("/")).build())) {
HttpServiceRegistry registry =
new DefaultHttpServiceRegistry(harness.registry(), harness.gateway());
UsersClient client = registry.client(harness.profile().name(), UsersClient.class);
server.enqueueJson(200, "{\"id\":1,\"name\":\"a\"}");
client.get(1);
assertThat(OperationContextHolder.instance().empty()).isTrue();
server.enqueueStatus(500);
assertThatThrownBy(() -> client.get(2)).isInstanceOf(RuntimeException.class);
assertThat(OperationContextHolder.instance().empty()).isTrue();
}
}
@Test
void resolvesTheProfileFromTheInterfaceDeclaration() throws Exception {
try (MockHttpServer server = MockHttpServer.start();
TestGateways.Harness harness =
TestGateways.forProfile(
ClientProfiles.builder("users").baseUrl(server.uri("/")).build())) {
server.enqueueJson(200, "{\"id\":3,\"name\":\"c\"}");
HttpServiceRegistry registry =
new DefaultHttpServiceRegistry(harness.registry(), harness.gateway());
assertThat(registry.client(UsersClient.class).get(3).name()).isEqualTo("c");
}
}
@Test
void returnsTheSameProxyForRepeatedLookups() throws Exception {
try (MockHttpServer server = MockHttpServer.start();
TestGateways.Harness harness =
TestGateways.forProfile(
ClientProfiles.builder("users").baseUrl(server.uri("/")).build())) {
HttpServiceRegistry registry =
new DefaultHttpServiceRegistry(harness.registry(), harness.gateway());
assertThat(registry.client(UsersClient.class))
.isSameAs(registry.client(harness.profile().name(), UsersClient.class));
}
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.adapter.outbound.httpclient.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
import dev.caskeleton.adapter.outbound.httpclient.testkit.MixedSignatureClient;
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ReactiveEventsClient;
import dev.caskeleton.adapter.outbound.httpclient.testkit.TestGateways;
import org.junit.jupiter.api.Test;
class BlockingReactiveSignatureSeparationTest {
private final ServiceOperationDescriptorScanner scanner = new ServiceOperationDescriptorScanner();
@Test
void classifiesAnInterfaceAsEitherBlockingOrReactive() {
assertThat(scanner.scan(ReactiveEventsClient.class))
.allSatisfy(descriptor -> assertThat(descriptor.reactive()).isTrue());
}
@Test
void refusesAnAmbiguousInterfaceOutright() {
assertThatThrownBy(() -> scanner.scan(MixedSignatureClient.class))
.isInstanceOf(HttpConfigurationException.class);
}
@Test
void rejectsAReactiveInterfaceOnTheBlockingRegistry() throws Exception {
try (MockHttpServer server = MockHttpServer.start();
TestGateways.Harness harness =
TestGateways.forProfile(
ClientProfiles.builder("events").baseUrl(server.uri("/")).build())) {
HttpServiceRegistry registry =
new DefaultHttpServiceRegistry(harness.registry(), harness.gateway());
assertThatThrownBy(() -> registry.client(ReactiveEventsClient.class))
.isInstanceOf(HttpConfigurationException.class)
.hasMessageContaining("reactive");
}
}
}
@@ -0,0 +1,89 @@
package dev.caskeleton.adapter.outbound.httpclient.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ReactiveEventsClient;
import dev.caskeleton.adapter.outbound.httpclient.testkit.ReactiveTestGateways;
import dev.caskeleton.adapter.outbound.httpclient.testkit.UsersClient;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
class ReactiveHttpServiceRegistryTest {
@Test
void returnsTypedReactiveResultsThroughTheProfileRuntime() throws Exception {
try (MockHttpServer server = MockHttpServer.start();
ReactiveTestGateways.Harness harness = ReactiveTestGateways.reactor(server.uri("/"))) {
server.enqueueJson(200, "{\"id\":\"e1\",\"value\":\"v\"}");
ReactiveHttpServiceRegistry registry =
new DefaultReactiveHttpServiceRegistry(harness.registry(), harness.gateway());
StepVerifier.create(
registry.client(harness.profile().name(), ReactiveEventsClient.class).get("e1"))
.expectNextMatches(event -> event.id().equals("e1"))
.verifyComplete();
}
}
/**
* The descriptor must be visible to the operators the platform composes <em>upstream</em> of the
* proxy — which is where filters and observation live — so the assertion is made from inside the
* delegate's own subscription rather than from a downstream operator.
*/
@Test
void propagatesOperationDescriptorThroughReactorContext() throws Exception {
ServiceOperationDescriptorScanner scanner = new ServiceOperationDescriptorScanner();
Map<Method, ServiceOperationDescriptor> descriptors = new LinkedHashMap<>();
scanner
.scan(ReactiveEventsClient.class)
.forEach(descriptor -> descriptors.put(descriptor.method(), descriptor));
AtomicReference<Optional<ServiceOperationDescriptor>> observed =
new AtomicReference<>(Optional.empty());
ReactiveEventsClient delegate =
id ->
Mono.deferContextual(
context -> {
observed.set(ReactiveOperationContext.from(context));
return Mono.just(new ReactiveEventsClient.EventResponse(id, "v"));
});
ReactiveEventsClient proxied =
(ReactiveEventsClient)
Proxy.newProxyInstance(
ReactiveEventsClient.class.getClassLoader(),
new Class<?>[] {ReactiveEventsClient.class},
new ReactiveServiceInvocationHandler(
delegate,
new ClientProfileName("events"),
descriptors,
OperationContextHolder.instance()));
StepVerifier.create(proxied.get("e1")).expectNextCount(1).verifyComplete();
assertThat(observed.get().map(descriptor -> descriptor.operationName().value()))
.contains("get-event");
}
@Test
void rejectsABlockingInterfaceOnTheReactiveRegistry() throws Exception {
try (MockHttpServer server = MockHttpServer.start();
ReactiveTestGateways.Harness harness = ReactiveTestGateways.reactor(server.uri("/"))) {
ReactiveHttpServiceRegistry registry =
new DefaultReactiveHttpServiceRegistry(harness.registry(), harness.gateway());
assertThatThrownBy(() -> registry.client(UsersClient.class))
.isInstanceOf(HttpConfigurationException.class)
.hasMessageContaining("blocking");
}
}
}

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