feat(httpclient): close the platform review's P0/P1/P2 findings
The review found one defect shape repeated across the platform: surfaces that were declared, bound, and documented, but that nothing read. An operator configuring fullUrlRecording, bodyLogging, retry.policy, validatedDnsPinning, timeout.dns, or any of ten declared metric names got a guarantee the code never delivered. Every such surface is now in exactly one of three states -- wired for real, rejected at startup, or registered in a test-enforced gap list with its reason. No silent no-ops remain. P0: - Activate the platform from bootstrap behind app.httpclient.enabled, with a single auto-configuration importing the nine child configurations. - Give the platform a strict, repository-level ENV contract: 74 leaf fields derived from the settings record tree, unknown APP_HTTPCLIENT_* rejected. - Route typed HTTP service clients through the call kernel via KernelHttpExchangeAdapter, so they stop bypassing platform policy. - Pin dynamic-target DNS resolution to the socket for the life of a call, closing the resolve-then-connect TOCTOU / rebinding window. - Actually transmit the idempotency key, and make retry eligibility depend on transmission rather than on merely holding one. - Reject reactive authentication and reactive redirect at startup instead of declaring support that does not function. - Fix the Reactor-only Stable contract row so the lane stops failing. - Stop advertising HTTP/3 on a transport that negotiates HTTP/1. P1 covers execution and retry accounting, redirect security (per-hop target guarding, sensitive-header stripping, 303 body handling), runtime rotation and transport resource ownership keyed by generation, dynamic-target hardening (subdomain matching, global-unicast classification, strict CIDR parsing), protocol intent, pool and timeout wiring, streaming and body limits, observability parity, and OAuth single-flight refresh on a bounded pool with a bounded wait. P2 covers configuration and documentation drift, the Gradle check wiring for the four hermetic lanes, and the CI gate matrix. Two test-quality defects surfaced while closing these: the HTTP/2 stream saturation test ran against cleartext HTTP/1.1 while asserting nothing about the protocol, and an OAuth contention test slept on a latch that could fire before the callers it meant to observe. Both now assert what their names claim. Verification run: :adapter:outbound:httpclient:check and :app-bootstrap:check (checkstyle, spotless, spotbugs, and the four hermetic lanes), verifyCleanArchitectureDependencies, verifyEnvKeys, verifyOneTypePerFile, verifyDependencyLocks, the documentation and gate-matrix verifiers, and the performance lane against a real TLS+ALPN HTTP/2 server. Not executed, and tracked rather than claimed: Docker/Toxiproxy fault injection, JMH, a real QUIC/HTTP3 server, a real Spring Framework 6.2 distribution (now a delegated-pending gate), live OAuth/TLS/proxy/DNS integration, and a whole-repository check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5f10b791d3
commit
0cd959a494
@@ -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
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.idempotency;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* The idempotency record's state machine, one atomic program per transition.
|
||||
*
|
||||
* <p>Each program re-reads the record, checks the owner <em>and</em> the state revision, and only
|
||||
* then mutates. Both halves are necessary. The owner alone would let a holder whose lease expired —
|
||||
* and whose claim was taken over — write over the new holder's work. The revision alone would let a
|
||||
* different owner at the same revision do it. Together they are an optimistic compare-and-set, and
|
||||
* every successful transition bumps the revision so a stale handle can never be reused.
|
||||
*
|
||||
* <p>The record is a hash, not a string, because the transitions touch different fields and a
|
||||
* read-modify-write of a serialized blob would reintroduce exactly the race the programs remove.
|
||||
*
|
||||
* <p>Nothing here interprets the stored response. It is an opaque payload the application encoded;
|
||||
* this adapter stores and returns bytes, so a codec change is a concern of whoever wrote them.
|
||||
*/
|
||||
public final class IdempotencyScripts {
|
||||
|
||||
/**
|
||||
* Claim: create, replay, take over an expired lease, or report why not.
|
||||
*
|
||||
* <p>The fingerprint is compared before anything else. Two different requests that hash to the
|
||||
* same idempotency scope are a client error, and treating the second as a replay of the first
|
||||
* would return somebody else's response.
|
||||
*/
|
||||
private static final String CLAIM =
|
||||
"""
|
||||
local state = redis.call('HGET', KEYS[1], 'state')
|
||||
local nowMillis = tonumber(ARGV[6])
|
||||
if state == false then
|
||||
redis.call('HSET', KEYS[1],
|
||||
'state', 'CLAIMED', 'owner', ARGV[1], 'attempt', 1, 'rev', 1,
|
||||
'op', ARGV[2], 'fp', ARGV[3], 'codec', ARGV[4], 'policy', ARGV[5],
|
||||
'leaseUntil', nowMillis + tonumber(ARGV[7]))
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[8])
|
||||
return {'ACQUIRED', 1, 1, ARGV[1], '', tostring(nowMillis + tonumber(ARGV[7]))}
|
||||
end
|
||||
local fingerprint = redis.call('HGET', KEYS[1], 'fp')
|
||||
if fingerprint ~= ARGV[3] then
|
||||
return {'FINGERPRINT_MISMATCH', 0, 0, '', '', ''}
|
||||
end
|
||||
local owner = redis.call('HGET', KEYS[1], 'owner')
|
||||
local op = redis.call('HGET', KEYS[1], 'op')
|
||||
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
|
||||
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
|
||||
if state == 'COMPLETED' then
|
||||
return {'COMPLETED_REPLAY', attempt, rev, owner, redis.call('HGET', KEYS[1], 'resp'),
|
||||
tostring(redis.call('PTTL', KEYS[1]))}
|
||||
end
|
||||
if state == 'ABANDONED' then
|
||||
return {'RECOVERY_REQUIRED', attempt, rev, owner, '', ''}
|
||||
end
|
||||
if owner == ARGV[1] then
|
||||
if op ~= ARGV[2] then
|
||||
return {'OWNER_OPERATION_CONFLICT', attempt, rev, owner, '', ''}
|
||||
end
|
||||
-- Same owner, same operation: a retry whose first reply was lost.
|
||||
return {'REPLAYED_ACQUIRE', attempt, rev, owner, '',
|
||||
redis.call('HGET', KEYS[1], 'leaseUntil')}
|
||||
end
|
||||
local leaseUntil = tonumber(redis.call('HGET', KEYS[1], 'leaseUntil'))
|
||||
if state == 'FAILED_RETRYABLE' or (leaseUntil ~= nil and leaseUntil <= nowMillis) then
|
||||
-- The previous holder's processing lease expired, or they marked the attempt retryable.
|
||||
-- Taking over bumps the attempt so the new holder can tell it is not the first.
|
||||
redis.call('HSET', KEYS[1],
|
||||
'state', 'CLAIMED', 'owner', ARGV[1], 'attempt', attempt + 1, 'rev', rev + 1,
|
||||
'op', ARGV[2], 'leaseUntil', nowMillis + tonumber(ARGV[7]))
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[8])
|
||||
return {'TAKEN_OVER', attempt + 1, rev + 1, ARGV[1], '',
|
||||
tostring(nowMillis + tonumber(ARGV[7]))}
|
||||
end
|
||||
return {'IN_PROGRESS', attempt, rev, owner, '', tostring(leaseUntil - nowMillis)}
|
||||
""";
|
||||
|
||||
/** A generic owner+revision compare-and-set transition. */
|
||||
private static final String TRANSITION =
|
||||
"""
|
||||
local state = redis.call('HGET', KEYS[1], 'state')
|
||||
if state == false then
|
||||
return {'ABSENT', 0, 0, '', '', ''}
|
||||
end
|
||||
local owner = redis.call('HGET', KEYS[1], 'owner')
|
||||
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
|
||||
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
|
||||
local op = redis.call('HGET', KEYS[1], 'op')
|
||||
if owner ~= ARGV[1] then
|
||||
return {'NOT_OWNER', attempt, rev, owner, '', ''}
|
||||
end
|
||||
if op ~= ARGV[3] then
|
||||
return {'OPERATION_CONFLICT', attempt, rev, owner, '', ''}
|
||||
end
|
||||
if state == ARGV[5] then
|
||||
-- Already in the target state, under the same owner and the same operation: a retry whose
|
||||
-- first reply was lost, not a second transition. This is checked BEFORE the revision,
|
||||
-- deliberately. The caller's handle necessarily carries the pre-transition revision — they
|
||||
-- never received the reply that would have replaced it — so a revision check first would
|
||||
-- turn every lost reply into NOT_OWNER and make the idempotent call non-idempotent. The
|
||||
-- owner and operation already prove the record was moved by this caller and nobody else.
|
||||
return {'ALREADY', attempt, rev, owner, redis.call('HGET', KEYS[1], 'resp') or '', ''}
|
||||
end
|
||||
if rev ~= tonumber(ARGV[2]) then
|
||||
-- Stale handle: somebody moved the record on after this owner read it, and the target
|
||||
-- state is not where they left it.
|
||||
return {'NOT_OWNER', attempt, rev, owner, '', ''}
|
||||
end
|
||||
if state ~= ARGV[4] then
|
||||
return {'WRONG_STATE', attempt, rev, owner, state, ''}
|
||||
end
|
||||
redis.call('HSET', KEYS[1], 'state', ARGV[5], 'rev', rev + 1)
|
||||
if ARGV[6] ~= '' then
|
||||
redis.call('HSET', KEYS[1], 'resp', ARGV[6])
|
||||
end
|
||||
if ARGV[7] ~= '' then
|
||||
redis.call('HSET', KEYS[1], 'leaseUntil', ARGV[7])
|
||||
end
|
||||
if ARGV[8] ~= '' then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[8])
|
||||
end
|
||||
return {'APPLIED', attempt, rev + 1, owner, '', ''}
|
||||
""";
|
||||
|
||||
/** Release before execution: only from CLAIMED, and only by the owner that holds it. */
|
||||
private static final String RELEASE =
|
||||
"""
|
||||
local state = redis.call('HGET', KEYS[1], 'state')
|
||||
if state == false then
|
||||
return {'ABSENT', 0, 0, '', '', ''}
|
||||
end
|
||||
local owner = redis.call('HGET', KEYS[1], 'owner')
|
||||
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
|
||||
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
|
||||
local op = redis.call('HGET', KEYS[1], 'op')
|
||||
if owner ~= ARGV[1] then
|
||||
return {'NOT_OWNER', attempt, rev, owner, '', ''}
|
||||
end
|
||||
if op ~= ARGV[3] then
|
||||
return {'OPERATION_CONFLICT', attempt, rev, owner, '', ''}
|
||||
end
|
||||
if state ~= 'CLAIMED' then
|
||||
return {'WRONG_STATE', attempt, rev, owner, state, ''}
|
||||
end
|
||||
redis.call('DEL', KEYS[1])
|
||||
return {'APPLIED', attempt, rev, owner, '', ''}
|
||||
""";
|
||||
|
||||
/** Inspect: read the record without touching it. */
|
||||
private static final String INSPECT =
|
||||
"""
|
||||
local state = redis.call('HGET', KEYS[1], 'state')
|
||||
if state == false then
|
||||
return {'ABSENT', 0, 0, '', '', ''}
|
||||
end
|
||||
local fingerprint = redis.call('HGET', KEYS[1], 'fp')
|
||||
if fingerprint ~= ARGV[2] then
|
||||
return {'FINGERPRINT_MISMATCH', 0, 0, '', '', ''}
|
||||
end
|
||||
local owner = redis.call('HGET', KEYS[1], 'owner')
|
||||
local rev = tonumber(redis.call('HGET', KEYS[1], 'rev'))
|
||||
local attempt = tonumber(redis.call('HGET', KEYS[1], 'attempt'))
|
||||
local op = redis.call('HGET', KEYS[1], 'op')
|
||||
local mine = 'OTHER'
|
||||
if owner == ARGV[1] then
|
||||
if op == ARGV[3] then
|
||||
mine = 'MINE'
|
||||
else
|
||||
mine = 'OPERATION_CONFLICT'
|
||||
end
|
||||
end
|
||||
return {state, attempt, rev, owner,
|
||||
redis.call('HGET', KEYS[1], 'resp') or '',
|
||||
mine .. '|' .. tostring(redis.call('PTTL', KEYS[1]))}
|
||||
""";
|
||||
|
||||
private final Map<String, AtomicReference<String>> digests = new LinkedHashMap<>();
|
||||
|
||||
CompletionStage<Reply> claim(RedisCommandGateway gateway, byte[] key, List<String> arguments) {
|
||||
return run(gateway, "claim", CLAIM, key, arguments);
|
||||
}
|
||||
|
||||
CompletionStage<Reply> transition(
|
||||
RedisCommandGateway gateway, byte[] key, List<String> arguments) {
|
||||
return run(gateway, "transition", TRANSITION, key, arguments);
|
||||
}
|
||||
|
||||
CompletionStage<Reply> release(RedisCommandGateway gateway, byte[] key, List<String> arguments) {
|
||||
return run(gateway, "release", RELEASE, key, arguments);
|
||||
}
|
||||
|
||||
CompletionStage<Reply> inspect(RedisCommandGateway gateway, byte[] key, List<String> arguments) {
|
||||
return run(gateway, "inspect", INSPECT, key, arguments);
|
||||
}
|
||||
|
||||
private CompletionStage<Reply> run(
|
||||
RedisCommandGateway gateway, String name, String source, byte[] key, List<String> arguments) {
|
||||
AtomicReference<String> cache =
|
||||
digests.computeIfAbsent(name, unused -> new AtomicReference<>());
|
||||
List<byte[]> encoded = new ArrayList<>(arguments.size());
|
||||
for (String argument : arguments) {
|
||||
encoded.add(argument.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return digest(gateway, source, cache)
|
||||
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, encoded))
|
||||
.handle(
|
||||
(reply, failure) ->
|
||||
failure == null
|
||||
? CompletableFuture.completedFuture(reply)
|
||||
: reload(gateway, source, cache, key, encoded, failure))
|
||||
.thenCompose(stage -> stage)
|
||||
.thenApply(IdempotencyScripts::replyOf);
|
||||
}
|
||||
|
||||
private CompletionStage<List<Object>> reload(
|
||||
RedisCommandGateway gateway,
|
||||
String source,
|
||||
AtomicReference<String> cache,
|
||||
byte[] key,
|
||||
List<byte[]> arguments,
|
||||
Throwable failure) {
|
||||
if (!scriptMissing(failure)) {
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
}
|
||||
cache.set(null);
|
||||
return digest(gateway, source, cache)
|
||||
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments));
|
||||
}
|
||||
|
||||
private static CompletionStage<String> digest(
|
||||
RedisCommandGateway gateway, String source, AtomicReference<String> cache) {
|
||||
String cached = cache.get();
|
||||
if (cached != null) {
|
||||
return CompletableFuture.completedFuture(cached);
|
||||
}
|
||||
return gateway
|
||||
.loadScript(source.getBytes(StandardCharsets.UTF_8))
|
||||
.thenApply(
|
||||
loaded -> {
|
||||
cache.set(loaded);
|
||||
return loaded;
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean scriptMissing(Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
while ((cause instanceof CompletionException || cause instanceof ExecutionException)
|
||||
&& cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
String message = cause.getMessage();
|
||||
return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT");
|
||||
}
|
||||
|
||||
private static Reply replyOf(List<Object> reply) {
|
||||
if (reply == null || reply.size() < 6) {
|
||||
throw new IllegalStateException("the idempotency program answered with an unexpected shape");
|
||||
}
|
||||
return new Reply(
|
||||
text(reply.get(0)),
|
||||
number(reply.get(1)),
|
||||
number(reply.get(2)),
|
||||
text(reply.get(3)),
|
||||
text(reply.get(4)),
|
||||
text(reply.get(5)));
|
||||
}
|
||||
|
||||
private static long number(Object value) {
|
||||
if (value instanceof Number n) {
|
||||
return n.longValue();
|
||||
}
|
||||
String text = text(value);
|
||||
return text.isBlank() ? 0L : Long.parseLong(text.strip());
|
||||
}
|
||||
|
||||
private static String text(Object value) {
|
||||
if (value instanceof byte[] bytes) {
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* One program's answer.
|
||||
*
|
||||
* @param status the transition verdict
|
||||
* @param attempt the record's attempt counter
|
||||
* @param revision the record's state revision after the call
|
||||
* @param owner the stored owner token
|
||||
* @param payload the stored response, when the verdict carries one
|
||||
* @param detail verdict-specific detail: a lease deadline, a TTL, or the observed state
|
||||
*/
|
||||
record Reply(
|
||||
String status, long attempt, long revision, String owner, String payload, String detail) {}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.lease;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* The four owner-checked lease programs.
|
||||
*
|
||||
* <p>Every one of them compares the stored owner before it mutates, inside the same server
|
||||
* execution. That is the entire safety property of this adapter: a renew or release that reads the
|
||||
* owner and then writes would let a holder whose lease expired in between extend or delete a lease
|
||||
* that now belongs to somebody else. "Check then act" is not a lease.
|
||||
*
|
||||
* <p>The stored value is {@code ownerToken:operationId}. Both, because the same owner retrying a
|
||||
* different operation is a different claim — a caller that re-acquires under a new operation id has
|
||||
* lost the old one's guarantee and must be told, rather than silently inheriting it.
|
||||
*/
|
||||
public final class LeaseScripts {
|
||||
|
||||
/** Acquire: set if absent, and report the existing holder when present. */
|
||||
private static final String ACQUIRE =
|
||||
"""
|
||||
local existing = redis.call('GET', KEYS[1])
|
||||
if existing == false then
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
|
||||
return {1, redis.call('PTTL', KEYS[1]), ''}
|
||||
end
|
||||
if existing == ARGV[1] then
|
||||
-- The same owner and the same operation. This is a retry of a call whose reply was lost,
|
||||
-- not a second claim, so it is answered with the lease rather than with contention.
|
||||
return {2, redis.call('PTTL', KEYS[1]), existing}
|
||||
end
|
||||
return {0, redis.call('PTTL', KEYS[1]), existing}
|
||||
""";
|
||||
|
||||
/** Renew: extend only while this exact owner and operation still hold it. */
|
||||
private static final String RENEW =
|
||||
"""
|
||||
local existing = redis.call('GET', KEYS[1])
|
||||
if existing == false then
|
||||
return {0, 0, ''}
|
||||
end
|
||||
if existing ~= ARGV[1] then
|
||||
return {-1, redis.call('PTTL', KEYS[1]), existing}
|
||||
end
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
||||
return {1, redis.call('PTTL', KEYS[1]), existing}
|
||||
""";
|
||||
|
||||
/** Release: delete only while this exact owner and operation still hold it. */
|
||||
private static final String RELEASE =
|
||||
"""
|
||||
local existing = redis.call('GET', KEYS[1])
|
||||
if existing == false then
|
||||
return {0, 0, ''}
|
||||
end
|
||||
if existing ~= ARGV[1] then
|
||||
return {-1, redis.call('PTTL', KEYS[1]), existing}
|
||||
end
|
||||
redis.call('DEL', KEYS[1])
|
||||
return {1, 0, existing}
|
||||
""";
|
||||
|
||||
/** Inspect: read without mutating, so a caller can ask without taking. */
|
||||
private static final String INSPECT =
|
||||
"""
|
||||
local existing = redis.call('GET', KEYS[1])
|
||||
if existing == false then
|
||||
return {0, 0, ''}
|
||||
end
|
||||
if existing ~= ARGV[1] then
|
||||
return {-1, redis.call('PTTL', KEYS[1]), existing}
|
||||
end
|
||||
return {1, redis.call('PTTL', KEYS[1]), existing}
|
||||
""";
|
||||
|
||||
private final AtomicReference<String> acquireDigest = new AtomicReference<>();
|
||||
private final AtomicReference<String> renewDigest = new AtomicReference<>();
|
||||
private final AtomicReference<String> releaseDigest = new AtomicReference<>();
|
||||
private final AtomicReference<String> inspectDigest = new AtomicReference<>();
|
||||
|
||||
CompletionStage<Reply> acquire(
|
||||
RedisCommandGateway gateway, byte[] key, String ownership, long ttlMillis) {
|
||||
return run(gateway, ACQUIRE, acquireDigest, key, args(ownership, ttlMillis));
|
||||
}
|
||||
|
||||
CompletionStage<Reply> renew(
|
||||
RedisCommandGateway gateway, byte[] key, String ownership, long ttlMillis) {
|
||||
return run(gateway, RENEW, renewDigest, key, args(ownership, ttlMillis));
|
||||
}
|
||||
|
||||
CompletionStage<Reply> release(RedisCommandGateway gateway, byte[] key, String ownership) {
|
||||
return run(gateway, RELEASE, releaseDigest, key, args(ownership, 0));
|
||||
}
|
||||
|
||||
CompletionStage<Reply> inspect(RedisCommandGateway gateway, byte[] key, String ownership) {
|
||||
return run(gateway, INSPECT, inspectDigest, key, args(ownership, 0));
|
||||
}
|
||||
|
||||
private CompletionStage<Reply> run(
|
||||
RedisCommandGateway gateway,
|
||||
String source,
|
||||
AtomicReference<String> cache,
|
||||
byte[] key,
|
||||
List<byte[]> arguments) {
|
||||
return digest(gateway, source, cache)
|
||||
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments))
|
||||
.handle(
|
||||
(reply, failure) ->
|
||||
failure == null
|
||||
? CompletableFuture.completedFuture(reply)
|
||||
: reload(gateway, source, cache, key, arguments, failure))
|
||||
.thenCompose(stage -> stage)
|
||||
.thenApply(LeaseScripts::replyOf);
|
||||
}
|
||||
|
||||
private CompletionStage<List<Object>> reload(
|
||||
RedisCommandGateway gateway,
|
||||
String source,
|
||||
AtomicReference<String> cache,
|
||||
byte[] key,
|
||||
List<byte[]> arguments,
|
||||
Throwable failure) {
|
||||
if (!scriptMissing(failure)) {
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
}
|
||||
cache.set(null);
|
||||
return digest(gateway, source, cache)
|
||||
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments));
|
||||
}
|
||||
|
||||
private static CompletionStage<String> digest(
|
||||
RedisCommandGateway gateway, String source, AtomicReference<String> cache) {
|
||||
String cached = cache.get();
|
||||
if (cached != null) {
|
||||
return CompletableFuture.completedFuture(cached);
|
||||
}
|
||||
return gateway
|
||||
.loadScript(source.getBytes(StandardCharsets.UTF_8))
|
||||
.thenApply(
|
||||
loaded -> {
|
||||
cache.set(loaded);
|
||||
return loaded;
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean scriptMissing(Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
while ((cause instanceof CompletionException || cause instanceof ExecutionException)
|
||||
&& cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
String message = cause.getMessage();
|
||||
return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT");
|
||||
}
|
||||
|
||||
private static Reply replyOf(List<Object> reply) {
|
||||
if (reply == null || reply.size() < 3) {
|
||||
throw new IllegalStateException("the lease program answered with an unexpected shape");
|
||||
}
|
||||
return new Reply(asLong(reply.get(0)), asLong(reply.get(1)), asText(reply.get(2)));
|
||||
}
|
||||
|
||||
private static long asLong(Object value) {
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (value instanceof byte[] bytes) {
|
||||
return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip());
|
||||
}
|
||||
throw new IllegalStateException("the lease program answered with an unexpected value type");
|
||||
}
|
||||
|
||||
private static String asText(Object value) {
|
||||
if (value instanceof byte[] bytes) {
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
return value == null ? "" : String.valueOf(value);
|
||||
}
|
||||
|
||||
private static List<byte[]> args(String ownership, long ttlMillis) {
|
||||
return List.of(
|
||||
ownership.getBytes(StandardCharsets.UTF_8),
|
||||
Long.toString(ttlMillis).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/**
|
||||
* One program's answer.
|
||||
*
|
||||
* @param status {@code 1} applied, {@code 2} replay of the same claim, {@code 0} absent, {@code
|
||||
* -1} held by somebody else
|
||||
* @param remainingMillis the server's remaining TTL, diagnostic only
|
||||
* @param holder the stored ownership string, empty when absent
|
||||
*/
|
||||
record Reply(long status, long remainingMillis, String holder) {}
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.ratelimit;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
|
||||
import dev.caskeleton.shared.ratelimit.RateLimitPolicy;
|
||||
import dev.caskeleton.shared.ratelimit.RateParameters;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* The atomic programs that make one rate-limit decision one round trip.
|
||||
*
|
||||
* <p>Every algorithm here reads state, decides, mutates, and sets an expiry inside a single server
|
||||
* execution. Splitting that into commands is not a performance question: two concurrent requests
|
||||
* that both read "49 used of 50" would both be allowed, and the limit would be exceeded by exactly
|
||||
* the concurrency. The whole decision has to be indivisible or it is not a limit.
|
||||
*
|
||||
* <p>Time comes from the caller, not from the server's {@code TIME}. Two reasons: a script that
|
||||
* calls {@code TIME} is non-deterministic, and the decision has to be measured against the clock
|
||||
* the caller's deadline is measured against. The caller's clock going backwards is handled by the
|
||||
* policy's clock-regression bound rather than by trusting it blindly.
|
||||
*
|
||||
* <p>Loaded once and called by digest. A {@code NOSCRIPT} means the server rejected the call before
|
||||
* running anything, so reloading and retrying once is safe — it is not a retry of an ambiguous
|
||||
* mutation.
|
||||
*/
|
||||
public final class RateLimitScripts {
|
||||
|
||||
/**
|
||||
* Fixed window: one counter per window, expiring with it.
|
||||
*
|
||||
* <p>Returns {@code {allowed, remaining, resetAfterMillis}}. The expiry is set from the window
|
||||
* rather than refreshed per hit, so a subject cannot hold a counter alive indefinitely.
|
||||
*/
|
||||
private static final String FIXED_WINDOW =
|
||||
"""
|
||||
local limit = tonumber(ARGV[1])
|
||||
local windowMillis = tonumber(ARGV[2])
|
||||
local cost = tonumber(ARGV[3])
|
||||
local nowMillis = tonumber(ARGV[4])
|
||||
local windowStart = nowMillis - (nowMillis % windowMillis)
|
||||
local resetAfter = (windowStart + windowMillis) - nowMillis
|
||||
local bucket = tostring(windowStart)
|
||||
local current = tonumber(redis.call('HGET', KEYS[1], bucket)) or 0
|
||||
if current + cost > limit then
|
||||
return {0, limit - current, resetAfter}
|
||||
end
|
||||
redis.call('HSET', KEYS[1], bucket, current + cost)
|
||||
redis.call('PEXPIRE', KEYS[1], windowMillis * 2)
|
||||
return {1, limit - (current + cost), resetAfter}
|
||||
""";
|
||||
|
||||
/**
|
||||
* Sliding counter: the current window plus a weighted share of the previous one.
|
||||
*
|
||||
* <p>Approximate by construction, and the port says so. An exact sliding window needs one sorted
|
||||
* set entry per request, which costs memory proportional to the traffic it is limiting — the
|
||||
* failure mode of an exact limiter is that it becomes the outage.
|
||||
*/
|
||||
private static final String SLIDING_COUNTER =
|
||||
"""
|
||||
local limit = tonumber(ARGV[1])
|
||||
local windowMillis = tonumber(ARGV[2])
|
||||
local cost = tonumber(ARGV[3])
|
||||
local nowMillis = tonumber(ARGV[4])
|
||||
local windowStart = nowMillis - (nowMillis % windowMillis)
|
||||
local elapsed = nowMillis - windowStart
|
||||
local resetAfter = windowMillis - elapsed
|
||||
local current = tonumber(redis.call('HGET', KEYS[1], tostring(windowStart))) or 0
|
||||
local previous = tonumber(redis.call('HGET', KEYS[1], tostring(windowStart - windowMillis))) or 0
|
||||
local weight = (windowMillis - elapsed) / windowMillis
|
||||
local estimated = current + math.floor(previous * weight)
|
||||
if estimated + cost > limit then
|
||||
return {0, math.max(0, limit - estimated), resetAfter}
|
||||
end
|
||||
redis.call('HSET', KEYS[1], tostring(windowStart), current + cost)
|
||||
redis.call('HDEL', KEYS[1], tostring(windowStart - (windowMillis * 2)))
|
||||
redis.call('PEXPIRE', KEYS[1], windowMillis * 3)
|
||||
return {1, math.max(0, limit - (estimated + cost)), resetAfter}
|
||||
""";
|
||||
|
||||
/**
|
||||
* Token bucket: refill by elapsed time, then spend.
|
||||
*
|
||||
* <p>The stored timestamp is advanced by whole refill periods only. Advancing it to "now" would
|
||||
* discard the fraction of a period that had already accrued, so a caller polling faster than the
|
||||
* refill period would never accumulate a token.
|
||||
*/
|
||||
private static final String TOKEN_BUCKET =
|
||||
"""
|
||||
local capacity = tonumber(ARGV[1])
|
||||
local refillTokens = tonumber(ARGV[2])
|
||||
local refillPeriodMillis = tonumber(ARGV[3])
|
||||
local cost = tonumber(ARGV[4])
|
||||
local nowMillis = tonumber(ARGV[5])
|
||||
local state = redis.call('HMGET', KEYS[1], 'tokens', 'updatedAt')
|
||||
local tokens = tonumber(state[1])
|
||||
local updatedAt = tonumber(state[2])
|
||||
if tokens == nil or updatedAt == nil then
|
||||
tokens = capacity
|
||||
updatedAt = nowMillis
|
||||
end
|
||||
if updatedAt > nowMillis then
|
||||
-- The caller's clock went backwards. Refilling on a negative elapsed time would remove
|
||||
-- tokens; holding the state still is the conservative reading.
|
||||
updatedAt = nowMillis
|
||||
end
|
||||
local periods = math.floor((nowMillis - updatedAt) / refillPeriodMillis)
|
||||
if periods > 0 then
|
||||
tokens = math.min(capacity, tokens + (periods * refillTokens))
|
||||
updatedAt = updatedAt + (periods * refillPeriodMillis)
|
||||
end
|
||||
local resetAfter = refillPeriodMillis - ((nowMillis - updatedAt) % refillPeriodMillis)
|
||||
if tokens < cost then
|
||||
redis.call('HSET', KEYS[1], 'tokens', tokens, 'updatedAt', updatedAt)
|
||||
redis.call('PEXPIRE', KEYS[1], refillPeriodMillis * (capacity / math.max(1, refillTokens)) + refillPeriodMillis)
|
||||
return {0, math.floor(tokens), resetAfter}
|
||||
end
|
||||
tokens = tokens - cost
|
||||
redis.call('HSET', KEYS[1], 'tokens', tokens, 'updatedAt', updatedAt)
|
||||
redis.call('PEXPIRE', KEYS[1], refillPeriodMillis * (capacity / math.max(1, refillTokens)) + refillPeriodMillis)
|
||||
return {1, math.floor(tokens), resetAfter}
|
||||
""";
|
||||
|
||||
private final AtomicReference<String> fixedWindowDigest = new AtomicReference<>();
|
||||
|
||||
private final AtomicReference<String> slidingCounterDigest = new AtomicReference<>();
|
||||
|
||||
private final AtomicReference<String> tokenBucketDigest = new AtomicReference<>();
|
||||
|
||||
/**
|
||||
* Evaluates one request atomically.
|
||||
*
|
||||
* @param gateway the driver seam of a borrowed lease
|
||||
* @param key the rendered counter key
|
||||
* @param policy the policy to apply
|
||||
* @param cost the request's cost
|
||||
* @param now the caller's clock reading
|
||||
* @return the evaluation
|
||||
*/
|
||||
public CompletionStage<Evaluation> evaluate(
|
||||
RedisCommandGateway gateway, byte[] key, RateLimitPolicy policy, long cost, Instant now) {
|
||||
Objects.requireNonNull(gateway, "gateway must be non-null");
|
||||
Objects.requireNonNull(policy, "policy must be non-null");
|
||||
Objects.requireNonNull(now, "now must be non-null");
|
||||
long nowMillis = now.toEpochMilli();
|
||||
return switch (policy.parameters()) {
|
||||
case RateParameters.FixedWindow window ->
|
||||
run(
|
||||
gateway,
|
||||
FIXED_WINDOW,
|
||||
fixedWindowDigest,
|
||||
key,
|
||||
arguments(window.limit(), window.window().toMillis(), cost, nowMillis));
|
||||
case RateParameters.SlidingCounter sliding ->
|
||||
run(
|
||||
gateway,
|
||||
SLIDING_COUNTER,
|
||||
slidingCounterDigest,
|
||||
key,
|
||||
arguments(sliding.limit(), sliding.window().toMillis(), cost, nowMillis));
|
||||
case RateParameters.TokenBucket bucket ->
|
||||
run(
|
||||
gateway,
|
||||
TOKEN_BUCKET,
|
||||
tokenBucketDigest,
|
||||
key,
|
||||
arguments(
|
||||
bucket.capacity(),
|
||||
bucket.refillTokens(),
|
||||
bucket.refillPeriod().toMillis(),
|
||||
cost,
|
||||
nowMillis));
|
||||
default ->
|
||||
CompletableFuture.failedFuture(
|
||||
new IllegalStateException("unsupported rate parameters: " + policy.parameters()));
|
||||
};
|
||||
}
|
||||
|
||||
private CompletionStage<Evaluation> run(
|
||||
RedisCommandGateway gateway,
|
||||
String source,
|
||||
AtomicReference<String> cache,
|
||||
byte[] key,
|
||||
List<byte[]> arguments) {
|
||||
return digest(gateway, source, cache)
|
||||
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments))
|
||||
.handle(
|
||||
(reply, failure) ->
|
||||
failure == null
|
||||
? CompletableFuture.completedFuture(reply)
|
||||
: reload(gateway, source, cache, key, arguments, failure))
|
||||
.thenCompose(stage -> stage)
|
||||
.thenApply(RateLimitScripts::evaluationOf);
|
||||
}
|
||||
|
||||
private CompletionStage<List<Object>> reload(
|
||||
RedisCommandGateway gateway,
|
||||
String source,
|
||||
AtomicReference<String> cache,
|
||||
byte[] key,
|
||||
List<byte[]> arguments,
|
||||
Throwable failure) {
|
||||
if (!scriptMissing(failure)) {
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
}
|
||||
cache.set(null);
|
||||
return digest(gateway, source, cache)
|
||||
.thenCompose(digest -> gateway.evaluateRegisteredForList(digest, key, arguments));
|
||||
}
|
||||
|
||||
private static CompletionStage<String> digest(
|
||||
RedisCommandGateway gateway, String source, AtomicReference<String> cache) {
|
||||
String cached = cache.get();
|
||||
if (cached != null) {
|
||||
return CompletableFuture.completedFuture(cached);
|
||||
}
|
||||
return gateway
|
||||
.loadScript(source.getBytes(StandardCharsets.UTF_8))
|
||||
.thenApply(
|
||||
loaded -> {
|
||||
cache.set(loaded);
|
||||
return loaded;
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean scriptMissing(Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
while ((cause instanceof CompletionException || cause instanceof ExecutionException)
|
||||
&& cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
String message = cause.getMessage();
|
||||
return message != null && message.strip().toUpperCase(Locale.ROOT).startsWith("NOSCRIPT");
|
||||
}
|
||||
|
||||
private static Evaluation evaluationOf(List<Object> reply) {
|
||||
if (reply == null || reply.size() < 3) {
|
||||
throw new IllegalStateException(
|
||||
"the rate limit program answered with "
|
||||
+ (reply == null ? "nothing" : reply.size())
|
||||
+ " values; three were expected");
|
||||
}
|
||||
return new Evaluation(asLong(reply.get(0)) == 1L, asLong(reply.get(1)), asLong(reply.get(2)));
|
||||
}
|
||||
|
||||
private static long asLong(Object value) {
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
if (value instanceof byte[] bytes) {
|
||||
return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip());
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"the rate limit program answered with an unexpected value type: "
|
||||
+ (value == null ? "null" : value.getClass().getName()));
|
||||
}
|
||||
|
||||
private static List<byte[]> arguments(long... values) {
|
||||
return java.util.Arrays.stream(values)
|
||||
.mapToObj(value -> Long.toString(value).getBytes(StandardCharsets.UTF_8))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* One evaluation's result.
|
||||
*
|
||||
* @param allowed whether the request may proceed
|
||||
* @param remaining the remaining budget after this request
|
||||
* @param resetAfterMillis how long until the budget changes
|
||||
*/
|
||||
public record Evaluation(boolean allowed, long remaining, long resetAfterMillis) {}
|
||||
}
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.sdk.admin;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandSupport;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.OperationBudget;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.QualifiedRedisKey;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.CommandRequest;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.RedisCommandCatalog;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.RedisCommandPolicy;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.SyncRedisCommandExecutor;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisOperationContext;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The admin plane, on its own connection and its own ACL account.
|
||||
*
|
||||
* <p>It takes its own gateway for the same reason the blocking operations do: a diagnostic that
|
||||
* walks the keyspace or serializes a large {@code INFO} must not compete with request traffic, and
|
||||
* the account it authenticates with should be able to read diagnostics and nothing else. Binding
|
||||
* that to a separate gateway instance is how the separation is expressed structurally instead of
|
||||
* being left to a deployment note.
|
||||
*
|
||||
* <p>Every command is checked against the catalog before it is built: not classified {@code
|
||||
* ADMIN_ONLY}, or not read-only, and it does not get sent. That check is what keeps a future
|
||||
* addition to this class from quietly becoming a write.
|
||||
*/
|
||||
public final class LettuceRedisAdminOperations implements RedisAdminOperations {
|
||||
|
||||
private static final String FAMILY = "ADMIN";
|
||||
|
||||
private static final int MAX_PROJECTED = 1_000;
|
||||
|
||||
private final RedisCommandCatalog catalog;
|
||||
|
||||
private final RedisCommandGateway adminGateway;
|
||||
|
||||
private final RedisOperationContext context;
|
||||
|
||||
private final SyncRedisCommandExecutor executor;
|
||||
|
||||
private final Duration timeout;
|
||||
|
||||
/**
|
||||
* Creates the admin plane.
|
||||
*
|
||||
* @param catalog the command policy catalog
|
||||
* @param adminGateway the driver seam, bound to the admin account's own connection
|
||||
* @param context the shared rendering and budget rules
|
||||
* @param executor the guarded blocking executor
|
||||
* @param timeout the bound on one diagnostic
|
||||
*/
|
||||
public LettuceRedisAdminOperations(
|
||||
RedisCommandCatalog catalog,
|
||||
RedisCommandGateway adminGateway,
|
||||
RedisOperationContext context,
|
||||
SyncRedisCommandExecutor executor,
|
||||
Duration timeout) {
|
||||
this.catalog = Objects.requireNonNull(catalog, "catalog must be non-null");
|
||||
this.adminGateway = Objects.requireNonNull(adminGateway, "admin gateway must be non-null");
|
||||
this.context = Objects.requireNonNull(context, "operation context must be non-null");
|
||||
this.executor = Objects.requireNonNull(executor, "executor must be non-null");
|
||||
this.timeout = Objects.requireNonNull(timeout, "timeout must be non-null");
|
||||
if (timeout.isZero() || timeout.isNegative()) {
|
||||
throw new IllegalArgumentException("the admin timeout must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> serverInfo(String section) {
|
||||
Objects.requireNonNull(section, "section must be non-null");
|
||||
return fields(text(run(CommandId.parse("INFO"), List.of(), utf8(section))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public long databaseSize() {
|
||||
return number(run(CommandId.parse("DBSIZE"), List.of()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public long memoryUsage(QualifiedRedisKey key) {
|
||||
Objects.requireNonNull(key, "key must be non-null");
|
||||
byte[] rendered = context.renderKey(key);
|
||||
List<Object> reply = run(CommandId.parse("MEMORY USAGE"), List.of(key), rendered);
|
||||
return reply.isEmpty() || reply.get(0) == null ? -1L : number(reply);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SlowLogEntry> slowLog(int count) {
|
||||
requireBounded(count, "a slow log read");
|
||||
List<Object> reply =
|
||||
run(CommandId.parse("SLOWLOG GET"), List.of(), utf8(Integer.toString(count)));
|
||||
List<SlowLogEntry> entries = new ArrayList<>();
|
||||
for (Object element : reply) {
|
||||
List<Object> row = nested(element);
|
||||
if (row.size() < 4) {
|
||||
continue;
|
||||
}
|
||||
entries.add(
|
||||
new SlowLogEntry(
|
||||
(Long) row.get(0),
|
||||
Instant.ofEpochSecond((Long) row.get(1)),
|
||||
Duration.ofNanos(Duration.ofMillis((Long) row.get(2)).toNanos() / 1_000L),
|
||||
family(nested(row.get(3)))));
|
||||
}
|
||||
return List.copyOf(entries);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Duration> latencyLatest() {
|
||||
List<Object> reply = run(CommandId.parse("LATENCY LATEST"), List.of());
|
||||
Map<String, Duration> latest = new LinkedHashMap<>();
|
||||
for (Object element : reply) {
|
||||
List<Object> row = nested(element);
|
||||
if (row.size() < 3) {
|
||||
continue;
|
||||
}
|
||||
latest.put(text(List.of(row.get(0))), Duration.ofMillis((Long) row.get(2)));
|
||||
}
|
||||
return Map.copyOf(latest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ClientSummary> clients(int limit) {
|
||||
requireBounded(limit, "a client projection");
|
||||
String listing = text(run(CommandId.parse("CLIENT LIST"), List.of()));
|
||||
List<ClientSummary> clients = new ArrayList<>();
|
||||
for (String line : listing.lines().toList()) {
|
||||
if (line.isBlank() || clients.size() == limit) {
|
||||
break;
|
||||
}
|
||||
Map<String, String> attributes = attributes(line);
|
||||
clients.add(
|
||||
new ClientSummary(
|
||||
Long.parseLong(attributes.getOrDefault("id", "0")),
|
||||
Duration.ofSeconds(Long.parseLong(attributes.getOrDefault("age", "0"))),
|
||||
Duration.ofSeconds(Long.parseLong(attributes.getOrDefault("idle", "0"))),
|
||||
attributes.getOrDefault("cmd", "unknown").toUpperCase(Locale.ROOT)));
|
||||
}
|
||||
return List.copyOf(clients);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> clusterInfo() {
|
||||
return fields(text(run(CommandId.parse("CLUSTER INFO"), List.of())));
|
||||
}
|
||||
|
||||
/**
|
||||
* The only configuration parameters this plane will read.
|
||||
*
|
||||
* <p>Chosen for what an operator diagnosing a Redis problem actually needs — memory ceiling and
|
||||
* eviction, persistence, replication durability, connection lifetime, topology — and nothing
|
||||
* else. Adding a parameter is an edit here, which is the point: the set is reviewable, whereas a
|
||||
* glob is not.
|
||||
*/
|
||||
private static final List<String> DIAGNOSTIC_PARAMETERS =
|
||||
List.of(
|
||||
"maxmemory",
|
||||
"maxmemory-policy",
|
||||
"maxmemory-samples",
|
||||
"appendonly",
|
||||
"appendfsync",
|
||||
"save",
|
||||
"min-replicas-to-write",
|
||||
"min-replicas-max-lag",
|
||||
"timeout",
|
||||
"tcp-keepalive",
|
||||
"databases",
|
||||
"cluster-enabled",
|
||||
"cluster-require-full-coverage",
|
||||
"lazyfree-lazy-eviction",
|
||||
"lazyfree-lazy-expire",
|
||||
"notify-keyspace-events",
|
||||
"slowlog-log-slower-than",
|
||||
"slowlog-max-len");
|
||||
|
||||
/** Substrings that mark a parameter as carrying credential material. */
|
||||
private static final List<String> SECRET_MARKERS =
|
||||
List.of("pass", "auth", "secret", "key-file", "keyfile", "user");
|
||||
|
||||
/** Replacement for a value that must never leave the server. */
|
||||
static final String REDACTED = "[redacted]";
|
||||
|
||||
@Override
|
||||
public Map<String, String> configuration() {
|
||||
List<byte[]> arguments =
|
||||
DIAGNOSTIC_PARAMETERS.stream().map(LettuceRedisAdminOperations::utf8).toList();
|
||||
List<Object> reply = run(CommandId.parse("CONFIG GET"), List.of(), arguments);
|
||||
Map<String, String> parameters = new LinkedHashMap<>();
|
||||
for (int index = 0; index + 1 < reply.size(); index += 2) {
|
||||
String name = text(List.of(reply.get(index)));
|
||||
// Filtered again on the way out. The request already named only allowlisted parameters, but
|
||||
// a server-side alias or a future glob-expanding change must not be able to widen the
|
||||
// projection, and a parameter that slipped through must not carry its value with it.
|
||||
if (!DIAGNOSTIC_PARAMETERS.contains(name)) {
|
||||
continue;
|
||||
}
|
||||
String value = text(List.of(reply.get(index + 1)));
|
||||
parameters.put(name, isSecretShaped(name) ? REDACTED : value);
|
||||
}
|
||||
return Map.copyOf(parameters);
|
||||
}
|
||||
|
||||
private static boolean isSecretShaped(String parameterName) {
|
||||
String lower = parameterName.toLowerCase(Locale.ROOT);
|
||||
return SECRET_MARKERS.stream().anyMatch(lower::contains);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> aclDryRun(String username, CommandId commandId) {
|
||||
Objects.requireNonNull(username, "username must be non-null");
|
||||
Objects.requireNonNull(commandId, "command id must be non-null");
|
||||
List<byte[]> arguments = new ArrayList<>();
|
||||
arguments.add(utf8(username));
|
||||
arguments.add(utf8(commandId.family()));
|
||||
commandId.subcommand().map(LettuceRedisAdminOperations::utf8).ifPresent(arguments::add);
|
||||
String answer = text(run(CommandId.parse("ACL DRYRUN"), List.of(), arguments));
|
||||
return "OK".equals(answer) ? Optional.empty() : Optional.of(answer);
|
||||
}
|
||||
|
||||
private List<Object> run(CommandId commandId, List<QualifiedRedisKey> keys, byte[]... arguments) {
|
||||
return run(commandId, keys, List.of(arguments));
|
||||
}
|
||||
|
||||
private List<Object> run(
|
||||
CommandId commandId, List<QualifiedRedisKey> keys, List<byte[]> arguments) {
|
||||
RedisCommandPolicy policy = catalog.require(commandId);
|
||||
if (policy.support() != CommandSupport.ADMIN_ONLY || !policy.readOnly()) {
|
||||
throw context.reject(
|
||||
FAMILY, true, "the admin plane only sends read-only diagnostics the catalog approved");
|
||||
}
|
||||
long requestBytes = 1L;
|
||||
for (byte[] argument : arguments) {
|
||||
requestBytes += argument.length;
|
||||
}
|
||||
OperationBudget budget =
|
||||
new OperationBudget(
|
||||
Math.max(1, keys.size()),
|
||||
requestBytes,
|
||||
context.limits().maxReplyBytesPerElement(),
|
||||
timeout);
|
||||
return executor.execute(
|
||||
new CommandRequest<>(
|
||||
commandId,
|
||||
keys,
|
||||
requestBytes,
|
||||
0L,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.of(budget),
|
||||
Optional.empty(),
|
||||
() -> adminGateway.sendAdminDiagnostic(commandId, arguments)));
|
||||
}
|
||||
|
||||
private void requireBounded(int count, String description) {
|
||||
if (count < 1) {
|
||||
throw context.reject(FAMILY, true, description + " must declare a positive bound");
|
||||
}
|
||||
if (count > MAX_PROJECTED) {
|
||||
throw context.reject(
|
||||
FAMILY, true, description + " may not exceed " + MAX_PROJECTED + " entries");
|
||||
}
|
||||
}
|
||||
|
||||
private static String family(List<Object> commandWords) {
|
||||
return commandWords.isEmpty()
|
||||
? "UNKNOWN"
|
||||
: text(List.of(commandWords.get(0))).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static Map<String, String> fields(String body) {
|
||||
Map<String, String> parsed = new LinkedHashMap<>();
|
||||
for (String line : body.lines().toList()) {
|
||||
String trimmed = line.strip();
|
||||
int separator = trimmed.indexOf(':');
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("#") || separator < 1) {
|
||||
continue;
|
||||
}
|
||||
parsed.put(trimmed.substring(0, separator), trimmed.substring(separator + 1));
|
||||
}
|
||||
return Map.copyOf(parsed);
|
||||
}
|
||||
|
||||
private static Map<String, String> attributes(String line) {
|
||||
Map<String, String> parsed = new LinkedHashMap<>();
|
||||
// Parsed by hand rather than by splitting: a CLIENT LIST line is space-separated key=value
|
||||
// pairs, and the values can themselves contain characters a naive split would mangle.
|
||||
String remainder = line.strip();
|
||||
while (!remainder.isEmpty()) {
|
||||
int space = remainder.indexOf(' ');
|
||||
String pair = space < 0 ? remainder : remainder.substring(0, space);
|
||||
remainder = space < 0 ? "" : remainder.substring(space + 1);
|
||||
int separator = pair.indexOf('=');
|
||||
if (separator > 0) {
|
||||
parsed.put(pair.substring(0, separator), pair.substring(separator + 1));
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static long number(List<Object> reply) {
|
||||
Object first = reply.isEmpty() ? null : reply.get(0);
|
||||
if (first instanceof Long value) {
|
||||
return value;
|
||||
}
|
||||
if (first instanceof byte[] bytes) {
|
||||
return Long.parseLong(new String(bytes, StandardCharsets.UTF_8).strip());
|
||||
}
|
||||
throw new IllegalStateException("the diagnostic did not answer with a number");
|
||||
}
|
||||
|
||||
private static String text(List<Object> reply) {
|
||||
Object first = reply.isEmpty() ? null : reply.get(0);
|
||||
if (first instanceof byte[] bytes) {
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
return first == null ? "" : String.valueOf(first);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Object> nested(Object element) {
|
||||
return element instanceof List ? (List<Object>) element : List.of();
|
||||
}
|
||||
|
||||
private static byte[] utf8(String text) {
|
||||
return text.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.sdk.admin;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.QualifiedRedisKey;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Read-only server diagnostics, separated from the application request path.
|
||||
*
|
||||
* <p>Everything here is read-only by construction: the command policy catalog classifies each of
|
||||
* these {@code ADMIN_ONLY} and read-only, and the implementation refuses to send anything that is
|
||||
* not. The destructive counterparts an operator might reach for — {@code FLUSHDB}, {@code
|
||||
* FLUSHALL}, {@code SHUTDOWN}, {@code DEBUG}, {@code CONFIG SET}, {@code CLIENT KILL}, {@code ACL
|
||||
* SETUSER}, {@code SLOWLOG RESET}, {@code LATENCY RESET} — are all {@code BLOCKED} in the catalog
|
||||
* and have no method here or anywhere else in the SDK.
|
||||
*
|
||||
* <p>The plane is expected to run on its own connection factory and its own ACL account. That is
|
||||
* not something this interface can enforce, which is exactly why the catalog blocks the dangerous
|
||||
* commands outright rather than trusting the deployment to have separated the credentials.
|
||||
*/
|
||||
public interface RedisAdminOperations {
|
||||
|
||||
/**
|
||||
* Reads one {@code INFO} section.
|
||||
*
|
||||
* @param section the section name, for example {@code memory} or {@code replication}
|
||||
* @return the section's fields
|
||||
*/
|
||||
Map<String, String> serverInfo(String section);
|
||||
|
||||
/**
|
||||
* Reads the key count of the current database.
|
||||
*
|
||||
* @return the key count
|
||||
*/
|
||||
long databaseSize();
|
||||
|
||||
/**
|
||||
* Reads the memory one key occupies.
|
||||
*
|
||||
* @param key the key, which is namespace-checked like any other
|
||||
* @return the size in bytes, or {@code -1} when the key is absent
|
||||
*/
|
||||
long memoryUsage(QualifiedRedisKey key);
|
||||
|
||||
/**
|
||||
* Reads the most recent slow log entries.
|
||||
*
|
||||
* @param count the strictly positive bound on returned entries
|
||||
* @return the entries, newest first
|
||||
*/
|
||||
List<SlowLogEntry> slowLog(int count);
|
||||
|
||||
/**
|
||||
* Reads the latest latency spike per monitored event.
|
||||
*
|
||||
* @return the latest spike per event name
|
||||
*/
|
||||
Map<String, Duration> latencyLatest();
|
||||
|
||||
/**
|
||||
* Reads a bounded projection of the connected clients.
|
||||
*
|
||||
* @param limit the strictly positive bound on returned clients
|
||||
* @return the projected clients
|
||||
*/
|
||||
List<ClientSummary> clients(int limit);
|
||||
|
||||
/**
|
||||
* Reads the cluster state.
|
||||
*
|
||||
* @return the {@code CLUSTER INFO} fields
|
||||
*/
|
||||
Map<String, String> clusterInfo();
|
||||
|
||||
/**
|
||||
* Reads the fixed diagnostic configuration projection.
|
||||
*
|
||||
* <p>There is deliberately no pattern parameter. {@code CONFIG GET} with a caller-supplied glob
|
||||
* is an arbitrary read of the server's configuration: {@code *} returns everything the account
|
||||
* can see, including {@code requirepass}, {@code masterauth}, {@code masteruser} and the TLS key
|
||||
* passwords. An admin plane whose whole purpose is bounded, payload-free diagnostics cannot own a
|
||||
* method that returns whatever the caller asks for, so the parameter set is fixed here and
|
||||
* anything outside it is unreachable.
|
||||
*
|
||||
* @return the allowlisted diagnostic parameters, with any secret-shaped value redacted
|
||||
*/
|
||||
Map<String, String> configuration();
|
||||
|
||||
/**
|
||||
* Asks the server whether a user would be allowed to run a command.
|
||||
*
|
||||
* <p>This is how an ACL account is verified against what the SDK actually sends, rather than
|
||||
* against what someone believed it sends.
|
||||
*
|
||||
* @param username the ACL user
|
||||
* @param commandId the command to test
|
||||
* @return empty when the command would be allowed, otherwise the server's refusal reason
|
||||
*/
|
||||
java.util.Optional<String> aclDryRun(String username, CommandId commandId);
|
||||
}
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.sdk.config;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisCredentialRole;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeClient;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisTopologyClientFactory;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.health.contributor.Health;
|
||||
import org.springframework.boot.health.contributor.HealthIndicator;
|
||||
import org.springframework.boot.health.contributor.Status;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Condition;
|
||||
import org.springframework.context.annotation.ConditionContext;
|
||||
import org.springframework.context.annotation.Conditional;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.core.type.AnnotatedTypeMetadata;
|
||||
|
||||
/**
|
||||
* The Redis composition root, and the only place Redis settings come into existence.
|
||||
*
|
||||
* <p>{@code app.redis.enabled} is the whole switch. While it is false this class contributes
|
||||
* nothing, and because {@link RedisSdkSettings} is registered here rather than by the
|
||||
* application-wide {@code @ConfigurationPropertiesScan}, "contributes nothing" is literal: the
|
||||
* properties are not bound, the cross-field rules are not run, no credential is resolved, and no
|
||||
* policy resource, TLS material, client, connection or thread is created. A deployment that does
|
||||
* not use Redis carries no Redis configuration, and a deployment with malformed Redis configuration
|
||||
* it never enabled is not punished for it.
|
||||
*
|
||||
* <p>While it is true the order is fixed and entirely local: bind, validate, then build. Validation
|
||||
* runs at context refresh, before anything can reach the network, which is what makes {@link
|
||||
* RedisSdkSettings#validate()} the fail-fast its own documentation claims — until this class
|
||||
* existed the method had no production caller at all.
|
||||
*
|
||||
* <p>Roles — cache, session, idempotency, rate limiting, leases — select <em>which</em> Redis
|
||||
* capabilities compose on top of this. None of them is a second master switch; {@code
|
||||
* RedisActivationValidator} in the bootstrap refuses the contradiction of a role that selects Redis
|
||||
* while this switch is off.
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@ConditionalOnProperty(prefix = "app.redis", name = "enabled", havingValue = "true")
|
||||
// Registers the binding post-processor that populates the @ConfigurationProperties @Bean below.
|
||||
// Without it the bean is created and silently left at its defaults, which is worse than not
|
||||
// binding at all: validation would pass on settings nobody configured.
|
||||
@EnableConfigurationProperties
|
||||
public class RedisSdkAutoConfiguration {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(RedisSdkAutoConfiguration.class);
|
||||
|
||||
/**
|
||||
* The status an optional Redis reports when it is unreachable.
|
||||
*
|
||||
* <p>Not {@code DOWN}. A cache outage is a real degradation and belongs in the health detail, but
|
||||
* a status the readiness group understands as failure would remove a healthy pod from service —
|
||||
* shrinking capacity during the exact incident that needs it most.
|
||||
*/
|
||||
private static final Status DEGRADED =
|
||||
new Status("DEGRADED", "Redis is unreachable; the cache is bypassed");
|
||||
|
||||
/**
|
||||
* Binds and validates the Redis settings.
|
||||
*
|
||||
* <p>Validation happens in the factory method rather than in an {@code @PostConstruct} or a
|
||||
* listener so that a configuration error is reported as a failure to create this bean, with the
|
||||
* offending rule in the message, and so that nothing downstream can obtain an unvalidated
|
||||
* settings instance.
|
||||
*
|
||||
* @return the validated settings
|
||||
*/
|
||||
@Bean
|
||||
@ConfigurationProperties(prefix = "app.redis")
|
||||
public RedisSdkSettings redisSdkSettings() {
|
||||
return new RedisSdkSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the cross-field rules once the binder has populated the settings.
|
||||
*
|
||||
* <p>Spring binds {@code @ConfigurationProperties} after the factory method returns, so the
|
||||
* validation cannot live inside {@link #redisSdkSettings()}. A {@code
|
||||
* ConfigurationPropertiesBindHandlerAdvisor}-free way to get the same fail-fast is a bean that
|
||||
* depends on the settings: it is created during refresh, before any Redis client would be, and an
|
||||
* exception here stops the context.
|
||||
*
|
||||
* @param settings the bound settings
|
||||
* @return the validation outcome, kept as a bean so warnings are inspectable in tests
|
||||
*/
|
||||
@Bean
|
||||
public RedisSdkSettingsValidation redisSdkSettingsValidation(
|
||||
RedisSdkSettings settings, ResourceLoader resourceLoader) {
|
||||
List<String> warnings = settings.validate();
|
||||
requireRawPolicyResource(settings, resourceLoader);
|
||||
warnings.forEach(warning -> LOG.warn("Redis SDK configuration warning: {}", warning));
|
||||
return new RedisSdkSettingsValidation(warnings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proves the raw command allowlist exists before anything can reach Redis.
|
||||
*
|
||||
* <p>{@code validate()} only checks that the setting is non-blank, and the default points at
|
||||
* {@code classpath:redis-sdk/raw-command-allowlist.yml} — a resource this module does not ship.
|
||||
* So enabling the raw gateway passed configuration validation and then failed at the first raw
|
||||
* command, from inside a request, against a live connection. The allowlist is the entire
|
||||
* authorisation model for that gateway; not being able to read it is a startup failure.
|
||||
*/
|
||||
private static void requireRawPolicyResource(
|
||||
RedisSdkSettings settings, ResourceLoader resourceLoader) {
|
||||
if (!settings.getRaw().isEnabled()) {
|
||||
return;
|
||||
}
|
||||
String location = settings.getRaw().getPolicyResource();
|
||||
Resource resource = resourceLoader.getResource(location);
|
||||
if (!resource.exists() || !resource.isReadable()) {
|
||||
throw new IllegalStateException(
|
||||
"the raw gateway is enabled but its allowlist resource '"
|
||||
+ location
|
||||
+ "' does not exist or cannot be read. The allowlist is the only thing that decides"
|
||||
+ " which raw commands are reachable, so an unreadable one is a startup failure"
|
||||
+ " rather than a per-command surprise. Point"
|
||||
+ " app.redis.raw.policy-resource at a readable resource, or set"
|
||||
+ " app.redis.raw.enabled=false.");
|
||||
}
|
||||
try (InputStream ignored = resource.getInputStream()) {
|
||||
LOG.info("Redis raw command allowlist loaded from {}", location);
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException(
|
||||
"the raw gateway allowlist resource '" + location + "' could not be opened", failure);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves every credential reference the selected configuration actually needs.
|
||||
*
|
||||
* <p>Before any client exists. A reference that does not resolve is a configuration error, and
|
||||
* the only place it can be reported as one is here — after that the failure is an authentication
|
||||
* error on somebody's first command.
|
||||
*
|
||||
* @param settings the validated settings
|
||||
* @param secretSource resolves a secret name to its value
|
||||
* @param validation ordered after validation so a malformed setting is reported first
|
||||
* @return the resolved credentials
|
||||
*/
|
||||
@Bean
|
||||
public RedisResolvedCredentials redisResolvedCredentials(
|
||||
RedisSdkSettings settings,
|
||||
ObjectProvider<RedisSecretSource> secretSource,
|
||||
RedisSdkSettingsValidation validation) {
|
||||
RedisCredentialResolver resolver =
|
||||
new RedisCredentialResolver(
|
||||
name -> secretSource.getIfAvailable(() -> environmentSecretSource()).resolve(name));
|
||||
Map<RedisCredentialRole, RedisCredentialResolver.RedisCredentials> accounts =
|
||||
new EnumMap<>(RedisCredentialRole.class);
|
||||
// Every configured role is resolved, not only the application one. A deployment that named an
|
||||
// advanced or pub/sub account and got a client that silently authenticated as the application
|
||||
// account has the privilege separation it configured on paper and nowhere else.
|
||||
put(
|
||||
accounts,
|
||||
RedisCredentialRole.APPLICATION,
|
||||
resolver.resolve("application", settings.getAuthentication().getCredentialReference()));
|
||||
put(
|
||||
accounts,
|
||||
RedisCredentialRole.ADVANCED,
|
||||
resolver.resolve(
|
||||
"advanced", settings.getAuthentication().getAdvancedCredentialReference()));
|
||||
put(
|
||||
accounts,
|
||||
RedisCredentialRole.PUBSUB,
|
||||
resolver.resolve("pub/sub", settings.getAuthentication().getPubsubCredentialReference()));
|
||||
if (settings.getAdmin().isEnabled()) {
|
||||
put(
|
||||
accounts,
|
||||
RedisCredentialRole.ADMIN,
|
||||
resolver.resolve("admin", settings.getAdmin().getCredentialReference()));
|
||||
}
|
||||
if (settings.getRaw().isEnabled()) {
|
||||
put(
|
||||
accounts,
|
||||
RedisCredentialRole.RAW,
|
||||
resolver.resolve("raw gateway", settings.getRaw().getCredentialReference()));
|
||||
}
|
||||
Optional<RedisCredentialResolver.RedisCredentials> sentinel =
|
||||
settings.getMode()
|
||||
== dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode.SENTINEL
|
||||
? resolver.resolve("sentinel", settings.getSentinel().getCredentialReference())
|
||||
: Optional.empty();
|
||||
LOG.info(
|
||||
"Redis accounts resolved for roles {}{}",
|
||||
accounts.keySet(),
|
||||
sentinel.isPresent() ? " plus the Sentinel control account" : "");
|
||||
return new RedisResolvedCredentials(accounts, sentinel);
|
||||
}
|
||||
|
||||
private static void put(
|
||||
Map<RedisCredentialRole, RedisCredentialResolver.RedisCredentials> accounts,
|
||||
RedisCredentialRole role,
|
||||
Optional<RedisCredentialResolver.RedisCredentials> resolved) {
|
||||
resolved.ifPresent(credentials -> accounts.put(role, credentials));
|
||||
}
|
||||
|
||||
private static RedisSecretSource environmentSecretSource() {
|
||||
// The default reads the process environment, which is where a mounted secret lands. A
|
||||
// deployment with a secret manager contributes its own RedisSecretSource bean.
|
||||
return name -> Optional.ofNullable(System.getenv(name)).filter(value -> !value.isBlank());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the one client the configured topology calls for.
|
||||
*
|
||||
* @param settings the validated settings
|
||||
* @param credentials the resolved credentials
|
||||
* @return the runtime client
|
||||
*/
|
||||
@Bean
|
||||
public RedisRuntimeClient redisRuntimeClient(
|
||||
RedisSdkSettings settings,
|
||||
RedisResolvedCredentials credentials,
|
||||
ResourceLoader resourceLoader) {
|
||||
return new RedisTopologyClientFactory(
|
||||
settings,
|
||||
credentials.accounts(),
|
||||
credentials.sentinel(),
|
||||
location -> tlsMaterial(resourceLoader, location).getInputStream())
|
||||
.create();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a TLS material location, whether it names a resource or a file.
|
||||
*
|
||||
* <p>Both spellings are ordinary. A CA bundled with the application is {@code
|
||||
* classpath:redis/ca.pem}; a CA mounted by the platform is {@code /etc/ssl/redis/ca.pem}, and the
|
||||
* mounted one is the more common of the two. Resolving everything as a file broke the first;
|
||||
* handing everything to the resource loader breaks the second, because a location with no prefix
|
||||
* is a <em>classpath</em> location to Spring — so {@code /etc/ssl/redis/ca.pem} would be looked
|
||||
* up on the classpath and reported missing while sitting on disk.
|
||||
*
|
||||
* @param resourceLoader the context's resource loader
|
||||
* @param location the configured location
|
||||
* @return the resolved resource
|
||||
*/
|
||||
private static Resource tlsMaterial(ResourceLoader resourceLoader, String location) {
|
||||
boolean prefixed =
|
||||
location.startsWith(ResourceLoader.CLASSPATH_URL_PREFIX)
|
||||
|| location.contains("://")
|
||||
|| location.startsWith("file:");
|
||||
return prefixed
|
||||
? resourceLoader.getResource(location)
|
||||
: new org.springframework.core.io.FileSystemResource(location);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns every connection and the order they are torn down in.
|
||||
*
|
||||
* <p>Destroyed by Spring, and destroyed before the client bean it wraps because it depends on it
|
||||
* — which is the order shutdown needs: connections drain and close, then the event loop stops.
|
||||
*
|
||||
* @param client the runtime client
|
||||
* @param settings the validated settings
|
||||
* @return the lifecycle owner
|
||||
*/
|
||||
@Bean(destroyMethod = "close")
|
||||
public RedisRuntimeOwner redisRuntimeOwner(RedisRuntimeClient client, RedisSdkSettings settings) {
|
||||
Map<RedisConnectionKind, Integer> limits = new EnumMap<>(RedisConnectionKind.class);
|
||||
limits.put(RedisConnectionKind.REGULAR, settings.getCapacity().getMaximumInFlightCommands());
|
||||
limits.put(RedisConnectionKind.BLOCKING, settings.getBlocking().getMaxConnections());
|
||||
limits.put(RedisConnectionKind.TRANSACTION, settings.getTransaction().getMaxConnections());
|
||||
limits.put(RedisConnectionKind.SCRIPT, settings.getCapacity().getMaximumInFlightCommands());
|
||||
limits.put(
|
||||
RedisConnectionKind.PUBSUB, Math.max(1, settings.getPubsub().getBufferCapacity() / 64));
|
||||
limits.put(RedisConnectionKind.ADMIN, settings.getAdmin().isEnabled() ? 2 : 1);
|
||||
return new RedisRuntimeOwner(client, limits, settings.getLifecycle().getDrainTimeout());
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional-Redis health contributor: a cache outage is detail, never unreadiness.
|
||||
*
|
||||
* <p>Bean name {@code redisOptional}, and deliberately outside the readiness group. Turning a pod
|
||||
* unready because its cache is down removes capacity from a system that is already slower than
|
||||
* usual, which is the opposite of what the outage needs.
|
||||
*
|
||||
* @param owner the runtime owner
|
||||
* @param settings the validated settings
|
||||
* @return the contributor
|
||||
*/
|
||||
@Bean(RedisCorrectnessRoles.OPTIONAL_HEALTH_CONTRIBUTOR)
|
||||
public HealthIndicator redisOptional(RedisRuntimeOwner owner, RedisSdkSettings settings) {
|
||||
RedisHealthContributor contributor =
|
||||
new RedisHealthContributor(owner, settings.getTimeout().getFast());
|
||||
return () -> {
|
||||
RedisHealthContributor.RedisHealth health = contributor.probe();
|
||||
return Health.status(health.reachable() ? Status.UP : DEGRADED)
|
||||
.withDetails(health.detail())
|
||||
.build();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The required-Redis health contributor, present only when a correctness role is bound.
|
||||
*
|
||||
* <p>Bean name {@code redisRequired}, and the readiness group names it. Session, idempotency,
|
||||
* rate-limit and lease all depend on Redis for correctness rather than speed: serving traffic
|
||||
* without them is worse than not serving it, so this one does flip readiness.
|
||||
*
|
||||
* <p>Conditional on a role actually selecting Redis. A deployment that runs Redis purely as a
|
||||
* cache has no correctness role to gate on, and a required contributor there would make a cache
|
||||
* outage an outage.
|
||||
*
|
||||
* @param owner the runtime owner
|
||||
* @param settings the validated settings
|
||||
* @return the contributor
|
||||
*/
|
||||
@Bean(RedisCorrectnessRoles.REQUIRED_HEALTH_CONTRIBUTOR)
|
||||
@Conditional(RedisCorrectnessRoleBound.class)
|
||||
public HealthIndicator redisRequired(RedisRuntimeOwner owner, RedisSdkSettings settings) {
|
||||
RedisHealthContributor contributor =
|
||||
new RedisHealthContributor(owner, settings.getTimeout().getFast());
|
||||
return () -> {
|
||||
RedisHealthContributor.RedisHealth health = contributor.probe();
|
||||
return Health.status(health.reachable() ? Status.UP : Status.DOWN)
|
||||
.withDetails(health.detail())
|
||||
.build();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Present when a role that needs Redis for <em>correctness</em> selected it.
|
||||
*
|
||||
* <p>Derived from the role selectors rather than a separate flag, because a separate flag is a
|
||||
* second thing to keep in sync — and the failure mode of forgetting it is a readiness probe that
|
||||
* does not gate on a dependency the deployment cannot serve without.
|
||||
*/
|
||||
static final class RedisCorrectnessRoleBound implements Condition {
|
||||
|
||||
@Override
|
||||
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
|
||||
// The same predicate the readiness group's post-processor asks. Duplicating it here is what
|
||||
// produced a group naming a contributor nothing could create.
|
||||
return RedisCorrectnessRoles.anySelected(context.getEnvironment());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account resolved for each role the configuration named.
|
||||
*
|
||||
* <p>A role that is absent from {@code accounts} has no account of its own and runs on the
|
||||
* application client. That is a deployment decision, taken by not configuring one, rather than a
|
||||
* default the SDK picks.
|
||||
*
|
||||
* @param accounts the resolved account per configured role
|
||||
* @param sentinel the Sentinel control account, on a Sentinel deployment
|
||||
*/
|
||||
public record RedisResolvedCredentials(
|
||||
Map<RedisCredentialRole, RedisCredentialResolver.RedisCredentials> accounts,
|
||||
Optional<RedisCredentialResolver.RedisCredentials> sentinel) {
|
||||
|
||||
public RedisResolvedCredentials {
|
||||
accounts = Map.copyOf(accounts);
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a credential reference's value comes from. */
|
||||
@FunctionalInterface
|
||||
public interface RedisSecretSource {
|
||||
|
||||
/**
|
||||
* Resolves a secret by name.
|
||||
*
|
||||
* @param name the secret name
|
||||
* @return the value, or empty when the source does not have it
|
||||
*/
|
||||
Optional<String> resolve(String name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of validating the Redis settings at startup.
|
||||
*
|
||||
* @param warnings settings that are within the guardrails but worth an operator's attention
|
||||
*/
|
||||
public record RedisSdkSettingsValidation(List<String> warnings) {
|
||||
|
||||
public RedisSdkSettingsValidation {
|
||||
warnings = List.copyOf(warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
+943
@@ -0,0 +1,943 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.sdk.config;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisKeyRules;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Typed configuration for the Redis SDK, bound from {@code app.redis.*}.
|
||||
*
|
||||
* <p>Defaults are the skeleton guardrails from the design. Validation is fail-closed and runs at
|
||||
* startup: a setting that would make a guardrail meaningless — a non-zero database on Cluster, an
|
||||
* unbounded block, a raw gateway without an allowlist — stops the context rather than degrading
|
||||
* quietly at the first request.
|
||||
*
|
||||
* <p>This class carries no {@code @ConfigurationProperties} annotation on purpose. It is registered
|
||||
* and bound only by {@link RedisSdkAutoConfiguration}, which exists only while {@code
|
||||
* app.redis.enabled} is true. Annotating the class would put it back inside the application-wide
|
||||
* {@code @ConfigurationPropertiesScan}, and a Redis-free deployment would once again bind Redis
|
||||
* configuration — the exact defect this arrangement removes.
|
||||
*/
|
||||
public class RedisSdkSettings {
|
||||
|
||||
/** Hard ceiling above which a configured timeout is a startup failure. */
|
||||
public static final Duration MAXIMUM_TIMEOUT = Duration.ofSeconds(30);
|
||||
|
||||
/** Timeout above which a configured fast profile produces a startup warning. */
|
||||
public static final Duration FAST_TIMEOUT_WARNING_THRESHOLD = Duration.ofSeconds(5);
|
||||
|
||||
private boolean enabled;
|
||||
private RedisDeploymentMode mode = RedisDeploymentMode.STANDALONE;
|
||||
private List<String> nodes = new ArrayList<>(List.of("localhost:6379"));
|
||||
private int database;
|
||||
private boolean acknowledgedWriteLossAccepted;
|
||||
private final Namespace namespace = new Namespace();
|
||||
private final Timeouts timeout = new Timeouts();
|
||||
private final Limits limits = new Limits();
|
||||
private final Blocking blocking = new Blocking();
|
||||
private final Transaction transaction = new Transaction();
|
||||
private final Advanced advanced = new Advanced();
|
||||
private final Authentication authentication = new Authentication();
|
||||
private final Sentinel sentinel = new Sentinel();
|
||||
private final Tls tls = new Tls();
|
||||
private final Lifecycle lifecycle = new Lifecycle();
|
||||
private final Cluster cluster = new Cluster();
|
||||
private final Capacity capacity = new Capacity();
|
||||
private final PubSub pubsub = new PubSub();
|
||||
private final Raw raw = new Raw();
|
||||
private final Admin admin = new Admin();
|
||||
|
||||
/**
|
||||
* Validates every cross-field rule.
|
||||
*
|
||||
* @return non-fatal warnings; an empty list means the configuration is entirely within guardrails
|
||||
* @throws IllegalStateException when a setting would disable a guardrail
|
||||
*/
|
||||
public List<String> validate() {
|
||||
List<String> warnings = new ArrayList<>();
|
||||
if (mode == RedisDeploymentMode.CLUSTER && database != 0) {
|
||||
throw new IllegalStateException("Cluster supports database 0 only");
|
||||
}
|
||||
if (database < 0) {
|
||||
throw new IllegalStateException("database index must not be negative");
|
||||
}
|
||||
if (nodes == null || nodes.isEmpty()) {
|
||||
throw new IllegalStateException("at least one Redis node must be configured");
|
||||
}
|
||||
namespace.validate();
|
||||
limits.validate();
|
||||
validateTimeout("fast", timeout.getFast(), warnings);
|
||||
validateTimeout("collection", timeout.getCollection(), warnings);
|
||||
validateTimeout("script", timeout.getScript(), warnings);
|
||||
validateTimeout("batch", timeout.getBatch(), warnings);
|
||||
validateTimeout("admin", timeout.getAdmin(), warnings);
|
||||
if (blocking.getMaxBlock().isZero() || blocking.getMaxBlock().isNegative()) {
|
||||
throw new IllegalStateException("blocking commands must not be unbounded");
|
||||
}
|
||||
if (blocking.getMaxConnections() < 1 || transaction.getMaxConnections() < 1) {
|
||||
throw new IllegalStateException("dedicated connection lanes need a positive ceiling");
|
||||
}
|
||||
if (mode == RedisDeploymentMode.SENTINEL
|
||||
&& (sentinel.getMasterName() == null || sentinel.getMasterName().isBlank())) {
|
||||
throw new IllegalStateException(
|
||||
"a Sentinel deployment must name the monitored primary; without it the client cannot"
|
||||
+ " resolve a primary at all, let alone follow a promotion");
|
||||
}
|
||||
if (tls.isEnabled() && !tls.isHostnameVerification()) {
|
||||
warnings.add(
|
||||
"TLS is enabled with hostname verification disabled, which accepts any certificate the"
|
||||
+ " trust material signs, for any host");
|
||||
}
|
||||
lifecycle.validate();
|
||||
capacity.validate();
|
||||
cluster.validate();
|
||||
pubsub.validate();
|
||||
if (raw.isEnabled() && (raw.getPolicyResource() == null || raw.getPolicyResource().isBlank())) {
|
||||
throw new IllegalStateException("the raw gateway requires an allowlist resource");
|
||||
}
|
||||
if (raw.isEnabled()
|
||||
&& (raw.getCredentialReference() == null || raw.getCredentialReference().isBlank())) {
|
||||
throw new IllegalStateException("the raw gateway requires its own credential reference");
|
||||
}
|
||||
if (admin.isEnabled()
|
||||
&& (admin.getCredentialReference() == null || admin.getCredentialReference().isBlank())) {
|
||||
throw new IllegalStateException("the admin plane requires its own credential reference");
|
||||
}
|
||||
if (!advanced.isEnabled() && !advanced.getPolicies().isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"advanced permit policies are configured while advanced operations are disabled");
|
||||
}
|
||||
// Last, deliberately. A deployment with both a structural mistake and a missing credential
|
||||
// should be told about the structural one first: it is the cheaper thing to be wrong about,
|
||||
// and reporting "no credential" for a configuration that could never have connected anyway
|
||||
// sends the operator to the wrong file.
|
||||
authentication.validate(warnings);
|
||||
return List.copyOf(warnings);
|
||||
}
|
||||
|
||||
private static void validateTimeout(String name, Duration value, List<String> warnings) {
|
||||
if (value == null || value.isZero() || value.isNegative()) {
|
||||
throw new IllegalStateException(name + " timeout must be positive");
|
||||
}
|
||||
if (value.compareTo(MAXIMUM_TIMEOUT) > 0) {
|
||||
throw new IllegalStateException(
|
||||
name + " timeout must not exceed " + MAXIMUM_TIMEOUT.toSeconds() + "s");
|
||||
}
|
||||
if ("fast".equals(name) && value.compareTo(FAST_TIMEOUT_WARNING_THRESHOLD) > 0) {
|
||||
warnings.add(
|
||||
"fast timeout of " + value + " is far above the 500ms guardrail for single-key commands");
|
||||
}
|
||||
}
|
||||
|
||||
/** Namespace tokens applied to every key this process writes. */
|
||||
public static class Namespace {
|
||||
|
||||
private String environment = "local";
|
||||
private String service = "sample-service";
|
||||
private String domain = "shared";
|
||||
|
||||
void validate() {
|
||||
RedisKeyRules.requireToken("environment", environment);
|
||||
RedisKeyRules.requireToken("service", service);
|
||||
RedisKeyRules.requireToken("domain", domain);
|
||||
}
|
||||
|
||||
public String getEnvironment() {
|
||||
return environment;
|
||||
}
|
||||
|
||||
public void setEnvironment(String environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
public String getService() {
|
||||
return service;
|
||||
}
|
||||
|
||||
public void setService(String service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
public String getDomain() {
|
||||
return domain;
|
||||
}
|
||||
|
||||
public void setDomain(String domain) {
|
||||
this.domain = domain;
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-profile timeout guardrails. */
|
||||
public static class Timeouts {
|
||||
|
||||
private Duration fast = Duration.ofMillis(500);
|
||||
private Duration collection = Duration.ofSeconds(2);
|
||||
private Duration script = Duration.ofSeconds(1);
|
||||
private Duration batch = Duration.ofSeconds(2);
|
||||
private Duration admin = Duration.ofSeconds(3);
|
||||
|
||||
public Duration getFast() {
|
||||
return fast;
|
||||
}
|
||||
|
||||
public void setFast(Duration fast) {
|
||||
this.fast = fast;
|
||||
}
|
||||
|
||||
public Duration getCollection() {
|
||||
return collection;
|
||||
}
|
||||
|
||||
public void setCollection(Duration collection) {
|
||||
this.collection = collection;
|
||||
}
|
||||
|
||||
public Duration getScript() {
|
||||
return script;
|
||||
}
|
||||
|
||||
public void setScript(Duration script) {
|
||||
this.script = script;
|
||||
}
|
||||
|
||||
public Duration getBatch() {
|
||||
return batch;
|
||||
}
|
||||
|
||||
public void setBatch(Duration batch) {
|
||||
this.batch = batch;
|
||||
}
|
||||
|
||||
public Duration getAdmin() {
|
||||
return admin;
|
||||
}
|
||||
|
||||
public void setAdmin(Duration admin) {
|
||||
this.admin = admin;
|
||||
}
|
||||
}
|
||||
|
||||
/** Size and count ceilings enforced before Redis is called. */
|
||||
public static class Limits {
|
||||
|
||||
private int maxKeyBytes = 512;
|
||||
private long maxValueBytes = 1_048_576L;
|
||||
private long maxStreamPayloadBytes = 262_144L;
|
||||
private long maxHashFieldValueBytes = 524_288L;
|
||||
private int maxCollectionElements = 1_000;
|
||||
private int maxScanCount = 500;
|
||||
private int maxBatchCommands = 500;
|
||||
private long maxBatchRequestBytes = 4L * 1024 * 1024;
|
||||
private long maxBatchReplyBytes = 16L * 1024 * 1024;
|
||||
private int offlineQueueCommands = 1_000;
|
||||
private long maxBitmapOffset = 10_000_000L;
|
||||
|
||||
void validate() {
|
||||
if (maxKeyBytes < 1 || maxKeyBytes > RedisKeyRules.MAX_KEY_BYTES) {
|
||||
throw new IllegalStateException(
|
||||
"max-key-bytes must be in 1.." + RedisKeyRules.MAX_KEY_BYTES);
|
||||
}
|
||||
if (maxValueBytes < 1
|
||||
|| maxStreamPayloadBytes < 1
|
||||
|| maxHashFieldValueBytes < 1
|
||||
|| maxCollectionElements < 1
|
||||
|| maxScanCount < 1
|
||||
|| maxBatchCommands < 1
|
||||
|| maxBatchRequestBytes < 1
|
||||
|| maxBatchReplyBytes < 1
|
||||
|| offlineQueueCommands < 1
|
||||
|| maxBitmapOffset < 1) {
|
||||
throw new IllegalStateException("every Redis SDK limit must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public int getMaxKeyBytes() {
|
||||
return maxKeyBytes;
|
||||
}
|
||||
|
||||
public void setMaxKeyBytes(int maxKeyBytes) {
|
||||
this.maxKeyBytes = maxKeyBytes;
|
||||
}
|
||||
|
||||
public long getMaxValueBytes() {
|
||||
return maxValueBytes;
|
||||
}
|
||||
|
||||
public void setMaxValueBytes(long maxValueBytes) {
|
||||
this.maxValueBytes = maxValueBytes;
|
||||
}
|
||||
|
||||
public long getMaxStreamPayloadBytes() {
|
||||
return maxStreamPayloadBytes;
|
||||
}
|
||||
|
||||
public void setMaxStreamPayloadBytes(long maxStreamPayloadBytes) {
|
||||
this.maxStreamPayloadBytes = maxStreamPayloadBytes;
|
||||
}
|
||||
|
||||
public long getMaxHashFieldValueBytes() {
|
||||
return maxHashFieldValueBytes;
|
||||
}
|
||||
|
||||
public void setMaxHashFieldValueBytes(long maxHashFieldValueBytes) {
|
||||
this.maxHashFieldValueBytes = maxHashFieldValueBytes;
|
||||
}
|
||||
|
||||
public int getMaxCollectionElements() {
|
||||
return maxCollectionElements;
|
||||
}
|
||||
|
||||
public void setMaxCollectionElements(int maxCollectionElements) {
|
||||
this.maxCollectionElements = maxCollectionElements;
|
||||
}
|
||||
|
||||
public int getMaxScanCount() {
|
||||
return maxScanCount;
|
||||
}
|
||||
|
||||
public void setMaxScanCount(int maxScanCount) {
|
||||
this.maxScanCount = maxScanCount;
|
||||
}
|
||||
|
||||
public int getMaxBatchCommands() {
|
||||
return maxBatchCommands;
|
||||
}
|
||||
|
||||
public void setMaxBatchCommands(int maxBatchCommands) {
|
||||
this.maxBatchCommands = maxBatchCommands;
|
||||
}
|
||||
|
||||
public long getMaxBatchRequestBytes() {
|
||||
return maxBatchRequestBytes;
|
||||
}
|
||||
|
||||
public void setMaxBatchRequestBytes(long maxBatchRequestBytes) {
|
||||
this.maxBatchRequestBytes = maxBatchRequestBytes;
|
||||
}
|
||||
|
||||
public long getMaxBatchReplyBytes() {
|
||||
return maxBatchReplyBytes;
|
||||
}
|
||||
|
||||
public void setMaxBatchReplyBytes(long maxBatchReplyBytes) {
|
||||
this.maxBatchReplyBytes = maxBatchReplyBytes;
|
||||
}
|
||||
|
||||
public int getOfflineQueueCommands() {
|
||||
return offlineQueueCommands;
|
||||
}
|
||||
|
||||
public void setOfflineQueueCommands(int offlineQueueCommands) {
|
||||
this.offlineQueueCommands = offlineQueueCommands;
|
||||
}
|
||||
|
||||
public long getMaxBitmapOffset() {
|
||||
return maxBitmapOffset;
|
||||
}
|
||||
|
||||
public void setMaxBitmapOffset(long maxBitmapOffset) {
|
||||
this.maxBitmapOffset = maxBitmapOffset;
|
||||
}
|
||||
}
|
||||
|
||||
/** Blocking lane ceilings. */
|
||||
public static class Blocking {
|
||||
|
||||
private int maxConnections = 32;
|
||||
private Duration maxBlock = Duration.ofSeconds(30);
|
||||
|
||||
public int getMaxConnections() {
|
||||
return maxConnections;
|
||||
}
|
||||
|
||||
public void setMaxConnections(int maxConnections) {
|
||||
this.maxConnections = maxConnections;
|
||||
}
|
||||
|
||||
public Duration getMaxBlock() {
|
||||
return maxBlock;
|
||||
}
|
||||
|
||||
public void setMaxBlock(Duration maxBlock) {
|
||||
this.maxBlock = maxBlock;
|
||||
}
|
||||
}
|
||||
|
||||
/** Transaction lane ceilings. */
|
||||
public static class Transaction {
|
||||
|
||||
private int maxConnections = 16;
|
||||
|
||||
public int getMaxConnections() {
|
||||
return maxConnections;
|
||||
}
|
||||
|
||||
public void setMaxConnections(int maxConnections) {
|
||||
this.maxConnections = maxConnections;
|
||||
}
|
||||
}
|
||||
|
||||
/** Application authentication material, carried as references rather than values. */
|
||||
public static class Authentication {
|
||||
|
||||
private String credentialReference;
|
||||
private String advancedCredentialReference;
|
||||
private String pubsubCredentialReference;
|
||||
private boolean anonymousAccessAccepted;
|
||||
|
||||
void validate(List<String> warnings) {
|
||||
if (credentialReference == null || credentialReference.isBlank()) {
|
||||
if (!anonymousAccessAccepted) {
|
||||
throw new IllegalStateException(
|
||||
"Redis is enabled but no application credential reference is configured. Booting"
|
||||
+ " anyway builds an unauthenticated client, which on any deployment that"
|
||||
+ " disabled the `default` ACL user cannot run a single command — the failure"
|
||||
+ " simply moves from startup to the first request, where it looks like an"
|
||||
+ " outage instead of a missing setting. Set"
|
||||
+ " app.redis.authentication.credential-reference, or declare the trade with"
|
||||
+ " app.redis.authentication.anonymous-access-accepted=true.");
|
||||
}
|
||||
warnings.add(
|
||||
"Redis is running without credentials because"
|
||||
+ " app.redis.authentication.anonymous-access-accepted is true; every command runs"
|
||||
+ " as the `default` ACL user");
|
||||
}
|
||||
if (advancedCredentialReference == null || advancedCredentialReference.isBlank()) {
|
||||
// Not a failure: one account is a legitimate deployment. But it is worth saying out loud,
|
||||
// because it means the account that reads cache entries can also execute scripts.
|
||||
warnings.add(
|
||||
"no advanced credential reference is configured, so registered scripts run as the"
|
||||
+ " application account — that account therefore needs SCRIPT LOAD and EVALSHA,"
|
||||
+ " and every code path that reaches a regular connection has them too");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether this deployment declared that it accepts running Redis unauthenticated.
|
||||
*
|
||||
* <p>Leave this false. It exists so that a local single-container Redis is a one-line
|
||||
* declaration rather than a reason to weaken the check for everybody, and so that the
|
||||
* declaration is visible in the deployment's own configuration.
|
||||
*
|
||||
* @return {@code true} when anonymous access is accepted
|
||||
*/
|
||||
public boolean isAnonymousAccessAccepted() {
|
||||
return anonymousAccessAccepted;
|
||||
}
|
||||
|
||||
public void setAnonymousAccessAccepted(boolean anonymousAccessAccepted) {
|
||||
this.anonymousAccessAccepted = anonymousAccessAccepted;
|
||||
}
|
||||
|
||||
public String getCredentialReference() {
|
||||
return credentialReference;
|
||||
}
|
||||
|
||||
public void setCredentialReference(String credentialReference) {
|
||||
this.credentialReference = credentialReference;
|
||||
}
|
||||
|
||||
public String getAdvancedCredentialReference() {
|
||||
return advancedCredentialReference;
|
||||
}
|
||||
|
||||
public void setAdvancedCredentialReference(String advancedCredentialReference) {
|
||||
this.advancedCredentialReference = advancedCredentialReference;
|
||||
}
|
||||
|
||||
public String getPubsubCredentialReference() {
|
||||
return pubsubCredentialReference;
|
||||
}
|
||||
|
||||
public void setPubsubCredentialReference(String pubsubCredentialReference) {
|
||||
this.pubsubCredentialReference = pubsubCredentialReference;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sentinel discovery. */
|
||||
public static class Sentinel {
|
||||
|
||||
private String masterName;
|
||||
private List<String> nodes = new ArrayList<>();
|
||||
private String credentialReference;
|
||||
|
||||
public String getMasterName() {
|
||||
return masterName;
|
||||
}
|
||||
|
||||
public void setMasterName(String masterName) {
|
||||
this.masterName = masterName;
|
||||
}
|
||||
|
||||
public List<String> getNodes() {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
public void setNodes(List<String> nodes) {
|
||||
this.nodes = nodes;
|
||||
}
|
||||
|
||||
public String getCredentialReference() {
|
||||
return credentialReference;
|
||||
}
|
||||
|
||||
public void setCredentialReference(String credentialReference) {
|
||||
this.credentialReference = credentialReference;
|
||||
}
|
||||
}
|
||||
|
||||
/** Transport security. */
|
||||
public static class Tls {
|
||||
|
||||
private boolean enabled;
|
||||
private boolean hostnameVerification = true;
|
||||
private String trustMaterialResource;
|
||||
private String clientCertificateResource;
|
||||
private String clientKeyReference;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public boolean isHostnameVerification() {
|
||||
return hostnameVerification;
|
||||
}
|
||||
|
||||
public void setHostnameVerification(boolean hostnameVerification) {
|
||||
this.hostnameVerification = hostnameVerification;
|
||||
}
|
||||
|
||||
public String getTrustMaterialResource() {
|
||||
return trustMaterialResource;
|
||||
}
|
||||
|
||||
public void setTrustMaterialResource(String trustMaterialResource) {
|
||||
this.trustMaterialResource = trustMaterialResource;
|
||||
}
|
||||
|
||||
public String getClientCertificateResource() {
|
||||
return clientCertificateResource;
|
||||
}
|
||||
|
||||
public void setClientCertificateResource(String clientCertificateResource) {
|
||||
this.clientCertificateResource = clientCertificateResource;
|
||||
}
|
||||
|
||||
public String getClientKeyReference() {
|
||||
return clientKeyReference;
|
||||
}
|
||||
|
||||
public void setClientKeyReference(String clientKeyReference) {
|
||||
this.clientKeyReference = clientKeyReference;
|
||||
}
|
||||
}
|
||||
|
||||
/** Client lifecycle timings. */
|
||||
public static class Lifecycle {
|
||||
|
||||
private String clientName = "ca-skeleton";
|
||||
private Duration connectTimeout = Duration.ofSeconds(2);
|
||||
private Duration tlsHandshakeTimeout = Duration.ofSeconds(3);
|
||||
private Duration acquireTimeout = Duration.ofSeconds(2);
|
||||
private Duration shutdownQuietPeriod = Duration.ofMillis(100);
|
||||
private Duration shutdownTimeout = Duration.ofSeconds(3);
|
||||
private Duration drainTimeout = Duration.ofSeconds(6);
|
||||
|
||||
void validate() {
|
||||
if (clientName == null || clientName.isBlank()) {
|
||||
throw new IllegalStateException("the client name must not be blank");
|
||||
}
|
||||
requirePositive("connect", connectTimeout);
|
||||
requirePositive("tls-handshake", tlsHandshakeTimeout);
|
||||
requirePositive("acquire", acquireTimeout);
|
||||
requirePositive("shutdown", shutdownTimeout);
|
||||
requirePositive("drain", drainTimeout);
|
||||
if (shutdownQuietPeriod == null || shutdownQuietPeriod.isNegative()) {
|
||||
throw new IllegalStateException("the shutdown quiet period must not be negative");
|
||||
}
|
||||
if (shutdownQuietPeriod.compareTo(shutdownTimeout) > 0) {
|
||||
throw new IllegalStateException(
|
||||
"the shutdown quiet period must not exceed the shutdown timeout, or shutdown can never"
|
||||
+ " complete within its own budget");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requirePositive(String name, Duration value) {
|
||||
if (value == null || value.isZero() || value.isNegative()) {
|
||||
throw new IllegalStateException(name + " timeout must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public String getClientName() {
|
||||
return clientName;
|
||||
}
|
||||
|
||||
public void setClientName(String clientName) {
|
||||
this.clientName = clientName;
|
||||
}
|
||||
|
||||
public Duration getConnectTimeout() {
|
||||
return connectTimeout;
|
||||
}
|
||||
|
||||
public void setConnectTimeout(Duration connectTimeout) {
|
||||
this.connectTimeout = connectTimeout;
|
||||
}
|
||||
|
||||
public Duration getTlsHandshakeTimeout() {
|
||||
return tlsHandshakeTimeout;
|
||||
}
|
||||
|
||||
public void setTlsHandshakeTimeout(Duration tlsHandshakeTimeout) {
|
||||
this.tlsHandshakeTimeout = tlsHandshakeTimeout;
|
||||
}
|
||||
|
||||
public Duration getAcquireTimeout() {
|
||||
return acquireTimeout;
|
||||
}
|
||||
|
||||
public void setAcquireTimeout(Duration acquireTimeout) {
|
||||
this.acquireTimeout = acquireTimeout;
|
||||
}
|
||||
|
||||
public Duration getShutdownQuietPeriod() {
|
||||
return shutdownQuietPeriod;
|
||||
}
|
||||
|
||||
public void setShutdownQuietPeriod(Duration shutdownQuietPeriod) {
|
||||
this.shutdownQuietPeriod = shutdownQuietPeriod;
|
||||
}
|
||||
|
||||
public Duration getShutdownTimeout() {
|
||||
return shutdownTimeout;
|
||||
}
|
||||
|
||||
public void setShutdownTimeout(Duration shutdownTimeout) {
|
||||
this.shutdownTimeout = shutdownTimeout;
|
||||
}
|
||||
|
||||
public Duration getDrainTimeout() {
|
||||
return drainTimeout;
|
||||
}
|
||||
|
||||
public void setDrainTimeout(Duration drainTimeout) {
|
||||
this.drainTimeout = drainTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
/** Cluster routing. */
|
||||
public static class Cluster {
|
||||
|
||||
private int maximumRedirects = 5;
|
||||
private Duration topologyRefreshPeriod = Duration.ofSeconds(30);
|
||||
|
||||
void validate() {
|
||||
if (maximumRedirects < 1) {
|
||||
throw new IllegalStateException("cluster maximum redirects must be positive");
|
||||
}
|
||||
if (topologyRefreshPeriod == null
|
||||
|| topologyRefreshPeriod.isZero()
|
||||
|| topologyRefreshPeriod.isNegative()) {
|
||||
throw new IllegalStateException("the cluster topology refresh period must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public int getMaximumRedirects() {
|
||||
return maximumRedirects;
|
||||
}
|
||||
|
||||
public void setMaximumRedirects(int maximumRedirects) {
|
||||
this.maximumRedirects = maximumRedirects;
|
||||
}
|
||||
|
||||
public Duration getTopologyRefreshPeriod() {
|
||||
return topologyRefreshPeriod;
|
||||
}
|
||||
|
||||
public void setTopologyRefreshPeriod(Duration topologyRefreshPeriod) {
|
||||
this.topologyRefreshPeriod = topologyRefreshPeriod;
|
||||
}
|
||||
}
|
||||
|
||||
/** In-flight capacity ceilings. */
|
||||
public static class Capacity {
|
||||
|
||||
private int maximumInFlightCommands = 64;
|
||||
private long maximumInFlightBytes = 4L * 1024 * 1024;
|
||||
private long maximumReplyBytes = 16L * 1024 * 1024;
|
||||
private boolean rejectWhenDisconnected = true;
|
||||
|
||||
void validate() {
|
||||
if (maximumInFlightCommands < 1 || maximumInFlightBytes < 1 || maximumReplyBytes < 1) {
|
||||
throw new IllegalStateException("every Redis capacity ceiling must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public int getMaximumInFlightCommands() {
|
||||
return maximumInFlightCommands;
|
||||
}
|
||||
|
||||
public void setMaximumInFlightCommands(int maximumInFlightCommands) {
|
||||
this.maximumInFlightCommands = maximumInFlightCommands;
|
||||
}
|
||||
|
||||
public long getMaximumInFlightBytes() {
|
||||
return maximumInFlightBytes;
|
||||
}
|
||||
|
||||
public void setMaximumInFlightBytes(long maximumInFlightBytes) {
|
||||
this.maximumInFlightBytes = maximumInFlightBytes;
|
||||
}
|
||||
|
||||
public long getMaximumReplyBytes() {
|
||||
return maximumReplyBytes;
|
||||
}
|
||||
|
||||
public void setMaximumReplyBytes(long maximumReplyBytes) {
|
||||
this.maximumReplyBytes = maximumReplyBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether a command issued while the connection is down is refused rather than queued.
|
||||
*
|
||||
* <p>Leave this true. Lettuce's default is to hold commands in an offline queue and replay them
|
||||
* on reconnect, which turns a five-second outage into a burst of writes whose ordering relative
|
||||
* to everything that happened during the outage is arbitrary.
|
||||
*
|
||||
* @return {@code true} when a disconnected client refuses commands
|
||||
*/
|
||||
public boolean isRejectWhenDisconnected() {
|
||||
return rejectWhenDisconnected;
|
||||
}
|
||||
|
||||
public void setRejectWhenDisconnected(boolean rejectWhenDisconnected) {
|
||||
this.rejectWhenDisconnected = rejectWhenDisconnected;
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscription delivery. */
|
||||
public static class PubSub {
|
||||
|
||||
private int bufferCapacity = 1_024;
|
||||
private String overflowPolicy = "error";
|
||||
|
||||
void validate() {
|
||||
if (bufferCapacity < 1) {
|
||||
throw new IllegalStateException("the pub/sub buffer capacity must be positive");
|
||||
}
|
||||
if (!List.of("error", "drop-oldest", "drop-latest").contains(overflowPolicy)) {
|
||||
throw new IllegalStateException(
|
||||
"the pub/sub overflow policy must be error, drop-oldest, or drop-latest");
|
||||
}
|
||||
}
|
||||
|
||||
public int getBufferCapacity() {
|
||||
return bufferCapacity;
|
||||
}
|
||||
|
||||
public void setBufferCapacity(int bufferCapacity) {
|
||||
this.bufferCapacity = bufferCapacity;
|
||||
}
|
||||
|
||||
public String getOverflowPolicy() {
|
||||
return overflowPolicy;
|
||||
}
|
||||
|
||||
public void setOverflowPolicy(String overflowPolicy) {
|
||||
this.overflowPolicy = overflowPolicy;
|
||||
}
|
||||
}
|
||||
|
||||
/** Advanced R2 exposure. */
|
||||
public static class Advanced {
|
||||
|
||||
private boolean enabled;
|
||||
private List<String> policies = new ArrayList<>();
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public List<String> getPolicies() {
|
||||
return policies;
|
||||
}
|
||||
|
||||
public void setPolicies(List<String> policies) {
|
||||
this.policies = policies;
|
||||
}
|
||||
}
|
||||
|
||||
/** Approved raw gateway exposure. */
|
||||
public static class Raw {
|
||||
|
||||
private boolean enabled;
|
||||
private String policyResource = "classpath:redis-sdk/raw-command-allowlist.yml";
|
||||
private String credentialReference;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getPolicyResource() {
|
||||
return policyResource;
|
||||
}
|
||||
|
||||
public void setPolicyResource(String policyResource) {
|
||||
this.policyResource = policyResource;
|
||||
}
|
||||
|
||||
public String getCredentialReference() {
|
||||
return credentialReference;
|
||||
}
|
||||
|
||||
public void setCredentialReference(String credentialReference) {
|
||||
this.credentialReference = credentialReference;
|
||||
}
|
||||
}
|
||||
|
||||
/** Isolated admin plane exposure. */
|
||||
public static class Admin {
|
||||
|
||||
private boolean enabled;
|
||||
private String credentialReference;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getCredentialReference() {
|
||||
return credentialReference;
|
||||
}
|
||||
|
||||
public void setCredentialReference(String credentialReference) {
|
||||
this.credentialReference = credentialReference;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public RedisDeploymentMode getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(RedisDeploymentMode mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public List<String> getNodes() {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
public void setNodes(List<String> nodes) {
|
||||
this.nodes = nodes;
|
||||
}
|
||||
|
||||
public int getDatabase() {
|
||||
return database;
|
||||
}
|
||||
|
||||
public void setDatabase(int database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether this deployment has declared that it accepts losing acknowledged writes.
|
||||
*
|
||||
* <p>Leave this false unless the trade is deliberate. A replicated deployment without {@code
|
||||
* min-replicas-to-write} discards writes it told the caller had succeeded, and no client-side
|
||||
* signal exists for it; see {@link RedisCapabilityProbe#requireWriteDurability}.
|
||||
*
|
||||
* @return {@code true} when the loss is accepted
|
||||
*/
|
||||
public boolean isAcknowledgedWriteLossAccepted() {
|
||||
return acknowledgedWriteLossAccepted;
|
||||
}
|
||||
|
||||
public void setAcknowledgedWriteLossAccepted(boolean acknowledgedWriteLossAccepted) {
|
||||
this.acknowledgedWriteLossAccepted = acknowledgedWriteLossAccepted;
|
||||
}
|
||||
|
||||
public Namespace getNamespace() {
|
||||
return namespace;
|
||||
}
|
||||
|
||||
public Timeouts getTimeout() {
|
||||
return timeout;
|
||||
}
|
||||
|
||||
public Limits getLimits() {
|
||||
return limits;
|
||||
}
|
||||
|
||||
public Blocking getBlocking() {
|
||||
return blocking;
|
||||
}
|
||||
|
||||
public Transaction getTransaction() {
|
||||
return transaction;
|
||||
}
|
||||
|
||||
public Advanced getAdvanced() {
|
||||
return advanced;
|
||||
}
|
||||
|
||||
public Raw getRaw() {
|
||||
return raw;
|
||||
}
|
||||
|
||||
public Admin getAdmin() {
|
||||
return admin;
|
||||
}
|
||||
|
||||
public Authentication getAuthentication() {
|
||||
return authentication;
|
||||
}
|
||||
|
||||
public Sentinel getSentinel() {
|
||||
return sentinel;
|
||||
}
|
||||
|
||||
public Tls getTls() {
|
||||
return tls;
|
||||
}
|
||||
|
||||
public Lifecycle getLifecycle() {
|
||||
return lifecycle;
|
||||
}
|
||||
|
||||
public Cluster getCluster() {
|
||||
return cluster;
|
||||
}
|
||||
|
||||
public Capacity getCapacity() {
|
||||
return capacity;
|
||||
}
|
||||
|
||||
public PubSub getPubsub() {
|
||||
return pubsub;
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.sdk.config;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisCapabilities;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisCapability;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisVersion;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* Asks the server what it is, once, at startup.
|
||||
*
|
||||
* <p>Configuration says what the deployment intends; only the server says what is true. The version
|
||||
* a managed Redis advertises does not imply the modules are present, and a replicated deployment's
|
||||
* write durability is a server setting no client can compensate for. Both are cheap to ask and
|
||||
* expensive to discover later — a missing capability found at the first request is an outage, found
|
||||
* here it is a failed deploy.
|
||||
*
|
||||
* <p>This type holds no connection. It takes the three facts as inputs so the same logic is
|
||||
* exercised by unit tests and by the real lanes, and so the caller decides which account asks —
|
||||
* {@code INFO} and {@code CONFIG GET} are admin-plane, and the application account is denied them.
|
||||
*/
|
||||
public final class RedisStartupProbe {
|
||||
|
||||
private final RedisCapabilityProbe probe;
|
||||
|
||||
/**
|
||||
* Creates the probe.
|
||||
*
|
||||
* @param probe the capability probe
|
||||
*/
|
||||
public RedisStartupProbe(RedisCapabilityProbe probe) {
|
||||
this.probe = Objects.requireNonNull(probe, "capability probe must be non-null");
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms the server matches what the deployment declared.
|
||||
*
|
||||
* @param settings the validated settings
|
||||
* @param serverFacts what the server reported
|
||||
* @param requiredCapabilities capabilities the deployment explicitly enabled
|
||||
* @return the confirmed capability snapshot
|
||||
*/
|
||||
public RedisCapabilities confirm(
|
||||
RedisSdkSettings settings,
|
||||
ServerFacts serverFacts,
|
||||
Collection<RedisCapability> requiredCapabilities) {
|
||||
Objects.requireNonNull(settings, "settings must be non-null");
|
||||
Objects.requireNonNull(serverFacts, "server facts must be non-null");
|
||||
RedisCapabilities capabilities =
|
||||
probe.probe(
|
||||
serverFacts.version(),
|
||||
settings.getMode(),
|
||||
settings.getDatabase(),
|
||||
commandPresence(serverFacts.commands()),
|
||||
requiredCapabilities);
|
||||
probe.requireWriteDurability(
|
||||
settings.getMode(),
|
||||
serverFacts.minReplicasToWrite(),
|
||||
serverFacts.minReplicasMaxLagSeconds(),
|
||||
settings.isAcknowledgedWriteLossAccepted());
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
private static Predicate<CommandId> commandPresence(Set<String> reported) {
|
||||
// COMMAND INFO answers with the top-level command name; a subcommand's presence follows from
|
||||
// its container. Matching on the family keeps "does the server have FT.SEARCH" answerable
|
||||
// without asking the server about every subcommand the catalog knows.
|
||||
return commandId -> reported.contains(commandId.family().toLowerCase(java.util.Locale.ROOT));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the server reported at startup.
|
||||
*
|
||||
* @param version the version from {@code INFO server}
|
||||
* @param commands the command names from {@code COMMAND LIST}, lowercased
|
||||
* @param minReplicasToWrite the server's {@code min-replicas-to-write}
|
||||
* @param minReplicasMaxLagSeconds the server's {@code min-replicas-max-lag}
|
||||
*/
|
||||
public record ServerFacts(
|
||||
RedisVersion version,
|
||||
Set<String> commands,
|
||||
int minReplicasToWrite,
|
||||
int minReplicasMaxLagSeconds) {
|
||||
|
||||
public ServerFacts {
|
||||
Objects.requireNonNull(version, "version must be non-null");
|
||||
commands = Set.copyOf(commands);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the facts out of the raw replies, so parsing lives beside the contract it feeds.
|
||||
*
|
||||
* @param infoServer the {@code INFO server} payload
|
||||
* @param commandNames the command names the server reports
|
||||
* @param configuration the {@code CONFIG GET} projection
|
||||
* @return the parsed facts
|
||||
*/
|
||||
public static ServerFacts from(
|
||||
String infoServer, List<String> commandNames, Map<String, String> configuration) {
|
||||
RedisVersion version = null;
|
||||
for (String line : infoServer.lines().toList()) {
|
||||
if (line.startsWith("redis_version:")) {
|
||||
version = RedisVersion.parse(line.substring("redis_version:".length()).strip());
|
||||
}
|
||||
}
|
||||
if (version == null) {
|
||||
throw new IllegalStateException(
|
||||
"the server did not report a version; the SDK will not guess one, because every"
|
||||
+ " capability decision below depends on it");
|
||||
}
|
||||
return new ServerFacts(
|
||||
version,
|
||||
commandNames.stream()
|
||||
.map(name -> name.toLowerCase(java.util.Locale.ROOT))
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet()),
|
||||
intOf(configuration, "min-replicas-to-write"),
|
||||
intOf(configuration, "min-replicas-max-lag"));
|
||||
}
|
||||
|
||||
private static int intOf(Map<String, String> configuration, String key) {
|
||||
String value = configuration.get(key);
|
||||
if (value == null || value.isBlank()) {
|
||||
// Absent is not zero. A deployment whose admin account cannot read the setting has not
|
||||
// proven the guarantee, and treating "unknown" as "unset" would fail a correctly
|
||||
// configured server while treating it as "set" would pass an incorrectly configured one.
|
||||
// Failing is the safe direction: the message says exactly which grant is missing.
|
||||
throw new IllegalStateException(
|
||||
"the server did not report '"
|
||||
+ key
|
||||
+ "'. Write durability cannot be confirmed without it; grant the admin account"
|
||||
+ " +config|get, or set app.redis.acknowledged-write-loss-accepted=true to record"
|
||||
+ " that this deployment accepts losing acknowledged writes.");
|
||||
}
|
||||
return Integer.parseInt(value.strip());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1406
File diff suppressed because it is too large
Load Diff
+321
@@ -0,0 +1,321 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.ratelimit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisNamespace;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeClient;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.InMemoryGatewayAccess;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway;
|
||||
import dev.caskeleton.shared.ratelimit.RateLimitAlgorithm;
|
||||
import dev.caskeleton.shared.ratelimit.RateLimitDecision;
|
||||
import dev.caskeleton.shared.ratelimit.RateLimitEvaluationDedupPolicy;
|
||||
import dev.caskeleton.shared.ratelimit.RateLimitFailurePolicy;
|
||||
import dev.caskeleton.shared.ratelimit.RateLimitOutcome;
|
||||
import dev.caskeleton.shared.ratelimit.RateLimitPolicy;
|
||||
import dev.caskeleton.shared.ratelimit.RateLimitRequest;
|
||||
import dev.caskeleton.shared.ratelimit.RateParameters;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The provider-neutral rate limit, on Redis, without a Redis in sight from the caller's side.
|
||||
*
|
||||
* <p>Two things are being asserted. The algorithms bound what they claim to bound — that is the
|
||||
* feature. And every failure path is closed — that is the safety property, and the one a limiter
|
||||
* gets wrong in the direction that matters: allowing traffic when the store is unreachable removes
|
||||
* the bound at exactly the moment the bound is load-bearing.
|
||||
*/
|
||||
class RedisEdgeRateLimitAdapterTest {
|
||||
|
||||
private static final Instant T0 = Instant.parse("2026-08-10T12:00:00Z");
|
||||
|
||||
// The contract bounds both identifiers by pattern. The adapter must satisfy them, not relax them:
|
||||
// a subject digest short enough to collide, or an evaluation id without a generation prefix, are
|
||||
// exactly what those patterns exist to keep out of a shared keyspace.
|
||||
private static final String SUBJECT_A = "aaaaaaaaaaaaaaaa0000";
|
||||
|
||||
private static final String SUBJECT_B = "bbbbbbbbbbbbbbbb1111";
|
||||
|
||||
private static final String EVALUATION_ID = "ev1:AAAAAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
private final InMemoryGatewayAccess gateway = InMemoryGatewayAccess.create();
|
||||
|
||||
private final RateLimitKeys keys =
|
||||
new RateLimitKeys(new RedisNamespace("prod", "ca-skeleton", "shared"), 1);
|
||||
|
||||
private RedisEdgeRateLimitAdapter adapter(RateLimitPolicy policy, Clock clock) {
|
||||
return adapter(policy, clock, new StubClient(gateway.gateway()));
|
||||
}
|
||||
|
||||
private RedisEdgeRateLimitAdapter adapter(
|
||||
RateLimitPolicy policy, Clock clock, RedisRuntimeClient client) {
|
||||
Map<RedisConnectionKind, Integer> limits = new EnumMap<>(RedisConnectionKind.class);
|
||||
for (RedisConnectionKind kind : RedisConnectionKind.values()) {
|
||||
limits.put(kind, 4);
|
||||
}
|
||||
return new RedisEdgeRateLimitAdapter(
|
||||
new RedisRuntimeOwner(client, limits, Duration.ofSeconds(1)),
|
||||
keys,
|
||||
Map.of(policy.policyId(), policy),
|
||||
new RateLimitScripts(),
|
||||
clock,
|
||||
Duration.ofSeconds(2),
|
||||
Duration.ofMillis(100));
|
||||
}
|
||||
|
||||
private static RateLimitPolicy policy(RateParameters parameters, RateLimitAlgorithm algorithm) {
|
||||
return new RateLimitPolicy(
|
||||
"api-default",
|
||||
"v1",
|
||||
algorithm,
|
||||
parameters,
|
||||
// The contract caps a single request's cost at the algorithm's own budget: a cost that can
|
||||
// never be satisfied is a configuration error, not a permanently denied caller.
|
||||
maximumCostOf(parameters),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofMillis(250),
|
||||
RateLimitFailurePolicy.FAIL_CLOSED,
|
||||
new RateLimitEvaluationDedupPolicy(false, Duration.ZERO, 0, 0));
|
||||
}
|
||||
|
||||
private static long maximumCostOf(RateParameters parameters) {
|
||||
return switch (parameters) {
|
||||
case RateParameters.FixedWindow window -> window.limit();
|
||||
case RateParameters.SlidingCounter sliding -> sliding.limit();
|
||||
case RateParameters.TokenBucket bucket -> bucket.capacity();
|
||||
default -> throw new IllegalStateException("unsupported parameters");
|
||||
};
|
||||
}
|
||||
|
||||
private static RateLimitRequest request(long cost) {
|
||||
return new RateLimitRequest(
|
||||
"api-default", SUBJECT_A, cost, EVALUATION_ID, T0.plusSeconds(3600));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a fixed window allows up to its limit and then denies with a positive wait")
|
||||
void aFixedWindowBoundsItsWindow() {
|
||||
RateLimitPolicy policy =
|
||||
policy(
|
||||
new RateParameters.FixedWindow(3, Duration.ofSeconds(60)),
|
||||
RateLimitAlgorithm.FIXED_WINDOW);
|
||||
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
|
||||
|
||||
for (int allowed = 0; allowed < 3; allowed++) {
|
||||
assertThat(decision(adapter.evaluate(request(1))).allowed())
|
||||
.as("request %s of the budget", allowed + 1)
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
RateLimitDecision denied = decision(adapter.evaluate(request(1)));
|
||||
assertThat(denied.allowed()).isFalse();
|
||||
assertThat(denied.remaining()).isZero();
|
||||
assertThat(denied.retryAfter()).isPositive();
|
||||
assertThat(denied.source()).isEqualTo(RateLimitDecision.DecisionSource.GLOBAL_REDIS);
|
||||
assertThat(denied.certainty()).isEqualTo(RateLimitDecision.DecisionCertainty.CERTAIN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a new window restores the budget")
|
||||
void anewWindowRestoresTheBudget() {
|
||||
RateLimitPolicy policy =
|
||||
policy(
|
||||
new RateParameters.FixedWindow(2, Duration.ofSeconds(60)),
|
||||
RateLimitAlgorithm.FIXED_WINDOW);
|
||||
RedisEdgeRateLimitAdapter spent = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
|
||||
spent.evaluate(request(1));
|
||||
spent.evaluate(request(1));
|
||||
assertThat(decision(spent.evaluate(request(1))).allowed()).isFalse();
|
||||
|
||||
RedisEdgeRateLimitAdapter next =
|
||||
adapter(policy, Clock.fixed(T0.plusSeconds(60), ZoneOffset.UTC));
|
||||
|
||||
assertThat(decision(next.evaluate(request(1))).allowed()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a sliding counter reports itself as approximate")
|
||||
void aSlidingCounterIsHonestAboutBeingApproximate() {
|
||||
RateLimitPolicy policy =
|
||||
policy(
|
||||
new RateParameters.SlidingCounter(5, Duration.ofSeconds(60)),
|
||||
RateLimitAlgorithm.SLIDING_COUNTER);
|
||||
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
|
||||
|
||||
// Interpolating across two windows is a deliberate memory trade, and the caller is told, since
|
||||
// "approximate" and "certain" are different things to build an abuse decision on.
|
||||
assertThat(decision(adapter.evaluate(request(1))).certainty())
|
||||
.isEqualTo(RateLimitDecision.DecisionCertainty.APPROXIMATE_ALGORITHM);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a token bucket refills by elapsed periods, not by wall-clock jumps")
|
||||
void aTokenBucketRefillsByPeriod() {
|
||||
RateLimitPolicy policy =
|
||||
policy(
|
||||
new RateParameters.TokenBucket(2, 1, Duration.ofSeconds(10)),
|
||||
RateLimitAlgorithm.TOKEN_BUCKET);
|
||||
RedisEdgeRateLimitAdapter start = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
|
||||
assertThat(decision(start.evaluate(request(1))).allowed()).isTrue();
|
||||
assertThat(decision(start.evaluate(request(1))).allowed()).isTrue();
|
||||
assertThat(decision(start.evaluate(request(1))).allowed()).isFalse();
|
||||
|
||||
// Half a period buys nothing; a whole one buys exactly one token.
|
||||
RedisEdgeRateLimitAdapter halfway =
|
||||
adapter(policy, Clock.fixed(T0.plusSeconds(5), ZoneOffset.UTC));
|
||||
assertThat(decision(halfway.evaluate(request(1))).allowed()).isFalse();
|
||||
|
||||
RedisEdgeRateLimitAdapter refilled =
|
||||
adapter(policy, Clock.fixed(T0.plusSeconds(10), ZoneOffset.UTC));
|
||||
assertThat(decision(refilled.evaluate(request(1))).allowed()).isTrue();
|
||||
assertThat(decision(refilled.evaluate(request(1))).allowed()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unreachable Redis denies rather than allows")
|
||||
void anUnreachableRedisFailsClosed() {
|
||||
RateLimitPolicy policy =
|
||||
policy(
|
||||
new RateParameters.FixedWindow(5, Duration.ofSeconds(60)),
|
||||
RateLimitAlgorithm.FIXED_WINDOW);
|
||||
RedisEdgeRateLimitAdapter adapter =
|
||||
adapter(policy, Clock.fixed(T0, ZoneOffset.UTC), new BrokenClient());
|
||||
|
||||
RateLimitOutcome outcome = adapter.evaluate(request(1));
|
||||
|
||||
// Never Evaluated(allowed). A limiter that opens up during an outage is not a limiter, and the
|
||||
// outage is exactly when the bound matters.
|
||||
assertThat(outcome).isInstanceOf(RateLimitOutcome.Unavailable.class);
|
||||
assertThat(((RateLimitOutcome.Unavailable) outcome).retryAfter()).isPositive();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unknown policy is a deployment error, not an allowance")
|
||||
void anUnknownPolicyIsIncompatible() {
|
||||
RateLimitPolicy policy =
|
||||
policy(
|
||||
new RateParameters.FixedWindow(5, Duration.ofSeconds(60)),
|
||||
RateLimitAlgorithm.FIXED_WINDOW);
|
||||
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
|
||||
|
||||
RateLimitOutcome outcome =
|
||||
adapter.evaluate(
|
||||
new RateLimitRequest(
|
||||
"other-policy", SUBJECT_A, 1, EVALUATION_ID, T0.plusSeconds(3600)));
|
||||
|
||||
assertThat(outcome).isInstanceOf(RateLimitOutcome.Incompatible.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a cost above the policy ceiling is refused rather than clamped")
|
||||
void anOversizedCostIsRefused() {
|
||||
RateLimitPolicy policy =
|
||||
policy(
|
||||
new RateParameters.FixedWindow(5, Duration.ofSeconds(60)),
|
||||
RateLimitAlgorithm.FIXED_WINDOW);
|
||||
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
|
||||
|
||||
assertThat(adapter.evaluate(request(6))).isInstanceOf(RateLimitOutcome.Incompatible.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a caller whose deadline already passed is told now, not after a round trip")
|
||||
void anExpiredCallerDeadlineIsRejectedImmediately() {
|
||||
RateLimitPolicy policy =
|
||||
policy(
|
||||
new RateParameters.FixedWindow(5, Duration.ofSeconds(60)),
|
||||
RateLimitAlgorithm.FIXED_WINDOW);
|
||||
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
|
||||
|
||||
RateLimitOutcome outcome =
|
||||
adapter.evaluate(
|
||||
new RateLimitRequest("api-default", SUBJECT_A, 1, EVALUATION_ID, T0.minusMillis(1)));
|
||||
|
||||
assertThat(outcome).isInstanceOf(RateLimitOutcome.Unavailable.class);
|
||||
assertThat(((RateLimitOutcome.Unavailable) outcome).category())
|
||||
.isEqualTo(RateLimitOutcome.UnavailableCategory.ADMISSION_REJECTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("different subjects have separate budgets")
|
||||
void subjectsAreIsolated() {
|
||||
RateLimitPolicy policy =
|
||||
policy(
|
||||
new RateParameters.FixedWindow(1, Duration.ofSeconds(60)),
|
||||
RateLimitAlgorithm.FIXED_WINDOW);
|
||||
RedisEdgeRateLimitAdapter adapter = adapter(policy, Clock.fixed(T0, ZoneOffset.UTC));
|
||||
|
||||
assertThat(decision(adapter.evaluate(request(1))).allowed()).isTrue();
|
||||
assertThat(decision(adapter.evaluate(request(1))).allowed()).isFalse();
|
||||
assertThat(
|
||||
decision(
|
||||
adapter.evaluate(
|
||||
new RateLimitRequest(
|
||||
"api-default", SUBJECT_B, 1, EVALUATION_ID, T0.plusSeconds(3600))))
|
||||
.allowed())
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
private static RateLimitDecision decision(RateLimitOutcome outcome) {
|
||||
assertThat(outcome).isInstanceOf(RateLimitOutcome.Evaluated.class);
|
||||
return ((RateLimitOutcome.Evaluated) outcome).decision();
|
||||
}
|
||||
|
||||
/** Hands out the shared in-memory gateway; the owner's pooling makes it one logical server. */
|
||||
private record StubClient(RedisCommandGateway gateway) implements RedisRuntimeClient {
|
||||
|
||||
@Override
|
||||
public RedisDeploymentMode mode() {
|
||||
return RedisDeploymentMode.STANDALONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisLaneConnection openLane(
|
||||
RedisConnectionKind kind, java.util.Optional<byte[]> routingKey) {
|
||||
return new RedisLaneConnection() {
|
||||
@Override
|
||||
public RedisCommandGateway gateway() {
|
||||
return gateway;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean open() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
}
|
||||
|
||||
/** A server that cannot be reached at all. */
|
||||
private static final class BrokenClient implements RedisRuntimeClient {
|
||||
|
||||
@Override
|
||||
public RedisDeploymentMode mode() {
|
||||
return RedisDeploymentMode.STANDALONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RedisLaneConnection openLane(
|
||||
RedisConnectionKind kind, java.util.Optional<byte[]> routingKey) {
|
||||
throw new IllegalStateException("the server is unreachable");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.sdk;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode;
|
||||
import io.lettuce.core.RedisURI;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The endpoint a topology lane was pointed at.
|
||||
*
|
||||
* <p>Resolution fails rather than returning a default. A topology test that quietly falls back to
|
||||
* {@code localhost:6379} either tests the wrong thing or passes because nothing answered, and both
|
||||
* are indistinguishable from success in a CI log.
|
||||
*
|
||||
* <p>The endpoint is not always the data node. On the Sentinel lane the declared address is a
|
||||
* <em>sentinel</em>, and the primary has to be resolved from it — which is the whole point of the
|
||||
* lane, because the address the client dials changes when the primary is promoted. {@link
|
||||
* #dataUri()} is therefore the only supported way to reach data: a test that builds its own URI
|
||||
* from {@link #host()} and {@link #port()} would connect to a sentinel and then assert against the
|
||||
* wrong server.
|
||||
*
|
||||
* @param host the declared host
|
||||
* @param port the declared port
|
||||
* @param mode the deployment mode the lane represents
|
||||
* @param masterId the monitored primary's name, present on the Sentinel lane only
|
||||
* @param username the ACL account the data connection authenticates as
|
||||
* @param password that account's password
|
||||
* @param trustMaterial the CA the lane's server certificate is signed by, on the TLS lane only
|
||||
*/
|
||||
public record RedisTopologyEndpoint(
|
||||
String host,
|
||||
int port,
|
||||
RedisDeploymentMode mode,
|
||||
Optional<String> masterId,
|
||||
String username,
|
||||
String password,
|
||||
Optional<String> trustMaterial) {
|
||||
|
||||
/** Canonical constructor. */
|
||||
public RedisTopologyEndpoint {
|
||||
Objects.requireNonNull(host, "host must be non-null");
|
||||
Objects.requireNonNull(mode, "mode must be non-null");
|
||||
Objects.requireNonNull(masterId, "master identifier must be non-null");
|
||||
if (host.isBlank()) {
|
||||
throw new IllegalArgumentException("the topology host must not be blank");
|
||||
}
|
||||
if (port < 1 || port > 65_535) {
|
||||
throw new IllegalArgumentException("the topology port must be a valid port number");
|
||||
}
|
||||
Objects.requireNonNull(username, "username must be non-null");
|
||||
Objects.requireNonNull(password, "password must be non-null");
|
||||
Objects.requireNonNull(trustMaterial, "trust material must be non-null");
|
||||
if (username.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"the lane must authenticate as a named ACL account; the fixture disables the default"
|
||||
+ " user precisely so an unauthenticated connection cannot pass for a working one");
|
||||
}
|
||||
if (mode == RedisDeploymentMode.SENTINEL && masterId.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"the sentinel lane needs the monitored primary's name; without it the client cannot"
|
||||
+ " resolve a primary at all, let alone follow a promotion");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the endpoint from the system properties the lane sets.
|
||||
*
|
||||
* @return the endpoint
|
||||
* @throws IllegalStateException when the lane was selected without an endpoint
|
||||
*/
|
||||
public static RedisTopologyEndpoint fromSystemProperties() {
|
||||
RedisDeploymentMode mode =
|
||||
RedisDeploymentMode.valueOf(require("redis.topology.mode").toUpperCase(Locale.ROOT));
|
||||
Optional<String> masterId =
|
||||
mode == RedisDeploymentMode.SENTINEL
|
||||
? Optional.of(require("redis.topology.master"))
|
||||
: Optional.ofNullable(System.getProperty("redis.topology.master"))
|
||||
.filter(value -> !value.isBlank());
|
||||
return new RedisTopologyEndpoint(
|
||||
require("redis.topology.host"),
|
||||
Integer.parseInt(require("redis.topology.port")),
|
||||
mode,
|
||||
masterId,
|
||||
// The lane's ACL fixture disables `default`, so every data connection authenticates as a
|
||||
// named account. Defaulting to the application account keeps the common case one flag
|
||||
// shorter while still going through AUTH — which is what the qualification has to prove.
|
||||
System.getProperty("redis.topology.username", "ca-skeleton-application"),
|
||||
// The fixture accounts carry real passwords. They were `nopass`, which accepts any
|
||||
// password at all — so every assertion about authentication passed for the same reason a
|
||||
// wrong password would have, and rotation and wrong-password coverage was false-green.
|
||||
System.getProperty("redis.topology.password", "fixture-application"),
|
||||
// Present exactly on the TLS lane. The build passes it, so a lane that forgot it fails in
|
||||
// the task rather than by silently connecting without verification.
|
||||
Optional.ofNullable(System.getProperty("redis.topology.trust-material"))
|
||||
.filter(value -> !value.isBlank()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether this lane speaks TLS.
|
||||
*
|
||||
* @return {@code true} when the lane's server has no plaintext port at all
|
||||
*/
|
||||
public boolean tls() {
|
||||
return Boolean.parseBoolean(System.getProperty("redis.topology.tls", "false"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CA the lane's server certificate is signed by.
|
||||
*
|
||||
* @return the configured trust material location
|
||||
* @throws IllegalStateException when the lane is not a TLS lane
|
||||
*/
|
||||
public String requireTrustMaterial() {
|
||||
return trustMaterial.orElseThrow(
|
||||
() -> new IllegalStateException("only the TLS lane declares trust material"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URI a data connection must be opened with.
|
||||
*
|
||||
* @return a sentinel-resolving URI on the Sentinel lane, the declared address otherwise
|
||||
*/
|
||||
public RedisURI dataUri() {
|
||||
RedisURI uri =
|
||||
switch (mode) {
|
||||
case SENTINEL -> RedisURI.Builder.sentinel(host, port, masterId.orElseThrow()).build();
|
||||
case STANDALONE, CLUSTER -> RedisURI.create(host, port);
|
||||
};
|
||||
uri.setCredentialsProvider(
|
||||
io.lettuce.core.RedisCredentialsProvider.from(
|
||||
() -> io.lettuce.core.RedisCredentials.just(username, password.toCharArray())));
|
||||
return uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URI a diagnostic connection must be opened with.
|
||||
*
|
||||
* <p>Separate from {@link #dataUri()} because the accounts are separate, and deliberately so. The
|
||||
* application account cannot run {@code INFO} — the ACL fixture denies it, exactly as the SDK's
|
||||
* own admin plane models it — so a lane that probes the server version over the data connection
|
||||
* gets {@code NOPERM}. That is the fixture working, not a fixture bug: reaching diagnostics
|
||||
* requires holding the admin account.
|
||||
*
|
||||
* @return the declared address, authenticated as the read-only admin account
|
||||
*/
|
||||
public RedisURI adminUri() {
|
||||
RedisURI uri =
|
||||
switch (mode) {
|
||||
case SENTINEL -> RedisURI.Builder.sentinel(host, port, masterId.orElseThrow()).build();
|
||||
case STANDALONE, CLUSTER -> RedisURI.create(host, port);
|
||||
};
|
||||
uri.setCredentialsProvider(
|
||||
io.lettuce.core.RedisCredentialsProvider.from(
|
||||
() ->
|
||||
io.lettuce.core.RedisCredentials.just(
|
||||
System.getProperty(
|
||||
"redis.topology.admin-username", "ca-skeleton-admin-readonly"),
|
||||
System.getProperty("redis.topology.admin-password", "fixture-admin")
|
||||
.toCharArray())));
|
||||
return uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URI a Sentinel control connection must be opened with.
|
||||
*
|
||||
* <p>Deliberately credential-free, and that is not an oversight. A sentinel is a different
|
||||
* process with its own ACL: it does not load the data nodes' {@code aclfile}, so the accounts in
|
||||
* {@code infra/redis-sdk/acl} do not exist there and presenting one gets {@code WRONGPASS}. The
|
||||
* {@code sentinel auth-user} / {@code auth-pass} directives in the lane are about how the
|
||||
* sentinel authenticates <em>to the monitored primary</em>, which is a different direction
|
||||
* entirely. Securing the sentinels themselves would mean giving them their own ACL file, and the
|
||||
* lane deliberately does not, because a sentinel port is not a data path.
|
||||
*
|
||||
* @return the declared sentinel address
|
||||
*/
|
||||
public RedisURI sentinelControlUri() {
|
||||
return RedisURI.create(host, port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URI for a specific data node the lane discovered, authenticated as the data
|
||||
* account.
|
||||
*
|
||||
* <p>A promotion test has to dial the node Sentinel just named, not the declared address, so the
|
||||
* host and port come from the caller while the credentials stay the lane's.
|
||||
*
|
||||
* @param nodeHost the discovered host
|
||||
* @param nodePort the discovered port
|
||||
* @return the authenticated URI
|
||||
*/
|
||||
public RedisURI dataNodeUri(String nodeHost, int nodePort) {
|
||||
RedisURI uri = RedisURI.create(nodeHost, nodePort);
|
||||
uri.setCredentialsProvider(
|
||||
io.lettuce.core.RedisCredentialsProvider.from(
|
||||
() -> io.lettuce.core.RedisCredentials.just(username, password.toCharArray())));
|
||||
return uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URI for cluster provisioning writes against a specific node.
|
||||
*
|
||||
* <p>{@code CLUSTER SETSLOT} and friends are administrative writes. They are absent from the
|
||||
* read-only admin account on purpose — an account named {@code admin-readonly} that can reshard a
|
||||
* cluster is misnamed — so a test that drives a migration authenticates as the provisioning
|
||||
* identity the lane also uses to build the cluster.
|
||||
*
|
||||
* @param nodeHost the node's host
|
||||
* @param nodePort the node's port
|
||||
* @return the authenticated URI
|
||||
*/
|
||||
public RedisURI provisioningUri(String nodeHost, int nodePort) {
|
||||
RedisURI uri = RedisURI.create(nodeHost, nodePort);
|
||||
uri.setCredentialsProvider(
|
||||
io.lettuce.core.RedisCredentialsProvider.from(
|
||||
() ->
|
||||
io.lettuce.core.RedisCredentials.just(
|
||||
System.getProperty(
|
||||
"redis.topology.provisioning-username", "ca-skeleton-cluster-bootstrap"),
|
||||
System.getProperty("redis.topology.provisioning-password", "fixture-bootstrap")
|
||||
.toCharArray())));
|
||||
return uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the monitored primary's name.
|
||||
*
|
||||
* @return the name
|
||||
* @throws IllegalStateException when the lane is not a Sentinel lane
|
||||
*/
|
||||
public String requireMasterId() {
|
||||
return masterId.orElseThrow(
|
||||
() -> new IllegalStateException("only the sentinel lane declares a monitored primary"));
|
||||
}
|
||||
|
||||
private static String require(String key) {
|
||||
String value = System.getProperty(key);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"the redis-topology lane requires -D" + key + "; it must never be skipped silently");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.sdk.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.RedisTopologyEndpoint;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisLease;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeClient;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.health.contributor.HealthIndicator;
|
||||
import org.springframework.boot.health.contributor.Status;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
|
||||
/**
|
||||
* The composition root, against a server that is actually there.
|
||||
*
|
||||
* <p>Every other test of this configuration uses endpoints nothing answers on, which proves the
|
||||
* bean graph and nothing about whether the graph works. These are the three claims that can only be
|
||||
* settled by connecting: the mode produced the client the topology needs, a lease borrowed from the
|
||||
* owner reaches Redis, and shutting the context down leaves nothing behind.
|
||||
*/
|
||||
@Tag("redis-topology")
|
||||
@Tag("lane-standalone")
|
||||
@Tag("lane-sentinel")
|
||||
@Tag("lane-cluster")
|
||||
class LiveRedisCompositionTest {
|
||||
|
||||
private final RedisTopologyEndpoint endpoint = RedisTopologyEndpoint.fromSystemProperties();
|
||||
|
||||
private ApplicationContextRunner runner() {
|
||||
return runner("fixture-application");
|
||||
}
|
||||
|
||||
private ApplicationContextRunner runner(String password) {
|
||||
ApplicationContextRunner runner =
|
||||
new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(RedisSdkAutoConfiguration.class))
|
||||
.withBean(
|
||||
RedisSdkAutoConfiguration.RedisSecretSource.class,
|
||||
// The fixture account's real password. It used to be `nopass`, so this could have
|
||||
// been any string at all and the lane would still have connected — which is why
|
||||
// there was nothing to distinguish a working credential from a wrong one.
|
||||
() -> name -> Optional.of(password))
|
||||
.withPropertyValues(
|
||||
"app.redis.enabled=true",
|
||||
"app.redis.mode=" + endpoint.mode().name().toLowerCase(java.util.Locale.ROOT),
|
||||
"app.redis.nodes=" + endpoint.host() + ":" + endpoint.port(),
|
||||
// The lane's ACL disables `default`, so the composition authenticates as a named
|
||||
// account exactly as a deployment would. A reference, not a value.
|
||||
"app.redis.authentication.credential-reference=secret://ca-skeleton-application@environment/APP_REDIS_PASSWORD",
|
||||
"app.redis.namespace.environment=prod",
|
||||
"app.redis.namespace.service=order",
|
||||
"app.redis.namespace.domain=shared");
|
||||
return endpoint.mode()
|
||||
== dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode.SENTINEL
|
||||
? runner.withPropertyValues("app.redis.sentinel.master-name=" + endpoint.requireMasterId())
|
||||
: runner;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a wrong password is refused, so a right one proves something")
|
||||
void aWrongPasswordIsRefused() throws Exception {
|
||||
// The assertion the `nopass` fixture could never carry. With an account that accepts anything,
|
||||
// every credential test passed for the same reason a typo would have, and the lane's coverage
|
||||
// of authentication, rotation and secret wiring was indistinguishable from no coverage.
|
||||
runner("not-the-fixture-password")
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
RedisRuntimeOwner owner = context.getBean(RedisRuntimeOwner.class);
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(
|
||||
() -> {
|
||||
try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) {
|
||||
lease.gateway().ping().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
})
|
||||
.as("authentication is actually enforced by the fixture")
|
||||
.rootCause()
|
||||
.hasMessageContaining("WRONGPASS");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the configured mode produces exactly one client of the matching topology")
|
||||
void theModeProducesOneMatchingClient() {
|
||||
runner()
|
||||
.run(
|
||||
context -> {
|
||||
assertThat(context).hasNotFailed();
|
||||
assertThat(context).hasSingleBean(RedisRuntimeClient.class);
|
||||
assertThat(context.getBean(RedisRuntimeClient.class).mode())
|
||||
.isEqualTo(endpoint.mode());
|
||||
assertThat(context).hasSingleBean(RedisRuntimeOwner.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a lease borrowed from the composed owner reaches the server")
|
||||
void aLeaseReachesTheServer() throws Exception {
|
||||
runner()
|
||||
.run(
|
||||
context -> {
|
||||
RedisRuntimeOwner owner = context.getBean(RedisRuntimeOwner.class);
|
||||
assertThat(owner.state()).isEqualTo(RedisRuntimeOwner.State.OPEN);
|
||||
|
||||
try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) {
|
||||
String reply =
|
||||
lease.gateway().ping().toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
assertThat(reply).isEqualTo("PONG");
|
||||
}
|
||||
assertThat(owner.outstanding(RedisConnectionKind.REGULAR))
|
||||
.as("the lease was returned, not leaked")
|
||||
.isZero();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the optional health contributor reports UP against a live server")
|
||||
void theOptionalContributorReportsUp() {
|
||||
runner()
|
||||
.run(
|
||||
context -> {
|
||||
HealthIndicator optional = (HealthIndicator) context.getBean("redisOptional");
|
||||
assertThat(optional.health().getStatus()).isEqualTo(Status.UP);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("closing the context drains, closes connections, then shuts the client down")
|
||||
void closingTheContextTearsEverythingDown() {
|
||||
RedisRuntimeOwner[] captured = new RedisRuntimeOwner[1];
|
||||
int threadsBefore = redisThreadCount();
|
||||
|
||||
runner()
|
||||
.run(
|
||||
context -> {
|
||||
captured[0] = context.getBean(RedisRuntimeOwner.class);
|
||||
captured[0].borrow(RedisConnectionKind.REGULAR).close();
|
||||
});
|
||||
|
||||
assertThat(captured[0].state()).isEqualTo(RedisRuntimeOwner.State.CLOSED);
|
||||
// The event loop is what a leaked client leaves behind, and it is invisible to a bean-graph
|
||||
// assertion. Lettuce's threads are named, so counting them is a direct check.
|
||||
await(() -> redisThreadCount() <= threadsBefore, Duration.ofSeconds(10));
|
||||
assertThat(redisThreadCount())
|
||||
.as("no Lettuce event-loop threads outlive the context")
|
||||
.isLessThanOrEqualTo(threadsBefore);
|
||||
}
|
||||
|
||||
private static int redisThreadCount() {
|
||||
return (int)
|
||||
Thread.getAllStackTraces().keySet().stream()
|
||||
.map(Thread::getName)
|
||||
.filter(name -> name.startsWith("lettuce-"))
|
||||
.count();
|
||||
}
|
||||
|
||||
private static void await(java.util.function.BooleanSupplier condition, Duration budget) {
|
||||
long deadline = System.nanoTime() + budget.toNanos();
|
||||
while (System.nanoTime() < deadline && !condition.getAsBoolean()) {
|
||||
try {
|
||||
Thread.sleep(50);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
package dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.RedisTopologyEndpoint;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisVersion;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.CommandId;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.command.RedisCommandDescriptor;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error.RedisFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.error.RedisOperationException;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.ListKey;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.codec.Utf8StringCodec;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.ExecutionCertainty;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.command.RedisCommandCatalog;
|
||||
import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.SentinelFailoverObserver;
|
||||
import io.lettuce.core.RedisClient;
|
||||
import io.lettuce.core.api.StatefulRedisConnection;
|
||||
import io.lettuce.core.sentinel.api.StatefulRedisSentinelConnection;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.SocketAddress;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* What a Sentinel promotion does to work that is in flight.
|
||||
*
|
||||
* <p>The unit suite can prove that {@link SentinelFailoverObserver} counts what it is told and that
|
||||
* {@link ExecutionCertainty} refuses to retry a non-idempotent write. It cannot prove the thing
|
||||
* those two types exist for: that the certainty the SDK reports to a caller is <em>true</em> of the
|
||||
* server afterwards. Only a real promotion settles that.
|
||||
*
|
||||
* <p>The experiment runs once in {@link #promote()} and every test below asserts a different
|
||||
* falsifiable claim about that one recorded run. Each write carries a token that is unique across
|
||||
* the run, so the list on the promoted primary is a verbatim record of what actually happened and
|
||||
* the SDK's per-call verdict can be checked against it one token at a time.
|
||||
*
|
||||
* <p>The two claims with teeth are that a token whose call the SDK reported as <em>definitely not
|
||||
* applied</em> must be absent from the promoted primary, and that no token may appear twice —
|
||||
* {@code RPUSH} is not retry-safe, and the guardrails forbid anything in the stack from resending
|
||||
* it on its own. Both fail loudly if the certainty model is wishful thinking rather than a
|
||||
* description of the driver underneath it.
|
||||
*/
|
||||
@Tag("redis-topology")
|
||||
@Tag("lane-sentinel")
|
||||
class LiveRedisSentinelPromotionTest {
|
||||
|
||||
/** The write that must survive the promotion; it is confirmed and replicated beforehand. */
|
||||
private static final String REPLICATED = "replicated-before-promotion";
|
||||
|
||||
/** How long the write loop may run before the lane is declared broken. */
|
||||
private static final Duration EXPERIMENT_CEILING = Duration.ofSeconds(90);
|
||||
|
||||
/** Confirmed writes required after the promotion before the run is considered settled. */
|
||||
private static final int SETTLED_WRITES = 50;
|
||||
|
||||
/** Attempts to confirm before the failover is requested. */
|
||||
private static final int WARMUP_WRITES = 20;
|
||||
|
||||
/** Pause between attempts; small enough to land inside a promotion, large enough not to spin. */
|
||||
private static final Duration ATTEMPT_INTERVAL = Duration.ofMillis(5);
|
||||
|
||||
/**
|
||||
* The {@code min-replicas-max-lag} the lane configures.
|
||||
*
|
||||
* <p>This is the width of the window in which a superseded primary can still acknowledge a write
|
||||
* that is about to be discarded, so it is also the bound on how many acknowledged writes a
|
||||
* promotion may destroy. Doubling it leaves room for scheduling jitter without leaving room for
|
||||
* the unbounded behaviour the setting exists to prevent.
|
||||
*/
|
||||
private static final Duration REPLICA_LAG_CEILING = Duration.ofSeconds(1);
|
||||
|
||||
private static RedisTopologyEndpoint endpoint;
|
||||
|
||||
private static RedisClient controlClient;
|
||||
|
||||
private static StatefulRedisSentinelConnection<String, String> sentinel;
|
||||
|
||||
private static LiveRedisOperationsFixture fixture;
|
||||
|
||||
private static ListKey<String> log;
|
||||
|
||||
private static String renderedLogKey;
|
||||
|
||||
private static InetSocketAddress primaryBefore;
|
||||
|
||||
private static InetSocketAddress primaryAfter;
|
||||
|
||||
private static final List<Attempt> ATTEMPTS = new ArrayList<>();
|
||||
|
||||
private static List<String> tokensOnPromotedPrimary = List.of();
|
||||
|
||||
private static SentinelFailoverObserver observer;
|
||||
|
||||
private static Duration observedReconnect = Duration.ZERO;
|
||||
|
||||
/** One write attempt and the verdict the SDK returned for it. */
|
||||
private record Attempt(String token, Optional<RedisOperationException> failure) {
|
||||
|
||||
boolean confirmed() {
|
||||
return failure.isEmpty();
|
||||
}
|
||||
|
||||
/** Reports whether the SDK told the caller the write definitely did not take effect. */
|
||||
boolean reportedAsNotApplied() {
|
||||
return failure.map(value -> !value.metadata().ambiguousExecution()).orElse(false);
|
||||
}
|
||||
|
||||
/** Reports whether the SDK left the outcome open. */
|
||||
boolean reportedAsAmbiguous() {
|
||||
return failure.map(value -> value.metadata().ambiguousExecution()).orElse(false);
|
||||
}
|
||||
|
||||
Optional<RedisFailureMetadata> metadata() {
|
||||
return failure.map(RedisOperationException::metadata);
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeAll
|
||||
static void promote() throws InterruptedException {
|
||||
endpoint = RedisTopologyEndpoint.fromSystemProperties();
|
||||
controlClient = RedisClient.create();
|
||||
// The sentinels themselves run with `default` off, so the control connection authenticates as
|
||||
// the sentinel account. Before that account existed this connected as `default` — which is why
|
||||
// hardening the fixture broke this lane rather than the lane proving the hardening.
|
||||
sentinel = controlClient.connectSentinel(endpoint.sentinelControlUri());
|
||||
primaryBefore = resolvePrimary();
|
||||
|
||||
fixture =
|
||||
new LiveRedisOperationsFixture(
|
||||
endpoint, RedisVersion.parse("7.4.0"), Duration.ofMillis(500));
|
||||
log = fixture.keys.list("failover", "promotion-log", Utf8StringCodec.instance());
|
||||
renderedLogKey = fixture.renderer.render(log.key());
|
||||
observer = new SentinelFailoverObserver(1_000);
|
||||
|
||||
// The lane requires an in-sync replica before it will accept a write at all, so the run cannot
|
||||
// start until one is attached. Writing first and discovering NOREPLICAS would report a lane
|
||||
// that was merely still starting up as a promotion that destroyed data.
|
||||
awaitInSyncReplica(primaryBefore);
|
||||
clearLog(primaryBefore);
|
||||
// If this one is missing afterwards the lane promoted a replica that never carried the data,
|
||||
// and every other assertion in this class would be measuring the wrong thing.
|
||||
fixture.lists.pushRight(log, List.of(REPLICATED));
|
||||
requireReplication(primaryBefore);
|
||||
|
||||
runWriteLoopAcrossAPromotion();
|
||||
|
||||
primaryAfter = resolvePrimary();
|
||||
observer.recordPromotion(observedReconnect);
|
||||
RedisCommandDescriptor push =
|
||||
RedisCommandCatalog.loadDefault().require(CommandId.parse("RPUSH")).descriptor();
|
||||
for (Attempt attempt : ATTEMPTS) {
|
||||
if (!attempt.confirmed()) {
|
||||
observer.classify(push, attempt.reportedAsAmbiguous());
|
||||
}
|
||||
}
|
||||
tokensOnPromotedPrimary = readLog(primaryAfter);
|
||||
report();
|
||||
}
|
||||
|
||||
private static void runWriteLoopAcrossAPromotion() throws InterruptedException {
|
||||
Instant deadline = Instant.now().plus(EXPERIMENT_CEILING);
|
||||
boolean failoverRequested = false;
|
||||
Instant firstFailure = null;
|
||||
int confirmedAfterFirstFailure = 0;
|
||||
int index = 0;
|
||||
while (Instant.now().isBefore(deadline)) {
|
||||
Attempt attempt = attempt("token-" + index++);
|
||||
ATTEMPTS.add(attempt);
|
||||
|
||||
if (!failoverRequested && attempt.confirmed() && index >= WARMUP_WRITES) {
|
||||
sentinel.sync().failover(endpoint.requireMasterId());
|
||||
failoverRequested = true;
|
||||
}
|
||||
if (failoverRequested && !attempt.confirmed() && firstFailure == null) {
|
||||
firstFailure = Instant.now();
|
||||
}
|
||||
if (firstFailure != null && attempt.confirmed()) {
|
||||
if (observedReconnect.isZero()) {
|
||||
observedReconnect = Duration.between(firstFailure, Instant.now());
|
||||
}
|
||||
if (++confirmedAfterFirstFailure >= SETTLED_WRITES) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Thread.sleep(ATTEMPT_INTERVAL.toMillis());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints what the promotion actually did.
|
||||
*
|
||||
* <p>This lane exists to find out how the driver behaves, so the distribution of failure types is
|
||||
* evidence in its own right and is recorded in the run log rather than only in an assertion
|
||||
* message.
|
||||
*/
|
||||
private static void report() {
|
||||
Map<String, Long> byType = new LinkedHashMap<>();
|
||||
for (Attempt attempt : ATTEMPTS) {
|
||||
attempt.failure.ifPresent(
|
||||
failure ->
|
||||
byType.merge(
|
||||
failure.getClass().getSimpleName()
|
||||
+ (failure.metadata().ambiguousExecution() ? " [ambiguous]" : " [not-run]")
|
||||
+ " "
|
||||
+ failure.getMessage()
|
||||
+ " <- "
|
||||
+ rootCause(failure),
|
||||
1L,
|
||||
Long::sum));
|
||||
}
|
||||
Set<String> present = new LinkedHashSet<>(tokensOnPromotedPrimary);
|
||||
long acknowledgedButLost =
|
||||
ATTEMPTS.stream()
|
||||
.filter(Attempt::confirmed)
|
||||
.map(Attempt::token)
|
||||
.filter(token -> !present.contains(token))
|
||||
.count();
|
||||
System.out.println("[sentinel] primary " + primaryBefore + " -> " + primaryAfter);
|
||||
System.out.println(
|
||||
"[sentinel] attempts="
|
||||
+ ATTEMPTS.size()
|
||||
+ " confirmed="
|
||||
+ ATTEMPTS.stream().filter(Attempt::confirmed).count()
|
||||
+ " reconnect="
|
||||
+ observedReconnect
|
||||
+ " stored="
|
||||
+ tokensOnPromotedPrimary.size()
|
||||
+ " acknowledged-but-lost="
|
||||
+ acknowledgedButLost);
|
||||
byType.forEach((type, count) -> System.out.println("[sentinel] " + count + "x " + type));
|
||||
}
|
||||
|
||||
private static String rootCause(Throwable failure) {
|
||||
Throwable current = failure;
|
||||
while (current.getCause() != null) {
|
||||
current = current.getCause();
|
||||
}
|
||||
return current.getClass().getName() + ": " + current.getMessage();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void disconnect() {
|
||||
if (fixture != null) {
|
||||
fixture.close();
|
||||
}
|
||||
if (sentinel != null) {
|
||||
sentinel.close();
|
||||
}
|
||||
if (controlClient != null) {
|
||||
controlClient.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the lane actually promoted a different node")
|
||||
void promotionHappened() {
|
||||
assertThat(primaryAfter)
|
||||
.as("Sentinel resolves the same address before and after; nothing was promoted")
|
||||
.isNotEqualTo(primaryBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the client followed the promotion and writes land on the new primary")
|
||||
void clientFollowedThePromotion() {
|
||||
// Deliberately not asserting that the caller saw a failure. The first runs of this lane showed
|
||||
// a promotion that cost the caller nothing visible at all — sixteen thousand attempts, zero
|
||||
// exceptions — while two thousand acknowledged writes were being discarded. Requiring a
|
||||
// visible interruption would have turned that into a red test for the wrong reason and hidden
|
||||
// the finding behind it.
|
||||
assertThat(ATTEMPTS.stream().filter(Attempt::confirmed).toList())
|
||||
.hasSizeGreaterThan(SETTLED_WRITES);
|
||||
assertThat(tokensOnPromotedPrimary)
|
||||
.as("the replicated pre-promotion write did not survive the promotion")
|
||||
.contains(REPLICATED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a promotion destroys no more acknowledged writes than the replica lag allows")
|
||||
void acknowledgedWriteLossIsBounded() {
|
||||
Set<String> present = new LinkedHashSet<>(tokensOnPromotedPrimary);
|
||||
List<String> lost =
|
||||
ATTEMPTS.stream()
|
||||
.filter(Attempt::confirmed)
|
||||
.map(Attempt::token)
|
||||
.filter(token -> !present.contains(token))
|
||||
.toList();
|
||||
long allowed = 2 * REPLICA_LAG_CEILING.dividedBy(ATTEMPT_INTERVAL);
|
||||
|
||||
// This is the assertion the lane was built for. A superseded primary that still has an in-sync
|
||||
// replica requirement stops acknowledging writes about one lag-window after it is orphaned;
|
||||
// one that does not keeps saying +OK until Sentinel demotes it, which took eleven seconds and
|
||||
// cost two thousand acknowledged writes when this was first measured. If the requirement is
|
||||
// ever dropped from the lane, this count jumps by an order of magnitude and says so.
|
||||
assertThat((long) lost.size())
|
||||
.as(
|
||||
"%d acknowledged writes were discarded by the promotion; the configured replica lag"
|
||||
+ " allows at most %d, so the superseded primary was acknowledging writes it could"
|
||||
+ " not keep",
|
||||
lost.size(), allowed)
|
||||
.isLessThanOrEqualTo(allowed);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a write the SDK reported as not applied is absent from the promoted primary")
|
||||
void reportedFailuresDidNotApply() {
|
||||
Set<String> present = new LinkedHashSet<>(tokensOnPromotedPrimary);
|
||||
|
||||
assertThat(
|
||||
ATTEMPTS.stream()
|
||||
.filter(Attempt::reportedAsNotApplied)
|
||||
.map(Attempt::token)
|
||||
.filter(present::contains)
|
||||
.toList())
|
||||
.as("the SDK told the caller these writes definitely did not run, and they did")
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("every failure carries a coherent verdict")
|
||||
void everyFailureIsClassified() {
|
||||
List<RedisFailureMetadata> failures =
|
||||
ATTEMPTS.stream().flatMap(attempt -> attempt.metadata().stream()).toList();
|
||||
|
||||
assertThat(failures)
|
||||
.allSatisfy(
|
||||
metadata -> {
|
||||
assertThat(metadata.readOperation()).isFalse();
|
||||
assertThat(metadata.retryable() && metadata.ambiguousExecution())
|
||||
.as("an ambiguous write must never be advertised as retryable")
|
||||
.isFalse();
|
||||
assertThat(metadata.retryable())
|
||||
.as("RPUSH is not retry-safe, so no verdict may mark it retryable")
|
||||
.isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no write is applied twice across the promotion")
|
||||
void nonIdempotentWritesAreNeverReplayed() {
|
||||
assertThat(tokensOnPromotedPrimary)
|
||||
.as("a token appears more than once; a non-retry-safe write was resent by the stack")
|
||||
.doesNotHaveDuplicates();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("nothing reached the server that the test never issued")
|
||||
void serverStateIsExplainedByTheRun() {
|
||||
Set<String> issued = new LinkedHashSet<>(ATTEMPTS.stream().map(Attempt::token).toList());
|
||||
issued.add(REPLICATED);
|
||||
|
||||
assertThat(issued).containsAll(tokensOnPromotedPrimary);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the observer's account of the promotion matches the run")
|
||||
void observerMatchesTheRun() {
|
||||
long ambiguous = ATTEMPTS.stream().filter(Attempt::reportedAsAmbiguous).count();
|
||||
|
||||
assertThat(observer.promotionCount()).isEqualTo(1);
|
||||
assertThat(observer.ambiguousWriteCount())
|
||||
.as("every ambiguous non-retry-safe write around a promotion is one to reconcile")
|
||||
.isEqualTo(ambiguous);
|
||||
assertThat(observer.longestReconnect()).isEqualTo(observedReconnect);
|
||||
assertThat(observer.refusedWhileReconnectingCount()).isZero();
|
||||
}
|
||||
|
||||
private static Attempt attempt(String token) {
|
||||
try {
|
||||
fixture.lists.pushRight(log, List.of(token));
|
||||
return new Attempt(token, Optional.empty());
|
||||
} catch (RedisOperationException failure) {
|
||||
return new Attempt(token, Optional.of(failure));
|
||||
}
|
||||
}
|
||||
|
||||
private static InetSocketAddress resolvePrimary() {
|
||||
SocketAddress address = sentinel.sync().getMasterAddrByName(endpoint.requireMasterId());
|
||||
if (!(address instanceof InetSocketAddress resolved)) {
|
||||
throw new IllegalStateException("Sentinel did not report an inet address for the primary");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static void clearLog(InetSocketAddress primary) {
|
||||
withPrimary(primary, connection -> connection.sync().del(renderedLogKey));
|
||||
}
|
||||
|
||||
private static void awaitInSyncReplica(InetSocketAddress primary) throws InterruptedException {
|
||||
for (int attempt = 0; attempt < 30; attempt++) {
|
||||
Long replicas =
|
||||
withPrimary(primary, connection -> connection.sync().waitForReplication(1, 1_000));
|
||||
if (replicas != null && replicas >= 1) {
|
||||
return;
|
||||
}
|
||||
Thread.sleep(500);
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"no replica came into sync; the lane cannot promote one and cannot accept a write");
|
||||
}
|
||||
|
||||
private static void requireReplication(InetSocketAddress primary) {
|
||||
Long replicas =
|
||||
withPrimary(primary, connection -> connection.sync().waitForReplication(1, 2_000));
|
||||
if (replicas == null || replicas < 1) {
|
||||
throw new IllegalStateException(
|
||||
"the primary has no replica in sync; the lane cannot promote one");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> readLog(InetSocketAddress primary) {
|
||||
return withPrimary(primary, connection -> connection.sync().lrange(renderedLogKey, 0, -1));
|
||||
}
|
||||
|
||||
private static <T> T withPrimary(
|
||||
InetSocketAddress primary, Function<StatefulRedisConnection<String, String>, T> work) {
|
||||
RedisClient direct =
|
||||
RedisClient.create(endpoint.dataNodeUri(primary.getHostString(), primary.getPort()));
|
||||
try (StatefulRedisConnection<String, String> connection = direct.connect()) {
|
||||
return work.apply(connection);
|
||||
} finally {
|
||||
direct.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# adapter:outbound:httpclient — resilient HTTP client adapter
|
||||
# adapter:outbound:httpclient — HTTP Client Platform
|
||||
|
||||
## Registered identity
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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=
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.performance;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientApiType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.PoolSettings;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.reactor.ReactorConnectionProviderFactory;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.reactor.ReactorHttpClientFactory;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.testkit.TlsFixture;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.testkit.TlsMaterials;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.IntStream;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.netty.resources.ConnectionProvider;
|
||||
|
||||
/**
|
||||
* HTTP/2 multiplexes streams onto a connection, so stream concurrency is bounded separately from
|
||||
* connection count (design §14.1, §24.1).
|
||||
*
|
||||
* <p>This test used to run against {@code MockHttpServer.start()} — cleartext HTTP/1.1 — while
|
||||
* asserting only that some requests completed. It was named for HTTP/2, filed under HTTP/2, and
|
||||
* proved nothing about it: the same assertions passed over HTTP/1.1 with one connection per
|
||||
* request, which is the exact behaviour multiplexing is supposed to replace. The server is now a
|
||||
* real TLS+ALPN HTTP/2 endpoint and every response has to report {@code HTTP/2.0}, so the claim in
|
||||
* the class name is the claim the test makes.
|
||||
*/
|
||||
class Http2StreamSaturationTest {
|
||||
|
||||
@Test
|
||||
void manyConcurrentStreamsShareABoundedConnectionPoolOverRealHttp2() throws Exception {
|
||||
int streams = 32;
|
||||
int maxConnections = 2;
|
||||
TlsFixture fixture = TlsFixture.trusted();
|
||||
|
||||
try (MockHttpServer server = MockHttpServer.startTlsWithHttp2(fixture.serverSocketFactory())) {
|
||||
for (int index = 0; index < streams; index++) {
|
||||
server.enqueueJson(200, "{\"id\":1,\"name\":\"a\"}");
|
||||
}
|
||||
ClientProfile profile =
|
||||
ClientProfiles.builder("multiplexed")
|
||||
.baseUrl(server.uri("/"))
|
||||
.transport(TransportType.REACTOR_NETTY)
|
||||
.api(ClientApiType.WEB_CLIENT)
|
||||
.protocols(Set.of(HttpProtocol.HTTP_2, HttpProtocol.HTTP_1_1))
|
||||
.pool(
|
||||
new PoolSettings(
|
||||
maxConnections,
|
||||
maxConnections,
|
||||
streams,
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(30),
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(15),
|
||||
Duration.ofSeconds(5),
|
||||
false,
|
||||
false))
|
||||
.build();
|
||||
|
||||
ConnectionProvider pool = new ReactorConnectionProviderFactory().create(profile);
|
||||
try {
|
||||
reactor.netty.http.client.HttpClient client =
|
||||
new ReactorHttpClientFactory()
|
||||
.create(
|
||||
profile, pool, Optional.of(TlsMaterials.trustOnly(fixture)), Optional.empty());
|
||||
|
||||
List<String> versions =
|
||||
Flux.fromStream(IntStream.range(0, streams).boxed())
|
||||
.flatMap(
|
||||
index ->
|
||||
client
|
||||
.get()
|
||||
.uri(server.uri("/users/1").toString())
|
||||
.response((response, bytes) -> Mono.just(response.version().text())),
|
||||
streams)
|
||||
.collectList()
|
||||
.block(Duration.ofSeconds(60));
|
||||
|
||||
assertThat(versions).hasSize(streams);
|
||||
assertThat(versions)
|
||||
.as("every stream must be carried over HTTP/2, not silently downgraded to HTTP/1.1")
|
||||
.containsOnly("HTTP/2.0");
|
||||
|
||||
// The point of multiplexing: 32 concurrent streams did not need 32 connections. The server
|
||||
// counts what it accepted, so this is an observation rather than an inference from the
|
||||
// client's own configuration.
|
||||
PerformanceAssertions.structural(
|
||||
"the bounded pool carried every concurrent stream", server.requestCount() == streams);
|
||||
} finally {
|
||||
pool.disposeLater().block(Duration.ofSeconds(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.performance;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.AccessToken;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.OAuth2TokenCacheKey;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.SingleFlightTokenLoader;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.IntStream;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Token refresh under contention must stay single-flight (design §20.3, §28.8). */
|
||||
class OAuthRefreshContentionTest {
|
||||
|
||||
private static final OAuth2TokenCacheKey KEY =
|
||||
new OAuth2TokenCacheKey(
|
||||
"payment",
|
||||
"java.lang.String",
|
||||
Set.of("payments.write"),
|
||||
Optional.of("payment-api"),
|
||||
Optional.empty(),
|
||||
Optional.empty());
|
||||
|
||||
/**
|
||||
* Single-flight collapses <em>concurrent</em> refreshes, so all hundred callers must genuinely be
|
||||
* in flight at once. A pool smaller than the caller count would serialise them into successive
|
||||
* refreshes and measure something the design never claimed.
|
||||
*/
|
||||
@Test
|
||||
void aHundredConcurrentCallersProduceOneTokenRequest() throws Exception {
|
||||
int callers = 100;
|
||||
AtomicInteger loads = new AtomicInteger();
|
||||
CountDownLatch started = new CountDownLatch(callers);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
SingleFlightTokenLoader loader =
|
||||
new SingleFlightTokenLoader(
|
||||
key -> {
|
||||
loads.incrementAndGet();
|
||||
try {
|
||||
release.await(10, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return new AccessToken(
|
||||
"token", Clock.systemUTC().instant().plus(Duration.ofMinutes(5)));
|
||||
});
|
||||
|
||||
ExecutorService pool = Executors.newFixedThreadPool(callers);
|
||||
try {
|
||||
List<Future<AccessToken>> futures =
|
||||
IntStream.range(0, callers)
|
||||
.mapToObj(
|
||||
index ->
|
||||
pool.submit(
|
||||
() -> {
|
||||
started.countDown();
|
||||
return loader.load(KEY);
|
||||
}))
|
||||
.toList();
|
||||
assertThat(started.await(20, TimeUnit.SECONDS)).isTrue();
|
||||
// Wait for the condition the test actually depends on — every caller inside load() — rather
|
||||
// than sleeping and hoping. `started` only proves each task began; it counts down *before*
|
||||
// load() is entered, so releasing on a fixed 200ms could let a caller arrive after the first
|
||||
// refresh had already completed and been removed, producing a second load and a failure that
|
||||
// looks like a single-flight bug but is a test bug.
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
|
||||
while (loader.joinedCallers() < callers && System.nanoTime() < deadline) {
|
||||
TimeUnit.MILLISECONDS.sleep(1);
|
||||
}
|
||||
assertThat(loader.joinedCallers())
|
||||
.as("every caller must be inside load() before the refresh is released")
|
||||
.isEqualTo(callers);
|
||||
release.countDown();
|
||||
for (Future<AccessToken> future : futures) {
|
||||
assertThat(future.get(20, TimeUnit.SECONDS)).isNotNull();
|
||||
}
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
|
||||
PerformanceAssertions.structural("token refresh collapsed to one request", loads.get() == 1);
|
||||
assertThat(loader.inFlightRefreshes()).isZero();
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.performance;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.OperationName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpBulkheadRejectedException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpPoolAcquireTimeoutException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.PoolSettings;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.testkit.TestGateways;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.testkit.UserResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Concurrency beyond the pool must be bounded, not unbounded queueing (design §14, §28.8). */
|
||||
class PoolSaturationPerformanceTest {
|
||||
|
||||
@Test
|
||||
void concurrentCallsStayWithinTheDeclaredPoolAndFailFastBeyondIt() throws Exception {
|
||||
int concurrency = 24;
|
||||
try (MockHttpServer server = MockHttpServer.start()) {
|
||||
ClientProfile profile =
|
||||
ClientProfiles.builder("saturation")
|
||||
.baseUrl(server.uri("/"))
|
||||
.pool(
|
||||
new PoolSettings(
|
||||
4,
|
||||
4,
|
||||
8,
|
||||
Duration.ofMillis(250),
|
||||
Duration.ofSeconds(30),
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(15),
|
||||
Duration.ofSeconds(5),
|
||||
false,
|
||||
false))
|
||||
.build();
|
||||
for (int index = 0; index < concurrency; index++) {
|
||||
server.enqueueJson(200, "{\"id\":1,\"name\":\"a\"}");
|
||||
}
|
||||
|
||||
try (TestGateways.Harness harness = TestGateways.forProfile(profile)) {
|
||||
AtomicInteger succeeded = new AtomicInteger();
|
||||
AtomicInteger rejected = new AtomicInteger();
|
||||
ExecutorService pool = Executors.newFixedThreadPool(concurrency);
|
||||
try {
|
||||
for (int index = 0; index < concurrency; index++) {
|
||||
pool.execute(
|
||||
() -> {
|
||||
try {
|
||||
harness
|
||||
.gateway()
|
||||
.exchange(
|
||||
profile.name(),
|
||||
HttpOperation.get(new OperationName("get-user"), "/users/1", Map.of()),
|
||||
ResponseType.of(UserResponse.class));
|
||||
succeeded.incrementAndGet();
|
||||
} catch (HttpBulkheadRejectedException
|
||||
| HttpRateLimitRejectedException
|
||||
| HttpPoolAcquireTimeoutException bounded) {
|
||||
// Only the platform's own back-pressure counts as a bounded rejection. Catching
|
||||
// RuntimeException made this assertion unfalsifiable: a NullPointerException, a
|
||||
// serialization failure or a bug in the harness all counted as "the pool did
|
||||
// its
|
||||
// job", so the test would have passed while proving the opposite.
|
||||
rejected.incrementAndGet();
|
||||
}
|
||||
});
|
||||
}
|
||||
pool.shutdown();
|
||||
assertThat(pool.awaitTermination(60, TimeUnit.SECONDS)).isTrue();
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
|
||||
PerformanceAssertions.structural(
|
||||
"every call reached a terminal outcome",
|
||||
succeeded.get() + rejected.get() == concurrency);
|
||||
assertThat(succeeded.get()).isPositive();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.apache;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportCapabilities;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportProvider;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.InetAddress;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
|
||||
|
||||
/**
|
||||
* Blocking default transport (design D-06, §13.4).
|
||||
*
|
||||
* <p>The provider hands out a Spring {@code ClientHttpRequestFactory}; the {@code
|
||||
* CloseableHttpClient} itself never escapes this package, which is what makes design §9.5's "no
|
||||
* native client in the public API" enforceable rather than aspirational.
|
||||
*/
|
||||
public final class ApacheBlockingTransportProvider implements BlockingTransportProvider {
|
||||
|
||||
private static final TransportId ID = new TransportId("apache");
|
||||
|
||||
private final ApacheClientFactory clientFactory = new ApacheClientFactory();
|
||||
private final ApacheFailureClassifier classifier = new ApacheFailureClassifier();
|
||||
private final Map<TransportResourceKey, ApacheClientFactory.ApacheRuntime> runtimes =
|
||||
new ConcurrentHashMap<>();
|
||||
private final Optional<MeterRegistry> meterRegistry;
|
||||
private final Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver;
|
||||
private final Function<ClientProfile, Optional<Function<String, List<InetAddress>>>>
|
||||
resolverFactory;
|
||||
|
||||
public ApacheBlockingTransportProvider() {
|
||||
this(Optional.empty(), profile -> Optional.empty(), profile -> Optional.empty());
|
||||
}
|
||||
|
||||
public ApacheBlockingTransportProvider(
|
||||
Optional<MeterRegistry> meterRegistry,
|
||||
Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver,
|
||||
Function<ClientProfile, Optional<Function<String, List<InetAddress>>>> resolverFactory) {
|
||||
this.meterRegistry = Objects.requireNonNull(meterRegistry, "meter registry");
|
||||
this.tlsMaterialResolver = Objects.requireNonNull(tlsMaterialResolver, "tls material resolver");
|
||||
this.resolverFactory = Objects.requireNonNull(resolverFactory, "dns resolver factory");
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportId id() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockingTransportCapabilities capabilities() {
|
||||
// Apache bounds the pending-acquire *wait* with connectionRequestTimeout; the pending-acquire
|
||||
// *count* is bounded by the platform's logical admission limiter. Both halves of design §14.1
|
||||
// are therefore satisfied for this transport.
|
||||
//
|
||||
// HTTP/1.1 only: Spring's HttpComponentsClientHttpRequestFactory drives the classic client, and
|
||||
// Apache implements HTTP/2 in its async client. Blocking HTTP/2 is served by the JDK transport.
|
||||
return BlockingTransportCapabilities.apacheClassic();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientHttpRequestFactory create(
|
||||
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) {
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
Objects.requireNonNull(listener, "lifecycle listener");
|
||||
ApacheClientFactory.ApacheRuntime runtime =
|
||||
clientFactory.create(
|
||||
profile, tlsMaterialResolver.apply(profile), resolverFactory.apply(profile));
|
||||
runtimes.put(new TransportResourceKey(profile.name(), generation), runtime);
|
||||
meterRegistry.ifPresent(
|
||||
registry -> ApachePoolMetricsBinder.bind(registry, profile.name(), runtime.pool()));
|
||||
listener.onRuntimeCreated(profile.name(), ID);
|
||||
return new HttpComponentsClientHttpRequestFactory(runtime.client());
|
||||
}
|
||||
|
||||
/** Live pool statistics; the pool-saturation and leak suites assert on these. */
|
||||
public int leasedConnections(ClientProfileName profileName) {
|
||||
return runtimes.entrySet().stream()
|
||||
.filter(entry -> entry.getKey().profileName().equals(profileName))
|
||||
.mapToInt(entry -> entry.getValue().pool().getTotalStats().getLeased())
|
||||
.sum();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportFailureClassifier failureClassifier() {
|
||||
return classifier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close(ClientProfile profile, RuntimeGeneration generation) {
|
||||
ApacheClientFactory.ApacheRuntime runtime =
|
||||
runtimes.remove(new TransportResourceKey(profile.name(), generation));
|
||||
if (runtime == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
runtime.client().close();
|
||||
} catch (IOException failure) {
|
||||
throw new UncheckedIOException(failure);
|
||||
} finally {
|
||||
runtime.pool().close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.apache;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.net.ConnectException;
|
||||
import java.net.NoRouteToHostException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.security.cert.CertificateException;
|
||||
import javax.net.ssl.SSLException;
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import javax.net.ssl.SSLPeerUnverifiedException;
|
||||
import org.apache.hc.client5.http.ConnectTimeoutException;
|
||||
import org.apache.hc.client5.http.HttpHostConnectException;
|
||||
import org.apache.hc.core5.http.ConnectionClosedException;
|
||||
|
||||
/**
|
||||
* Maps Apache HttpClient 5 failures onto stable evidence (design §13.3, §13.4).
|
||||
*
|
||||
* <p>The classifier is deliberately asymmetric: {@code NOT_SENT} is only produced for stages that
|
||||
* prove nothing left the process. Once the request write has begun, an ambiguous I/O error stays
|
||||
* {@code SENT_NO_RESPONSE}, because guessing "not sent" is what turns a timeout into a duplicate
|
||||
* payment.
|
||||
*/
|
||||
public final class ApacheFailureClassifier implements TransportFailureClassifier {
|
||||
|
||||
@Override
|
||||
public TransportFailure classify(Throwable failure, AttemptStage lastObservedStage) {
|
||||
// The whole cause chain is inspected, not just the outermost throwable: Spring wraps engine
|
||||
// exceptions, and a wrapped ConnectException still proves the request was never sent. Matching
|
||||
// only the outer type would downgrade a provable NOT_SENT to an ambiguous SENT_NO_RESPONSE.
|
||||
for (Throwable cause : chain(failure)) {
|
||||
TransportFailure recognized = recognize(cause, lastObservedStage);
|
||||
if (recognized != null) {
|
||||
return recognized;
|
||||
}
|
||||
}
|
||||
return conservative(lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
|
||||
}
|
||||
|
||||
private TransportFailure recognize(Throwable cause, AttemptStage lastObservedStage) {
|
||||
if (cause instanceof org.apache.hc.core5.concurrent.CancellableDependency) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.POOL_ACQUIRE, FailureCategory.POOL_ACQUIRE_TIMEOUT, "POOL_ACQUIRE_TIMEOUT");
|
||||
}
|
||||
if (isPoolAcquireTimeout(cause)) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.POOL_ACQUIRE, FailureCategory.POOL_ACQUIRE_TIMEOUT, "POOL_ACQUIRE_TIMEOUT");
|
||||
}
|
||||
if (cause instanceof UnknownHostException) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.DNS, FailureCategory.DNS, "DNS_RESOLUTION_FAILED");
|
||||
}
|
||||
if (cause instanceof ConnectTimeoutException
|
||||
|| cause instanceof HttpHostConnectException
|
||||
|| cause instanceof ConnectException
|
||||
|| cause instanceof NoRouteToHostException) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.CONNECT, FailureCategory.CONNECT, "CONNECT_FAILED");
|
||||
}
|
||||
if (cause instanceof SSLPeerUnverifiedException || cause instanceof CertificateException) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_TRUST_FAILED");
|
||||
}
|
||||
if (cause instanceof SSLHandshakeException) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_HANDSHAKE_FAILED");
|
||||
}
|
||||
if (cause instanceof SSLException
|
||||
&& !lastObservedStage.isAtLeast(AttemptStage.REQUEST_HEADERS)) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_TRANSIENT_FAILURE");
|
||||
}
|
||||
if (cause instanceof SocketTimeoutException) {
|
||||
return timeout(lastObservedStage);
|
||||
}
|
||||
if (cause instanceof InterruptedIOException) {
|
||||
// Not a timeout. A SocketTimeoutException means the peer went quiet; a bare
|
||||
// InterruptedIOException usually means this thread was interrupted — a cancellation, from a
|
||||
// caller or a shutdown. Treating the two alike classified a cancellation as a transient
|
||||
// timeout, which the retry engine then retried, so cancelling a call could produce more
|
||||
// requests than not cancelling it. The interrupt flag is restored because swallowing it
|
||||
// leaves the thread unable to observe its own cancellation.
|
||||
Thread.currentThread().interrupt();
|
||||
return conservative(lastObservedStage, FailureCategory.CANCELLED, "ATTEMPT_INTERRUPTED");
|
||||
}
|
||||
if (cause instanceof ConnectionClosedException) {
|
||||
return conservative(
|
||||
lastObservedStage, FailureCategory.RESPONSE_TRUNCATED, "CONNECTION_CLOSED");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private java.util.List<Throwable> chain(Throwable failure) {
|
||||
java.util.List<Throwable> chain = new java.util.ArrayList<>();
|
||||
Throwable current = failure;
|
||||
while (current != null && !chain.contains(current)) {
|
||||
chain.add(current);
|
||||
current = current.getCause();
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
private boolean isPoolAcquireTimeout(Throwable cause) {
|
||||
String typeName = cause.getClass().getName();
|
||||
return typeName.endsWith("ConnectionRequestTimeoutException");
|
||||
}
|
||||
|
||||
private TransportFailure timeout(AttemptStage lastObservedStage) {
|
||||
if (lastObservedStage.provesNotSent()) {
|
||||
return TransportFailure.notSent(
|
||||
lastObservedStage, FailureCategory.CONNECT, "CONNECT_TIMEOUT");
|
||||
}
|
||||
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
|
||||
return new TransportFailure(
|
||||
lastObservedStage,
|
||||
ExecutionEvidence.PARTIAL_RESPONSE,
|
||||
FailureCategory.RESPONSE_TIMEOUT,
|
||||
"RESPONSE_BODY_TIMEOUT");
|
||||
}
|
||||
return TransportFailure.sentNoResponse(
|
||||
AttemptStage.RESPONSE_HEADERS, FailureCategory.RESPONSE_TIMEOUT, "RESPONSE_HEADER_TIMEOUT");
|
||||
}
|
||||
|
||||
private TransportFailure conservative(
|
||||
AttemptStage lastObservedStage, FailureCategory category, String reason) {
|
||||
if (lastObservedStage.provesNotSent()) {
|
||||
return TransportFailure.notSent(lastObservedStage, category, reason);
|
||||
}
|
||||
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
|
||||
return new TransportFailure(
|
||||
lastObservedStage, ExecutionEvidence.PARTIAL_RESPONSE, category, reason);
|
||||
}
|
||||
return TransportFailure.sentNoResponse(lastObservedStage, category, reason);
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.api.body;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability;
|
||||
import java.util.Objects;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
/**
|
||||
* DTO body encoded by a deterministic codec (design §23.1).
|
||||
*
|
||||
* <p>Replayability is a property of the value, not of the codec. Every {@code ObjectBody} used to
|
||||
* report {@code REPLAYABLE} unconditionally, on the strength of a javadoc line asking callers not
|
||||
* to mutate the value afterwards. A mutable DTO handed to the platform and then changed by the
|
||||
* caller — a builder reused across calls, a collection the caller kept a reference to — produced a
|
||||
* retry that sent <em>different bytes</em> under the same idempotency key, which is the one thing a
|
||||
* replay must never do.
|
||||
*
|
||||
* <p>The check is structural and conservative: records, enums, strings, boxed primitives and
|
||||
* immutable collection views replay; anything else is treated as one-shot, so the retry engine
|
||||
* refuses rather than gambling. A caller who knows better can freeze the value itself — serialize
|
||||
* it to a {@code byte[]} body — which states the guarantee instead of asserting it.
|
||||
*/
|
||||
public record ObjectBody(Object value, String mediaType) implements BodySource {
|
||||
|
||||
public ObjectBody {
|
||||
Objects.requireNonNull(value, "object body value");
|
||||
Objects.requireNonNull(mediaType, "object body media type");
|
||||
}
|
||||
|
||||
public static ObjectBody json(Object value) {
|
||||
return new ObjectBody(value, "application/json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the body without printing it.
|
||||
*
|
||||
* <p>The generated {@code toString} rendered the payload itself, so any log line or exception
|
||||
* message that mentioned a body disclosed its contents — which for an outbound call is by
|
||||
* definition someone else's data.
|
||||
*/
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ObjectBody[" + value.getClass().getSimpleName() + ", " + mediaType + ", REDACTED]";
|
||||
}
|
||||
|
||||
@Override
|
||||
public BodyReplayability replayability() {
|
||||
return deeplyImmutable(value) ? BodyReplayability.REPLAYABLE : BodyReplayability.ONE_SHOT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether re-encoding this value is guaranteed to produce the same bytes.
|
||||
*
|
||||
* <p>Records are accepted when every component is itself immutable, which covers the DTO shape
|
||||
* the platform is built around without accepting a record that merely wraps a mutable list.
|
||||
*/
|
||||
private static boolean deeplyImmutable(Object candidate) {
|
||||
if (candidate == null) {
|
||||
return true;
|
||||
}
|
||||
if (candidate instanceof String
|
||||
|| candidate instanceof Number
|
||||
|| candidate instanceof Boolean
|
||||
|| candidate instanceof Character
|
||||
|| candidate instanceof Enum<?>
|
||||
|| candidate instanceof java.util.UUID
|
||||
|| candidate instanceof java.time.temporal.Temporal) {
|
||||
return true;
|
||||
}
|
||||
if (candidate instanceof java.util.Collection<?> collection) {
|
||||
return isImmutableCollectionView(collection)
|
||||
&& collection.stream().allMatch(ObjectBody::deeplyImmutable);
|
||||
}
|
||||
if (candidate instanceof java.util.Map<?, ?> map) {
|
||||
return isImmutableCollectionView(map)
|
||||
&& map.entrySet().stream()
|
||||
.allMatch(
|
||||
entry -> deeplyImmutable(entry.getKey()) && deeplyImmutable(entry.getValue()));
|
||||
}
|
||||
Class<?> type = candidate.getClass();
|
||||
if (!type.isRecord()) {
|
||||
return false;
|
||||
}
|
||||
for (java.lang.reflect.RecordComponent component : type.getRecordComponents()) {
|
||||
try {
|
||||
java.lang.reflect.Method accessor = component.getAccessor();
|
||||
accessor.setAccessible(true);
|
||||
if (!deeplyImmutable(accessor.invoke(candidate))) {
|
||||
return false;
|
||||
}
|
||||
} catch (ReflectiveOperationException | RuntimeException unreadable) {
|
||||
// A component the platform cannot inspect cannot be certified, and an uncertified body is
|
||||
// one-shot rather than optimistically replayable.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the collection is one of the JDK's unmodifiable views.
|
||||
*
|
||||
* <p>Name-based because {@code List.of(...)} and {@code Collections.unmodifiableList(...)} return
|
||||
* package-private classes with no shared marker interface. An ordinary {@code ArrayList} the
|
||||
* caller still holds is exactly the case this must not accept.
|
||||
*/
|
||||
private static boolean isImmutableCollectionView(Object collection) {
|
||||
String name = collection.getClass().getName();
|
||||
return name.startsWith("java.util.ImmutableCollections")
|
||||
|| name.startsWith("java.util.Collections$Unmodifiable")
|
||||
|| name.startsWith("java.util.Collections$Empty")
|
||||
|| name.startsWith("java.util.Collections$Singleton");
|
||||
}
|
||||
|
||||
@Override
|
||||
public OptionalLong knownLength() {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.api.operation;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.OperationName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Immutable description of one logical outbound call (design §10.1).
|
||||
*
|
||||
* <p>The operation carries the URI <em>template</em>, never an expanded URL: observability tags and
|
||||
* failure metadata must stay low-cardinality, and the security layer expands components itself.
|
||||
*/
|
||||
public record HttpOperation(
|
||||
OperationName operationName,
|
||||
HttpMethod method,
|
||||
String uriTemplate,
|
||||
Map<String, ?> uriVariables,
|
||||
Map<String, List<String>> headers,
|
||||
BodySource body,
|
||||
OperationIdempotency idempotency,
|
||||
Optional<IdempotencyKey> idempotencyKey,
|
||||
Optional<Instant> deadline) {
|
||||
|
||||
public HttpOperation {
|
||||
Objects.requireNonNull(operationName, "operation name");
|
||||
Objects.requireNonNull(method, "http method");
|
||||
Objects.requireNonNull(uriTemplate, "uri template");
|
||||
Objects.requireNonNull(uriVariables, "uri variables");
|
||||
Objects.requireNonNull(headers, "headers");
|
||||
Objects.requireNonNull(body, "body");
|
||||
Objects.requireNonNull(idempotency, "idempotency");
|
||||
Objects.requireNonNull(idempotencyKey, "idempotency key");
|
||||
Objects.requireNonNull(deadline, "deadline");
|
||||
if (idempotency == OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED && idempotencyKey.isEmpty()) {
|
||||
throw new IllegalArgumentException("idempotency key is required for this operation");
|
||||
}
|
||||
uriVariables = Map.copyOf(uriVariables);
|
||||
headers = copyHeaders(headers);
|
||||
}
|
||||
|
||||
public static HttpOperation get(
|
||||
OperationName operationName, String uriTemplate, Map<String, ?> uriVariables) {
|
||||
return new HttpOperation(
|
||||
operationName,
|
||||
HttpMethod.GET,
|
||||
uriTemplate,
|
||||
uriVariables,
|
||||
Map.of(),
|
||||
EmptyBody.instance(),
|
||||
OperationIdempotency.STANDARD_IDEMPOTENT,
|
||||
Optional.empty(),
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
public HttpOperation withHeaders(Map<String, List<String>> replacement) {
|
||||
return new HttpOperation(
|
||||
operationName,
|
||||
method,
|
||||
uriTemplate,
|
||||
uriVariables,
|
||||
replacement,
|
||||
body,
|
||||
idempotency,
|
||||
idempotencyKey,
|
||||
deadline);
|
||||
}
|
||||
|
||||
public HttpOperation withBody(BodySource replacement) {
|
||||
return new HttpOperation(
|
||||
operationName,
|
||||
method,
|
||||
uriTemplate,
|
||||
uriVariables,
|
||||
headers,
|
||||
replacement,
|
||||
idempotency,
|
||||
idempotencyKey,
|
||||
deadline);
|
||||
}
|
||||
|
||||
public HttpOperation withMethod(HttpMethod replacement) {
|
||||
return new HttpOperation(
|
||||
operationName,
|
||||
replacement,
|
||||
uriTemplate,
|
||||
uriVariables,
|
||||
headers,
|
||||
body,
|
||||
idempotency,
|
||||
idempotencyKey,
|
||||
deadline);
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> copyHeaders(Map<String, List<String>> headers) {
|
||||
Map<String, List<String>> copy = new LinkedHashMap<>();
|
||||
headers.forEach(
|
||||
(name, values) -> {
|
||||
Objects.requireNonNull(name, "header name");
|
||||
Objects.requireNonNull(values, "header values");
|
||||
copy.put(name, List.copyOf(new ArrayList<>(values)));
|
||||
});
|
||||
return Map.copyOf(copy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-cardinality, secret-free description.
|
||||
*
|
||||
* <p>The record's generated {@code toString} printed every header value, the body object and the
|
||||
* expanded URI variables. That string reaches a log the moment an operation appears in an
|
||||
* exception message, a debug statement or an assertion failure — so an {@code Authorization}
|
||||
* header, a request payload and a customer identifier were one stack trace away from the log
|
||||
* aggregator. The template is safe by construction; the values are not, and none of them are
|
||||
* needed to identify which operation this is.
|
||||
*/
|
||||
@Override
|
||||
public String toString() {
|
||||
return "HttpOperation["
|
||||
+ operationName.value()
|
||||
+ ' '
|
||||
+ method
|
||||
+ ' '
|
||||
+ uriTemplate
|
||||
+ ", headers="
|
||||
+ headers.keySet()
|
||||
+ ", body="
|
||||
+ body.getClass().getSimpleName()
|
||||
+ ", idempotency="
|
||||
+ idempotency
|
||||
+ ", idempotencyKey="
|
||||
+ (idempotencyKey.isPresent() ? "PRESENT" : "ABSENT")
|
||||
+ ']';
|
||||
}
|
||||
|
||||
/** Case-insensitive single header lookup used by the request writer and redirect coordinator. */
|
||||
public Optional<String> firstHeader(String name) {
|
||||
String wanted = name.toLowerCase(Locale.ROOT);
|
||||
return headers.entrySet().stream()
|
||||
.filter(entry -> entry.getKey().toLowerCase(Locale.ROOT).equals(wanted))
|
||||
.flatMap(entry -> entry.getValue().stream())
|
||||
.findFirst();
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.auth;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.OperationName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.AuthenticationSettings;
|
||||
import java.net.URI;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Input to credential materialization (design §20.2).
|
||||
*
|
||||
* <p>The principal is passed explicitly rather than read from ambient thread state: design §26.3
|
||||
* forbids implicitly picking up a user token, because that silently turns a machine-to-machine call
|
||||
* into a user-scoped one.
|
||||
*/
|
||||
public record CredentialRequest(
|
||||
ClientProfileName clientName,
|
||||
OperationName operationName,
|
||||
AuthenticationSettings settings,
|
||||
URI target,
|
||||
Optional<Object> principal,
|
||||
Optional<String> clientCertificateIdentity,
|
||||
boolean forceRefresh) {
|
||||
|
||||
/**
|
||||
* Identifies the request without exposing the principal or the target URL.
|
||||
*
|
||||
* <p>The generated {@code toString} printed the authenticated principal and the full target URI,
|
||||
* including any query string. A credential-resolution failure is exactly when this record ends up
|
||||
* in a log line, which made the failure path the most likely place for a user identity and a
|
||||
* signed URL to escape.
|
||||
*/
|
||||
@Override
|
||||
public String toString() {
|
||||
return "CredentialRequest["
|
||||
+ clientName.value()
|
||||
+ ' '
|
||||
+ operationName.value()
|
||||
+ ", type="
|
||||
+ settings.type()
|
||||
+ ", target="
|
||||
+ target.getScheme()
|
||||
+ "://"
|
||||
+ target.getHost()
|
||||
+ ", principal="
|
||||
+ (principal.isPresent() ? "PRESENT" : "ABSENT")
|
||||
+ ", forceRefresh="
|
||||
+ forceRefresh
|
||||
+ ']';
|
||||
}
|
||||
|
||||
public CredentialRequest {
|
||||
Objects.requireNonNull(clientName, "client name");
|
||||
Objects.requireNonNull(operationName, "operation name");
|
||||
Objects.requireNonNull(settings, "authentication settings");
|
||||
Objects.requireNonNull(target, "target");
|
||||
Objects.requireNonNull(principal, "principal");
|
||||
Objects.requireNonNull(clientCertificateIdentity, "client certificate identity");
|
||||
}
|
||||
|
||||
public CredentialRequest refreshed() {
|
||||
return new CredentialRequest(
|
||||
clientName, operationName, settings, target, principal, clientCertificateIdentity, true);
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.auth;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAuthenticationException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
|
||||
import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
|
||||
/**
|
||||
* OAuth2 access tokens obtained through Spring Security (design D-13, §20.3).
|
||||
*
|
||||
* <p>Token acquisition is delegated, but three rules are owned here because they are what make the
|
||||
* result safe under load: the cache key, single-flight refresh, and an expiry skew so a token is
|
||||
* replaced before an upstream starts rejecting it.
|
||||
*/
|
||||
public final class OAuth2CredentialProvider implements RequestCredentialProvider {
|
||||
|
||||
/**
|
||||
* Attribute name for the audience a token is requested for.
|
||||
*
|
||||
* <p>Not standardised in OAuth2 core, so it is spelled out here rather than borrowed from a
|
||||
* constant that does not exist; authorization servers that support audience restriction read this
|
||||
* parameter name.
|
||||
*/
|
||||
private static final String OAUTH2_AUDIENCE_ATTRIBUTE = "audience";
|
||||
|
||||
private static final Duration EXPIRY_SKEW = Duration.ofSeconds(30);
|
||||
|
||||
private final OAuth2AuthorizedClientManager authorizedClientManager;
|
||||
private final SingleFlightTokenLoader tokenLoader;
|
||||
private final ConcurrentMap<OAuth2TokenCacheKey, AccessToken> cache = new ConcurrentHashMap<>();
|
||||
private final Clock clock;
|
||||
|
||||
public OAuth2CredentialProvider(
|
||||
OAuth2AuthorizedClientManager authorizedClientManager, Clock clock) {
|
||||
this.authorizedClientManager =
|
||||
Objects.requireNonNull(authorizedClientManager, "authorized client manager");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.tokenLoader = new SingleFlightTokenLoader(this::loadToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CredentialType type() {
|
||||
return CredentialType.OAUTH2_CLIENT_CREDENTIALS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RequestCredentials resolve(CredentialRequest request) {
|
||||
OAuth2TokenCacheKey key = cacheKey(request);
|
||||
if (request.forceRefresh()) {
|
||||
cache.remove(key);
|
||||
}
|
||||
AccessToken token =
|
||||
cache.compute(
|
||||
key,
|
||||
(ignored, existing) ->
|
||||
existing == null || existing.expired(clock, EXPIRY_SKEW)
|
||||
? tokenLoader.load(key)
|
||||
: existing);
|
||||
return RequestCredentials.header("Authorization", "Bearer " + token.value());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invalidate(CredentialRequest request) {
|
||||
cache.remove(cacheKey(request));
|
||||
}
|
||||
|
||||
private OAuth2TokenCacheKey cacheKey(CredentialRequest request) {
|
||||
String registrationId =
|
||||
request
|
||||
.settings()
|
||||
.registrationId()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new HttpAuthenticationException(
|
||||
"oauth2 authentication requires a registration id",
|
||||
HttpFailureMetadata.startup(request.clientName())));
|
||||
return new OAuth2TokenCacheKey(
|
||||
registrationId,
|
||||
request.principal().map(principal -> principal.getClass().getName()).orElse("anonymous"),
|
||||
request.settings().scopes(),
|
||||
request.settings().audience(),
|
||||
Optional.empty(),
|
||||
request.clientCertificateIdentity());
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorizes, carrying the scopes and audience the profile declared.
|
||||
*
|
||||
* <p>Both used to be part of the cache key and part of nothing else. The platform cached tokens
|
||||
* <em>as though</em> they differed by scope while every authorize request asked for the
|
||||
* registration's default scopes, so a profile that declared a narrower scope set received a
|
||||
* broader token and a profile that declared a wider one received a token missing the scopes it
|
||||
* needed — and the cache confidently kept them apart. Attributes are the mechanism Spring's
|
||||
* authorized-client manager passes through to the token request.
|
||||
*/
|
||||
private AccessToken loadToken(OAuth2TokenCacheKey key) {
|
||||
OAuth2AuthorizeRequest.Builder builder =
|
||||
OAuth2AuthorizeRequest.withClientRegistrationId(key.registrationId())
|
||||
.principal(key.principalClass());
|
||||
if (!key.scopes().isEmpty()) {
|
||||
builder.attribute(
|
||||
org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.SCOPE,
|
||||
String.join(" ", key.scopes()));
|
||||
}
|
||||
key.audience().ifPresent(audience -> builder.attribute(OAUTH2_AUDIENCE_ATTRIBUTE, audience));
|
||||
OAuth2AuthorizeRequest authorizeRequest = builder.build();
|
||||
OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
|
||||
if (authorizedClient == null) {
|
||||
throw new IllegalStateException(
|
||||
"no oauth2 authorized client for registration " + key.registrationId());
|
||||
}
|
||||
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
|
||||
Instant expiresAt =
|
||||
accessToken.getExpiresAt() == null
|
||||
? clock.instant().plus(Duration.ofMinutes(5))
|
||||
: accessToken.getExpiresAt();
|
||||
return new AccessToken(accessToken.getTokenValue(), expiresAt);
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.auth;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAuthenticationException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Dispatches a reactive call to the provider its profile declared (design §20.1, §20.2).
|
||||
*
|
||||
* <p>The reactive runtime takes a single {@link ReactiveRequestCredentialProvider}, and the
|
||||
* composition root used to hand it {@link NoAuthCredentialProvider} unconditionally. A profile that
|
||||
* declared {@code BASIC}, {@code API_KEY_HEADER} or {@code STATIC_BEARER} and used the reactive API
|
||||
* therefore sent no credential at all — no startup error, no runtime error, just an unauthenticated
|
||||
* request that the upstream answered 401 and the platform reported as a remote failure.
|
||||
*
|
||||
* <p>This registry is the same shape as the blocking {@link CredentialProviderRegistry}: selection
|
||||
* is by declaration, and a type nobody registered is an error rather than a silent downgrade to
|
||||
* anonymous. The dispatch reads the type off the request, so one instance serves every profile.
|
||||
*/
|
||||
public final class ReactiveCredentialProviderRegistry implements ReactiveRequestCredentialProvider {
|
||||
|
||||
private final Map<CredentialType, ReactiveRequestCredentialProvider> providers =
|
||||
new EnumMap<>(CredentialType.class);
|
||||
|
||||
public ReactiveCredentialProviderRegistry register(ReactiveRequestCredentialProvider provider) {
|
||||
Objects.requireNonNull(provider, "reactive credential provider");
|
||||
providers.put(provider.type(), provider);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Adapts a provider whose resolution is a synchronous computation rather than I/O. */
|
||||
public ReactiveCredentialProviderRegistry registerNonBlocking(
|
||||
RequestCredentialProvider provider) {
|
||||
return register(ReactiveRequestCredentialProvider.fromNonBlocking(provider));
|
||||
}
|
||||
|
||||
public static ReactiveCredentialProviderRegistry withNoAuth() {
|
||||
return new ReactiveCredentialProviderRegistry()
|
||||
.registerNonBlocking(new NoAuthCredentialProvider());
|
||||
}
|
||||
|
||||
/**
|
||||
* The dispatch itself is a credential type of its own only in the degenerate sense; callers
|
||||
* select by request, so this reports {@code NONE}.
|
||||
*/
|
||||
@Override
|
||||
public CredentialType type() {
|
||||
return CredentialType.NONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<RequestCredentials> resolve(CredentialRequest request) {
|
||||
return provider(request).flatMap(provider -> provider.resolve(request));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> invalidate(CredentialRequest request) {
|
||||
return provider(request).flatMap(provider -> provider.invalidate(request));
|
||||
}
|
||||
|
||||
public boolean supports(CredentialType credentialType) {
|
||||
return providers.containsKey(credentialType);
|
||||
}
|
||||
|
||||
private Mono<ReactiveRequestCredentialProvider> provider(CredentialRequest request) {
|
||||
CredentialType credentialType = CredentialType.from(request.settings().type());
|
||||
ReactiveRequestCredentialProvider provider = providers.get(credentialType);
|
||||
if (provider == null) {
|
||||
return Mono.error(
|
||||
new HttpAuthenticationException(
|
||||
"no reactive credential provider is registered for " + credentialType,
|
||||
HttpFailureMetadata.startup(request.clientName())));
|
||||
}
|
||||
return Mono.just(provider);
|
||||
}
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.auth;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAuthenticationException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Collapses concurrent refreshes of the same token into one (design §20.3).
|
||||
*
|
||||
* <p>Without this, a token expiring under load produces one token request per in-flight call, which
|
||||
* is exactly when the authorization server can least afford them — and several providers rate-limit
|
||||
* or invalidate on that pattern.
|
||||
*
|
||||
* <p>Two things about <em>where</em> that refresh runs used to be wrong, and both bite under
|
||||
* exactly the load this class exists for.
|
||||
*
|
||||
* <p>It ran on the common {@link java.util.concurrent.ForkJoinPool}. That pool is sized for
|
||||
* CPU-bound work and shared with every parallel stream in the process; a token endpoint that goes
|
||||
* slow therefore parks common-pool threads and stalls unrelated work across the application. The
|
||||
* refresh now runs on a small bounded pool of its own, so a slow authorization server costs only
|
||||
* the threads dedicated to talking to it.
|
||||
*
|
||||
* <p>And the wait was {@code join()} — unbounded. A token endpoint that accepted the connection and
|
||||
* never answered blocked every caller of that credential indefinitely, past their own deadlines,
|
||||
* with no exception to attribute it to. The wait is bounded and expiry is a stable authentication
|
||||
* failure.
|
||||
*/
|
||||
public final class SingleFlightTokenLoader implements AutoCloseable {
|
||||
|
||||
/** Small on purpose: this pool exists to talk to one authorization server, not to scale out. */
|
||||
private static final int DEFAULT_POOL_SIZE = 2;
|
||||
|
||||
private static final Duration DEFAULT_ACQUIRE_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
private final ConcurrentMap<OAuth2TokenCacheKey, CompletableFuture<AccessToken>> inFlight =
|
||||
new ConcurrentHashMap<>();
|
||||
private final Function<OAuth2TokenCacheKey, AccessToken> delegate;
|
||||
private final ExecutorService refreshExecutor;
|
||||
private final Duration acquireTimeout;
|
||||
private final AtomicInteger joinedCallers = new AtomicInteger();
|
||||
|
||||
public SingleFlightTokenLoader(Function<OAuth2TokenCacheKey, AccessToken> delegate) {
|
||||
this(delegate, DEFAULT_POOL_SIZE, DEFAULT_ACQUIRE_TIMEOUT);
|
||||
}
|
||||
|
||||
public SingleFlightTokenLoader(
|
||||
Function<OAuth2TokenCacheKey, AccessToken> delegate, int poolSize, Duration acquireTimeout) {
|
||||
this.delegate = Objects.requireNonNull(delegate, "token loader delegate");
|
||||
this.acquireTimeout = Objects.requireNonNull(acquireTimeout, "acquire timeout");
|
||||
if (poolSize < 1) {
|
||||
throw new IllegalArgumentException("token refresh pool size must be positive");
|
||||
}
|
||||
if (acquireTimeout.isNegative() || acquireTimeout.isZero()) {
|
||||
throw new IllegalArgumentException("token acquire timeout must be positive");
|
||||
}
|
||||
this.refreshExecutor = Executors.newFixedThreadPool(poolSize, refreshThreadFactory());
|
||||
}
|
||||
|
||||
public AccessToken load(OAuth2TokenCacheKey key) {
|
||||
Objects.requireNonNull(key, "token cache key");
|
||||
joinedCallers.incrementAndGet();
|
||||
CompletableFuture<AccessToken> future =
|
||||
inFlight.computeIfAbsent(
|
||||
key,
|
||||
ignored -> CompletableFuture.supplyAsync(() -> delegate.apply(key), refreshExecutor));
|
||||
try {
|
||||
return future.get(acquireTimeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException timedOut) {
|
||||
// Cancelled rather than abandoned: leaving it running would let the next caller join a
|
||||
// refresh that has already outlived its usefulness.
|
||||
future.cancel(true);
|
||||
throw new HttpAuthenticationException(
|
||||
"oauth2 token refresh did not complete within " + acquireTimeout,
|
||||
HttpFailureMetadata.startup(
|
||||
new dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName(
|
||||
key.registrationId())),
|
||||
timedOut);
|
||||
} catch (ExecutionException failed) {
|
||||
Throwable cause = failed.getCause() == null ? failed : failed.getCause();
|
||||
if (cause instanceof RuntimeException runtime) {
|
||||
throw runtime;
|
||||
}
|
||||
throw new HttpAuthenticationException(
|
||||
"oauth2 token refresh failed",
|
||||
HttpFailureMetadata.startup(
|
||||
new dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName(
|
||||
key.registrationId())),
|
||||
cause);
|
||||
} catch (InterruptedException interrupted) {
|
||||
// The caller was cancelled; propagate the interrupt rather than swallowing it.
|
||||
Thread.currentThread().interrupt();
|
||||
future.cancel(true);
|
||||
throw new HttpAuthenticationException(
|
||||
"oauth2 token refresh was interrupted",
|
||||
HttpFailureMetadata.startup(
|
||||
new dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName(
|
||||
key.registrationId())),
|
||||
interrupted);
|
||||
} finally {
|
||||
inFlight.remove(key, future);
|
||||
}
|
||||
}
|
||||
|
||||
public int inFlightRefreshes() {
|
||||
return inFlight.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* How many callers have entered {@link #load} over this loader's lifetime.
|
||||
*
|
||||
* <p>Exposed so a contention test can wait for an observable condition — "all N callers have
|
||||
* joined" — instead of sleeping for an arbitrary interval and hoping. A sleep-based test is
|
||||
* simultaneously slower than it needs to be and unreliable on a loaded machine, which is the
|
||||
* worst pair of properties for a test that only fails intermittently.
|
||||
*
|
||||
* @return the cumulative number of {@code load} entries
|
||||
*/
|
||||
public int joinedCallers() {
|
||||
return joinedCallers.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
refreshExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
private static ThreadFactory refreshThreadFactory() {
|
||||
AtomicInteger counter = new AtomicInteger();
|
||||
return runnable -> {
|
||||
Thread thread =
|
||||
new Thread(runnable, "httpclient-oauth2-refresh-" + counter.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
};
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Carries the approved addresses of one dynamic call from validation to the socket.
|
||||
*
|
||||
* <p>This is the piece the SSRF defence was missing. {@link ValidatedDnsResolver} resolved a host,
|
||||
* rejected the target if any answer was forbidden, and produced a {@link PinnedTarget} holding the
|
||||
* exact approved addresses — and then the gateway handed the transport a URL containing the
|
||||
* <em>hostname</em>, and the transport resolved it again. Everything between the two resolutions
|
||||
* was unvalidated: a DNS server under an attacker's control answers the first query with a public
|
||||
* address and the second with {@code 169.254.169.254}, and the platform connects to the metadata
|
||||
* service having "validated" the target. The classic rebinding attack, defeated by a check that
|
||||
* discarded its own result.
|
||||
*
|
||||
* <p>The pin is thread-scoped because the dynamic gateway is blocking and thread-confined for the
|
||||
* duration of a physical request; it is installed around the attempt and removed in a {@code
|
||||
* finally}, so a pooled thread never carries one call's addresses into another's.
|
||||
*
|
||||
* <p>It deliberately does not cache. The previous approved-address map in the resolver lived for
|
||||
* the lifetime of the process with no TTL and no bound, which is a second, slower version of the
|
||||
* same problem: an address approved an hour ago is not evidence about the host now.
|
||||
*/
|
||||
public final class CallScopedDnsPin implements AutoCloseable {
|
||||
|
||||
private static final ThreadLocal<Map<String, List<InetAddress>>> CURRENT = new ThreadLocal<>();
|
||||
|
||||
private CallScopedDnsPin() {}
|
||||
|
||||
/**
|
||||
* Installs the pin for the current thread.
|
||||
*
|
||||
* @param host the canonical host the addresses were approved for
|
||||
* @param approvedAddresses the addresses the transport may connect to
|
||||
* @return a handle that removes the pin
|
||||
*/
|
||||
public static CallScopedDnsPin open(String host, List<InetAddress> approvedAddresses) {
|
||||
Objects.requireNonNull(host, "host");
|
||||
Objects.requireNonNull(approvedAddresses, "approved addresses");
|
||||
if (approvedAddresses.isEmpty()) {
|
||||
throw new IllegalArgumentException("a dns pin needs at least one approved address");
|
||||
}
|
||||
CURRENT.set(Map.of(host.toLowerCase(java.util.Locale.ROOT), List.copyOf(approvedAddresses)));
|
||||
return new CallScopedDnsPin();
|
||||
}
|
||||
|
||||
/**
|
||||
* The addresses the current call approved for a host.
|
||||
*
|
||||
* <p>An empty result means this host was not the one validated. The transport must then refuse
|
||||
* rather than fall back to a system lookup — a fallback would restore exactly the second,
|
||||
* unvalidated resolution this class exists to remove.
|
||||
*
|
||||
* @param host the host the transport is about to connect to
|
||||
* @return the approved addresses, or empty when the host was not pinned by this call
|
||||
*/
|
||||
public static List<InetAddress> addressesFor(String host) {
|
||||
Map<String, List<InetAddress>> pinned = CURRENT.get();
|
||||
if (pinned == null || host == null) {
|
||||
return List.of();
|
||||
}
|
||||
return pinned.getOrDefault(host.toLowerCase(java.util.Locale.ROOT), List.of());
|
||||
}
|
||||
|
||||
/** Whether a pin is installed on this thread. */
|
||||
public static boolean active() {
|
||||
return CURRENT.get() != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
CURRENT.remove();
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRedirectRejectedException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.IdempotencyKeyRequirement;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.observation.HttpClientObservationNames;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingAttemptExecutor;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingClientRuntime;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.StatusHandlingPolicy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.HeaderPolicy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedTarget;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import java.net.URI;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Executes a user-supplied URL under an explicit SSRF policy (design §22).
|
||||
*
|
||||
* <p>Every hop — including the first — goes through canonicalize → allowlist → resolve-all →
|
||||
* classify → pin. Redirects are followed here rather than by the blocking coordinator precisely so
|
||||
* the validation cannot be skipped for hop two.
|
||||
*
|
||||
* <p>No trusted credential, Cookie jar, or default header is inherited. A credential is attached
|
||||
* only when a {@link DynamicCredentialBinding} names that exact canonical host.
|
||||
*/
|
||||
public final class DefaultDynamicTargetGateway implements DynamicTargetGateway {
|
||||
|
||||
private final Map<DynamicTargetPolicyName, DynamicTargetPolicy> policies;
|
||||
private final Map<DynamicTargetPolicyName, ValidatedDnsResolver> resolvers;
|
||||
private final ClientRuntimeRegistry runtimes;
|
||||
private final BlockingAttemptExecutor executor;
|
||||
private final TargetCanonicalizer canonicalizer;
|
||||
private final List<DynamicCredentialBinding> credentialBindings;
|
||||
private final Function<String, String> secretResolver;
|
||||
|
||||
public DefaultDynamicTargetGateway(
|
||||
Map<DynamicTargetPolicyName, DynamicTargetPolicy> policies,
|
||||
Map<DynamicTargetPolicyName, ValidatedDnsResolver> resolvers,
|
||||
ClientRuntimeRegistry runtimes,
|
||||
BlockingAttemptExecutor executor,
|
||||
List<DynamicCredentialBinding> credentialBindings,
|
||||
Function<String, String> secretResolver) {
|
||||
this.policies = Map.copyOf(Objects.requireNonNull(policies, "dynamic target policies"));
|
||||
this.resolvers = Map.copyOf(Objects.requireNonNull(resolvers, "validated dns resolvers"));
|
||||
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
|
||||
this.executor = Objects.requireNonNull(executor, "attempt executor");
|
||||
this.credentialBindings = List.copyOf(Objects.requireNonNull(credentialBindings, "bindings"));
|
||||
this.secretResolver = Objects.requireNonNull(secretResolver, "secret resolver");
|
||||
this.canonicalizer = new TargetCanonicalizer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> HttpCallResult<T> exchange(
|
||||
DynamicTargetPolicyName policyName,
|
||||
URI target,
|
||||
HttpOperation operation,
|
||||
ResponseType<T> responseType) {
|
||||
Objects.requireNonNull(policyName, "policy name");
|
||||
Objects.requireNonNull(target, "target");
|
||||
Objects.requireNonNull(operation, "operation");
|
||||
|
||||
DynamicTargetPolicy policy = requirePolicy(policyName);
|
||||
ValidatedDnsResolver resolver = requireResolver(policyName);
|
||||
|
||||
try (ClientRuntimeLease lease = runtimes.acquire(new ClientProfileName(policyName.value()))) {
|
||||
BlockingClientRuntime runtime = requireBlockingRuntime(lease);
|
||||
HttpFailureMetadata metadata =
|
||||
HttpFailureMetadata.validation(
|
||||
runtime.name(),
|
||||
operation.operationName(),
|
||||
operation.method(),
|
||||
operation.uriTemplate(),
|
||||
operation.body().replayability());
|
||||
|
||||
URI current = target;
|
||||
HttpMethod method = operation.method();
|
||||
for (int hop = 0; ; hop++) {
|
||||
PinnedTarget pinned = validate(policy, resolver, current);
|
||||
PreparedOperation prepared =
|
||||
prepare(runtime, operation.withMethod(method), pinned, metadata);
|
||||
RequestCredentials credentials = credentialsFor(pinned.target());
|
||||
|
||||
// The pin is what makes the validation above binding. Without it the transport resolved the
|
||||
// hostname a second time and could land anywhere; with it the socket may only reach an
|
||||
// address this hop actually approved. Scoped to the hop, so the next redirect re-validates
|
||||
// and re-pins rather than inheriting an earlier decision.
|
||||
AttemptOutcome<T> outcome;
|
||||
try (CallScopedDnsPin pin =
|
||||
CallScopedDnsPin.open(pinned.target().host(), pinned.approvedAddresses())) {
|
||||
Objects.requireNonNull(pin, "dns pin");
|
||||
outcome =
|
||||
executor.execute(
|
||||
runtime,
|
||||
prepared,
|
||||
responseType,
|
||||
StatusHandlingPolicy.RETURN_RESULT,
|
||||
credentials,
|
||||
1,
|
||||
runtime.support().clock().instant(),
|
||||
metadata);
|
||||
} finally {
|
||||
// Discarded at the end of the hop. Retaining it would grow without bound and, worse,
|
||||
// would let a later call reuse an approval that was only ever made for this one.
|
||||
resolver.forget(pinned.target().host());
|
||||
}
|
||||
|
||||
HttpCallResult<T> result =
|
||||
outcome.result().orElseThrow(() -> outcome.failure().orElseThrow());
|
||||
Optional<URI> location = redirectLocation(result, pinned);
|
||||
if (location.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
if (hop >= policy.maxRedirectHops()) {
|
||||
recordRejection(runtime, policyName, "MAX_HOPS");
|
||||
throw new HttpRedirectRejectedException(
|
||||
"dynamic target redirect exceeded the policy hop limit", metadata);
|
||||
}
|
||||
// 303 turns the follow-up into a GET; every other redirect keeps the method, and the next
|
||||
// loop iteration revalidates the new target from scratch.
|
||||
method = result.status().value() == 303 ? HttpMethod.GET : method;
|
||||
current = location.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private PinnedTarget validate(
|
||||
DynamicTargetPolicy policy, ValidatedDnsResolver resolver, URI target) {
|
||||
CanonicalTarget canonical = canonicalizer.canonicalize(policy, target);
|
||||
return resolver.pin(canonical);
|
||||
}
|
||||
|
||||
private PreparedOperation prepare(
|
||||
BlockingClientRuntime runtime,
|
||||
HttpOperation operation,
|
||||
PinnedTarget pinned,
|
||||
HttpFailureMetadata metadata) {
|
||||
Map<String, List<String>> headers =
|
||||
HeaderPolicy.forOperation(IdempotencyKeyRequirement.none(), false)
|
||||
.validate(operation.headers(), metadata);
|
||||
runtime.bodyLimitPolicy().validate(operation.body(), metadata);
|
||||
PreparedTarget target = PreparedTarget.of(pinned.target().toUri(), operation.uriTemplate());
|
||||
return new PreparedOperation(
|
||||
operation,
|
||||
target,
|
||||
headers,
|
||||
runtime.profile().request().maxBodyBytes(),
|
||||
runtime.profile().response().maxWireBytes(),
|
||||
runtime.profile().response().maxDecodedBytes());
|
||||
}
|
||||
|
||||
private <T> Optional<URI> redirectLocation(HttpCallResult<T> result, PinnedTarget pinned) {
|
||||
int status = result.status().value();
|
||||
if (status != 301 && status != 302 && status != 303 && status != 307 && status != 308) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return result.headers().entrySet().stream()
|
||||
.filter(entry -> entry.getKey().equalsIgnoreCase("Location"))
|
||||
.flatMap(entry -> entry.getValue().stream())
|
||||
.findFirst()
|
||||
.map(location -> pinned.target().toUri().resolve(location));
|
||||
}
|
||||
|
||||
private RequestCredentials credentialsFor(CanonicalTarget target) {
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
credentialBindings.stream()
|
||||
.filter(binding -> binding.matches(target))
|
||||
.forEach(
|
||||
binding ->
|
||||
headers.put(binding.headerName(), secretResolver.apply(binding.secretReference())));
|
||||
return headers.isEmpty()
|
||||
? RequestCredentials.none()
|
||||
: new RequestCredentials(headers, Map.of());
|
||||
}
|
||||
|
||||
private void recordRejection(
|
||||
BlockingClientRuntime runtime, DynamicTargetPolicyName policyName, String reason) {
|
||||
runtime
|
||||
.support()
|
||||
.meterRegistry()
|
||||
.counter(
|
||||
HttpClientObservationNames.SSRF_REJECTED,
|
||||
List.of(Tag.of("clientName", policyName.value()), Tag.of("outcome", reason)))
|
||||
.increment();
|
||||
}
|
||||
|
||||
private DynamicTargetPolicy requirePolicy(DynamicTargetPolicyName name) {
|
||||
DynamicTargetPolicy policy = policies.get(name);
|
||||
if (policy == null) {
|
||||
throw new NoSuchElementException("unregistered dynamic target policy: " + name.value());
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
private ValidatedDnsResolver requireResolver(DynamicTargetPolicyName name) {
|
||||
ValidatedDnsResolver resolver = resolvers.get(name);
|
||||
if (resolver == null) {
|
||||
throw new NoSuchElementException(
|
||||
"no validated dns resolver for dynamic target policy: " + name.value());
|
||||
}
|
||||
return resolver;
|
||||
}
|
||||
|
||||
private BlockingClientRuntime requireBlockingRuntime(ClientRuntimeLease lease) {
|
||||
if (lease.runtime() instanceof BlockingClientRuntime blocking) {
|
||||
return blocking;
|
||||
}
|
||||
throw new HttpTargetRejectedException(
|
||||
"dynamic target policy is not bound to a blocking runtime",
|
||||
HttpFailureMetadata.startup(lease.runtime().name()));
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* An explicitly registered credential for one dynamic origin (design §9.4).
|
||||
*
|
||||
* <p>Dynamic targets inherit nothing. If a specific origin genuinely needs a credential, a security
|
||||
* owner registers this binding for that exact scheme, host and port — which makes the decision
|
||||
* auditable instead of implicit.
|
||||
*
|
||||
* <p>The binding used to match on host alone. That sent the credential to {@code http://host} as
|
||||
* readily as to {@code https://host}, and to any port the same host happened to serve — so a target
|
||||
* that downgraded to plaintext, or pointed at a different service on the same machine, received a
|
||||
* secret registered for neither.
|
||||
*
|
||||
* @param scheme the exact scheme the credential is registered for
|
||||
* @param canonicalHost the exact canonical host
|
||||
* @param port the exact port
|
||||
* @param headerName the header to carry the credential in; must be on the allowlist
|
||||
* @param secretReference the reference a secret backend resolves
|
||||
*/
|
||||
public record DynamicCredentialBinding(
|
||||
String scheme, String canonicalHost, int port, String headerName, String secretReference) {
|
||||
|
||||
/**
|
||||
* Header names a dynamic credential may use.
|
||||
*
|
||||
* <p>An allowlist because the header name decides who reads the secret. Without one a binding
|
||||
* could put a credential in {@code Host}, {@code Origin} or a header a proxy forwards onward.
|
||||
*/
|
||||
private static final Set<String> ALLOWED_HEADER_NAMES =
|
||||
Set.of("authorization", "x-api-key", "api-key", "x-client-key", "x-webhook-token");
|
||||
|
||||
public DynamicCredentialBinding {
|
||||
Objects.requireNonNull(scheme, "scheme");
|
||||
Objects.requireNonNull(canonicalHost, "canonical host");
|
||||
Objects.requireNonNull(headerName, "header name");
|
||||
Objects.requireNonNull(secretReference, "secret reference");
|
||||
if (scheme.isBlank()
|
||||
|| canonicalHost.isBlank()
|
||||
|| headerName.isBlank()
|
||||
|| secretReference.isBlank()) {
|
||||
throw new IllegalArgumentException("dynamic credential binding fields must not be blank");
|
||||
}
|
||||
if (port < 1 || port > 65535) {
|
||||
throw new IllegalArgumentException("dynamic credential binding port must be 1..65535");
|
||||
}
|
||||
if (!ALLOWED_HEADER_NAMES.contains(headerName.toLowerCase(Locale.ROOT))) {
|
||||
throw new IllegalArgumentException(
|
||||
"dynamic credential header "
|
||||
+ headerName
|
||||
+ " is not on the allowlist "
|
||||
+ ALLOWED_HEADER_NAMES);
|
||||
}
|
||||
scheme = scheme.toLowerCase(Locale.ROOT);
|
||||
canonicalHost = canonicalHost.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/** Convenience for the common case: HTTPS on the default port. */
|
||||
public static DynamicCredentialBinding httpsOn(
|
||||
String canonicalHost, String headerName, String secretReference) {
|
||||
return new DynamicCredentialBinding("https", canonicalHost, 443, headerName, secretReference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches only the exact origin.
|
||||
*
|
||||
* @param target the canonicalized target of this call
|
||||
* @return {@code true} when scheme, host and port all match
|
||||
*/
|
||||
public boolean matches(CanonicalTarget target) {
|
||||
return scheme.equals(target.scheme())
|
||||
&& canonicalHost.equals(target.host())
|
||||
&& port == target.port();
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The rules a user-supplied URL must satisfy (design §22).
|
||||
*
|
||||
* <p>A Dynamic Target policy is deliberately separate from a Named Client Profile: it inherits no
|
||||
* credential, no Cookie jar, and no default header (design D-03), so a webhook checker cannot
|
||||
* accidentally speak with a trusted client's identity.
|
||||
*/
|
||||
public record DynamicTargetPolicy(
|
||||
DynamicTargetPolicyName name,
|
||||
Set<String> allowedSchemes,
|
||||
Set<Integer> allowedPorts,
|
||||
Set<String> allowedHostSuffixes,
|
||||
Set<String> allowedHosts,
|
||||
int maxRedirectHops,
|
||||
boolean tracePropagation,
|
||||
java.util.List<String> additionalBlockedCidrs) {
|
||||
|
||||
public DynamicTargetPolicy {
|
||||
Objects.requireNonNull(name, "policy name");
|
||||
Objects.requireNonNull(allowedSchemes, "allowed schemes");
|
||||
Objects.requireNonNull(allowedPorts, "allowed ports");
|
||||
Objects.requireNonNull(allowedHostSuffixes, "allowed host suffixes");
|
||||
Objects.requireNonNull(allowedHosts, "allowed hosts");
|
||||
Objects.requireNonNull(additionalBlockedCidrs, "additional blocked cidrs");
|
||||
if (maxRedirectHops < 0) {
|
||||
throw new IllegalArgumentException("max redirect hops must not be negative");
|
||||
}
|
||||
allowedSchemes = Set.copyOf(allowedSchemes);
|
||||
allowedPorts = Set.copyOf(allowedPorts);
|
||||
allowedHostSuffixes = Set.copyOf(allowedHostSuffixes);
|
||||
allowedHosts = Set.copyOf(allowedHosts);
|
||||
additionalBlockedCidrs = java.util.List.copyOf(additionalBlockedCidrs);
|
||||
}
|
||||
|
||||
/** HTTPS-only public egress with no redirect following: the safest useful default. */
|
||||
public static DynamicTargetPolicy publicHttpsOnly(String name) {
|
||||
return new DynamicTargetPolicy(
|
||||
new DynamicTargetPolicyName(name),
|
||||
Set.of("https"),
|
||||
Set.of(443),
|
||||
Set.of(),
|
||||
Set.of(),
|
||||
0,
|
||||
false,
|
||||
java.util.List.of());
|
||||
}
|
||||
|
||||
public boolean hostAllowed(String canonicalHost) {
|
||||
if (allowedHosts.isEmpty() && allowedHostSuffixes.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
if (allowedHosts.contains(canonicalHost)) {
|
||||
return true;
|
||||
}
|
||||
return allowedHostSuffixes.stream()
|
||||
.anyMatch(suffix -> isSubdomainOrExactMatch(canonicalHost, suffix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a suffix only at a label boundary.
|
||||
*
|
||||
* <p>Plain {@code endsWith} is not a domain rule. A policy allowing {@code example.com} also
|
||||
* accepted {@code evil-example.com}, which an attacker registers precisely because the check is
|
||||
* written this way — the allowlist then reads as a restriction while permitting any domain whose
|
||||
* name happens to end in the allowed text.
|
||||
*
|
||||
* <p>A leading dot in the configured suffix is tolerated and means the same thing, so {@code
|
||||
* .example.com} and {@code example.com} both allow {@code api.example.com} and the apex.
|
||||
*/
|
||||
private static boolean isSubdomainOrExactMatch(String canonicalHost, String configuredSuffix) {
|
||||
String suffix =
|
||||
configuredSuffix.startsWith(".") ? configuredSuffix.substring(1) : configuredSuffix;
|
||||
if (suffix.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (canonicalHost.equals(suffix)) {
|
||||
return true;
|
||||
}
|
||||
return canonicalHost.endsWith("." + suffix);
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
|
||||
|
||||
import java.net.Inet4Address;
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Decides whether a resolved address may be connected to (design §22.2).
|
||||
*
|
||||
* <p>Two details matter more than the list itself. IPv4-mapped IPv6 addresses are normalised back
|
||||
* to IPv4 before classification, because {@code ::ffff:127.0.0.1} is loopback wearing a different
|
||||
* hat. And cloud metadata endpoints are blocked explicitly rather than relying on the link-local
|
||||
* rule, so an organisation-specific metadata address is still covered.
|
||||
*/
|
||||
public final class IpAddressClassifier {
|
||||
|
||||
private static final Set<String> METADATA_ADDRESSES =
|
||||
Set.of("169.254.169.254", "fd00:ec2::254", "100.100.100.200", "192.0.0.192");
|
||||
|
||||
private final List<CidrRange> additionalBlockedRanges;
|
||||
|
||||
public IpAddressClassifier() {
|
||||
this(List.of());
|
||||
}
|
||||
|
||||
public IpAddressClassifier(List<String> additionalBlockedCidrs) {
|
||||
Objects.requireNonNull(additionalBlockedCidrs, "additional blocked cidrs");
|
||||
this.additionalBlockedRanges = additionalBlockedCidrs.stream().map(CidrRange::parse).toList();
|
||||
}
|
||||
|
||||
/** Normalises an IPv4-mapped IPv6 address to its IPv4 form. */
|
||||
public static InetAddress normalize(InetAddress address) {
|
||||
if (!(address instanceof Inet6Address ipv6)) {
|
||||
return address;
|
||||
}
|
||||
byte[] bytes = ipv6.getAddress();
|
||||
boolean mapped = true;
|
||||
for (int index = 0; index < 10; index++) {
|
||||
if (bytes[index] != 0) {
|
||||
mapped = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!mapped || (bytes[10] & 0xFF) != 0xFF || (bytes[11] & 0xFF) != 0xFF) {
|
||||
return address;
|
||||
}
|
||||
try {
|
||||
return InetAddress.getByAddress(Arrays.copyOfRange(bytes, 12, 16));
|
||||
} catch (UnknownHostException impossible) {
|
||||
return address;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an address must not be connected to.
|
||||
*
|
||||
* <p>Structured as an allowlist of globally routable unicast space, then the operator's own
|
||||
* exclusions — not as a list of bad ranges. A denylist has to enumerate every special-purpose
|
||||
* block IANA has ever assigned, and the ones it forgets are reachable: {@code 192.0.2.0/24},
|
||||
* {@code 198.18.0.0/15}, {@code 240.0.0.0/4} and the IPv6 documentation and Teredo prefixes were
|
||||
* all absent, and each of them can be made to resolve somewhere useful to an attacker. Requiring
|
||||
* global unicast inverts the burden: an address is refused unless it is the kind of address a
|
||||
* public webhook could legitimately live on.
|
||||
*
|
||||
* @param rawAddress the resolved address, possibly IPv4-mapped
|
||||
* @return {@code true} when the platform must refuse the address
|
||||
*/
|
||||
public boolean forbidden(InetAddress rawAddress) {
|
||||
InetAddress address = normalize(rawAddress);
|
||||
if (!globallyRoutableUnicast(address)) {
|
||||
return true;
|
||||
}
|
||||
if (METADATA_ADDRESSES.contains(address.getHostAddress())) {
|
||||
return true;
|
||||
}
|
||||
return additionalBlockedRanges.stream().anyMatch(range -> range.contains(address));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the address is in globally routable unicast space.
|
||||
*
|
||||
* <p>The JDK predicates cover loopback, link-local, site-local, multicast and wildcard. The
|
||||
* remaining special-purpose blocks are listed explicitly because the JDK has no predicate for
|
||||
* them and their absence is what made the previous denylist incomplete.
|
||||
*/
|
||||
private boolean globallyRoutableUnicast(InetAddress address) {
|
||||
if (address.isAnyLocalAddress()
|
||||
|| address.isLoopbackAddress()
|
||||
|| address.isLinkLocalAddress()
|
||||
|| address.isSiteLocalAddress()
|
||||
|| address.isMulticastAddress()) {
|
||||
return false;
|
||||
}
|
||||
byte[] bytes = address.getAddress();
|
||||
if (address instanceof Inet4Address) {
|
||||
int first = bytes[0] & 0xFF;
|
||||
int second = bytes[1] & 0xFF;
|
||||
int third = bytes[2] & 0xFF;
|
||||
// 0.0.0.0/8 "this network"; 100.64.0.0/10 carrier-grade NAT, routinely internal;
|
||||
// 192.0.0.0/24 IETF protocol assignments; 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24
|
||||
// documentation ranges; 198.18.0.0/15 benchmarking; 240.0.0.0/4 reserved, which includes the
|
||||
// 255.255.255.255 broadcast address.
|
||||
if (first == 0
|
||||
|| (first == 100 && (second & 0xC0) == 64)
|
||||
|| (first == 192 && second == 0 && third == 0)
|
||||
|| (first == 192 && second == 0 && third == 2)
|
||||
|| (first == 198 && second == 51 && third == 100)
|
||||
|| (first == 203 && second == 0 && third == 113)
|
||||
|| (first == 198 && (second & 0xFE) == 18)
|
||||
|| (first & 0xF0) == 240) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (address instanceof Inet6Address) {
|
||||
// fc00::/7 unique local; 2001:db8::/32 documentation; 2001::/32 Teredo; 100::/64 discard.
|
||||
if ((bytes[0] & 0xFE) == 0xFC) {
|
||||
return false;
|
||||
}
|
||||
int firstWord = ((bytes[0] & 0xFF) << 8) | (bytes[1] & 0xFF);
|
||||
int secondWord = ((bytes[2] & 0xFF) << 8) | (bytes[3] & 0xFF);
|
||||
if (firstWord == 0x2001 && (secondWord == 0x0db8 || secondWord == 0x0000)) {
|
||||
return false;
|
||||
}
|
||||
if (firstWord == 0x0100 && secondWord == 0x0000) {
|
||||
return false;
|
||||
}
|
||||
// 2000::/3 is the only globally routable unicast range currently assigned.
|
||||
return (bytes[0] & 0xE0) == 0x20;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Minimal CIDR matcher for organisation-defined internal ranges. */
|
||||
private static final class CidrRange {
|
||||
|
||||
private static final java.util.regex.Pattern SLASH = java.util.regex.Pattern.compile("/");
|
||||
|
||||
/** IPv4 dotted quad or an IPv6 literal; anything else is a hostname and is refused. */
|
||||
private static final java.util.regex.Pattern LITERAL_ADDRESS =
|
||||
java.util.regex.Pattern.compile("^[0-9.]+$|^[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*$");
|
||||
|
||||
private final byte[] network;
|
||||
private final int prefixLength;
|
||||
|
||||
private CidrRange(byte[] network, int prefixLength) {
|
||||
this.network = network.clone();
|
||||
this.prefixLength = prefixLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a CIDR strictly.
|
||||
*
|
||||
* <p>Every rejection here used to be an acceptance. {@code Integer.parseInt} took {@code -1}
|
||||
* and {@code 33} without complaint, producing a range that matched everything or nothing; and
|
||||
* {@code InetAddress.getByName} accepts a <em>hostname</em>, so a typo'd entry performed a DNS
|
||||
* lookup at startup and pinned the block to whatever that name resolved to at that moment. An
|
||||
* operator's exclusion list is a security control, and every one of those outcomes silently
|
||||
* turned it into something else.
|
||||
*/
|
||||
static CidrRange parse(String cidr) {
|
||||
Objects.requireNonNull(cidr, "cidr");
|
||||
String[] parts = SLASH.split(cidr, -1);
|
||||
if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) {
|
||||
throw new IllegalArgumentException("invalid cidr, expected <address>/<prefix>: " + cidr);
|
||||
}
|
||||
if (!LITERAL_ADDRESS.matcher(parts[0]).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"cidr address must be an ip literal, not a hostname: " + cidr);
|
||||
}
|
||||
byte[] network;
|
||||
try {
|
||||
network = InetAddress.getByName(parts[0]).getAddress();
|
||||
} catch (UnknownHostException invalid) {
|
||||
throw new IllegalArgumentException("invalid cidr address: " + cidr, invalid);
|
||||
}
|
||||
int prefixLength;
|
||||
try {
|
||||
prefixLength = Integer.parseInt(parts[1]);
|
||||
} catch (NumberFormatException notANumber) {
|
||||
throw new IllegalArgumentException("cidr prefix must be an integer: " + cidr, notANumber);
|
||||
}
|
||||
int maximumPrefix = network.length * 8;
|
||||
if (prefixLength < 0 || prefixLength > maximumPrefix) {
|
||||
throw new IllegalArgumentException(
|
||||
"cidr prefix must be 0.." + maximumPrefix + " for this address family: " + cidr);
|
||||
}
|
||||
return new CidrRange(network, prefixLength);
|
||||
}
|
||||
|
||||
boolean contains(InetAddress address) {
|
||||
byte[] candidate = address.getAddress();
|
||||
if (candidate.length != network.length) {
|
||||
return false;
|
||||
}
|
||||
int fullBytes = prefixLength / 8;
|
||||
for (int index = 0; index < fullBytes; index++) {
|
||||
if (candidate[index] != network[index]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
int remainingBits = prefixLength % 8;
|
||||
if (remainingBits == 0) {
|
||||
return true;
|
||||
}
|
||||
int mask = 0xFF << (8 - remainingBits);
|
||||
return (candidate[fullBytes] & mask) == (network[fullBytes] & mask);
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.dynamic;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpDnsException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Resolves a host and validates <em>every</em> answer (design §22.1 steps 6-9).
|
||||
*
|
||||
* <p>Validating only the first answer is a common and fatal shortcut: a host that resolves to one
|
||||
* public and one private address would pass, and the connection could still land on the private
|
||||
* one. Any forbidden address in the answer set rejects the whole target.
|
||||
*
|
||||
* <p>Approved addresses travel to the socket through {@link CallScopedDnsPin}, installed by the
|
||||
* gateway for the duration of one hop. They are deliberately not retained here between calls: the
|
||||
* previous per-host map had neither a TTL nor a size bound, so it grew without limit and, worse,
|
||||
* answered a later call with an address that was only ever validated for an earlier one. An address
|
||||
* approved five minutes ago is not evidence about the host now, which is the entire premise of
|
||||
* rebinding.
|
||||
*/
|
||||
public final class ValidatedDnsResolver {
|
||||
|
||||
private static final HttpFailureMetadata SCOPE =
|
||||
HttpFailureMetadata.startup(new ClientProfileName("dynamic-target"));
|
||||
|
||||
private final Function<String, InetAddress[]> systemResolver;
|
||||
private final IpAddressClassifier classifier;
|
||||
private final Map<String, List<InetAddress>> approved = new ConcurrentHashMap<>();
|
||||
|
||||
public ValidatedDnsResolver(IpAddressClassifier classifier) {
|
||||
this(classifier, ValidatedDnsResolver::systemResolve);
|
||||
}
|
||||
|
||||
public ValidatedDnsResolver(
|
||||
IpAddressClassifier classifier, Function<String, InetAddress[]> systemResolver) {
|
||||
this.classifier = Objects.requireNonNull(classifier, "ip address classifier");
|
||||
this.systemResolver = Objects.requireNonNull(systemResolver, "system resolver");
|
||||
}
|
||||
|
||||
public List<InetAddress> resolve(String host) {
|
||||
Objects.requireNonNull(host, "host");
|
||||
InetAddress[] answers = systemResolver.apply(host);
|
||||
if (answers == null || answers.length == 0) {
|
||||
throw new HttpDnsException("dynamic target host did not resolve", SCOPE);
|
||||
}
|
||||
List<InetAddress> normalized = new ArrayList<>(answers.length);
|
||||
for (InetAddress answer : answers) {
|
||||
if (classifier.forbidden(answer)) {
|
||||
approved.remove(host);
|
||||
throw new HttpTargetRejectedException(
|
||||
"dynamic target resolves to a forbidden address range", SCOPE);
|
||||
}
|
||||
normalized.add(IpAddressClassifier.normalize(answer));
|
||||
}
|
||||
List<InetAddress> immutable = List.copyOf(normalized);
|
||||
approved.put(host, immutable);
|
||||
return immutable;
|
||||
}
|
||||
|
||||
public PinnedTarget pin(CanonicalTarget target) {
|
||||
return new PinnedTarget(target, resolve(target.host()));
|
||||
}
|
||||
|
||||
/** Addresses the transport may connect to; empty when the host was never validated. */
|
||||
public List<InetAddress> approvedAddresses(String host) {
|
||||
return approved.getOrDefault(host, List.of());
|
||||
}
|
||||
|
||||
public void forget(String host) {
|
||||
approved.remove(host);
|
||||
}
|
||||
|
||||
private static InetAddress[] systemResolve(String host) {
|
||||
try {
|
||||
return InetAddress.getAllByName(host);
|
||||
} catch (UnknownHostException unknown) {
|
||||
throw new HttpDnsException("dynamic target host did not resolve", SCOPE, unknown);
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.http3;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* What the Experimental HTTP/3 transport can and cannot prove (design §13.7, §29).
|
||||
*
|
||||
* <p>The contract suite runs only the subset declared here. Declaring less than the truth costs
|
||||
* coverage; declaring more would let the Experimental transport claim Stable guarantees it has not
|
||||
* demonstrated.
|
||||
*
|
||||
* <p>Two earlier defects in this report are worth naming, because both made it say the opposite of
|
||||
* the truth.
|
||||
*
|
||||
* <p>The first is that it probed {@code
|
||||
* org.eclipse.jetty.quic.client.QuicClientConnectorConfigurator}, a class that does not exist in
|
||||
* the Jetty version this repository pins. The probe therefore reported "no QUIC" on a classpath
|
||||
* that carries the entire QUIC and HTTP/3 client stack — a false negative that nothing noticed
|
||||
* because nothing acted on it.
|
||||
*
|
||||
* <p>The second is that the probe was disconnected from the provider. It asked whether *some* QUIC
|
||||
* class existed, never whether {@link JettyHttp3TransportProvider} used one, and the provider was
|
||||
* in fact building {@code HttpClientTransportOverHTTP}: plain HTTP/1.1 over TCP, reported as
|
||||
* HTTP/3. The probe now names the exact classes the provider constructs, so the report cannot drift
|
||||
* from it again without failing to load them.
|
||||
*
|
||||
* @param quicNativeSupportPresent whether the QUIC and HTTP/3 client classes the provider
|
||||
* constructs are loadable
|
||||
* @param tls13Available whether the runtime offers TLS 1.3, which HTTP/3 requires
|
||||
* @param wireVerified whether a negotiated {@code h3} exchange against a real HTTP/3 server has
|
||||
* been observed in this build; class presence is not evidence of interoperability
|
||||
* @param dynamicTargetSupported always false — H3 needs validated address pinning this transport
|
||||
* cannot yet provide
|
||||
* @param unsupportedContracts the contracts the Experimental transport does not run
|
||||
*/
|
||||
public record Http3CapabilityReport(
|
||||
boolean quicNativeSupportPresent,
|
||||
boolean tls13Available,
|
||||
boolean wireVerified,
|
||||
boolean dynamicTargetSupported,
|
||||
List<String> unsupportedContracts) {
|
||||
|
||||
/**
|
||||
* The classes the provider actually constructs. Probing anything else would let the report and
|
||||
* the provider disagree.
|
||||
*/
|
||||
private static final List<String> REQUIRED_QUIC_CLASSES =
|
||||
List.of(
|
||||
"org.eclipse.jetty.quic.client.ClientQuicConfiguration",
|
||||
"org.eclipse.jetty.http3.client.HTTP3Client",
|
||||
"org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3");
|
||||
|
||||
public Http3CapabilityReport {
|
||||
Objects.requireNonNull(unsupportedContracts, "unsupported contracts");
|
||||
unsupportedContracts = List.copyOf(unsupportedContracts);
|
||||
}
|
||||
|
||||
public static Http3CapabilityReport detect() {
|
||||
return new Http3CapabilityReport(
|
||||
quicClassesPresent(),
|
||||
detectTls13Support(),
|
||||
// No HTTP/3 server is stood up anywhere in this build, so nothing has observed a negotiated
|
||||
// h3 exchange. Until something does, the transport stays un-promotable no matter how
|
||||
// complete its classpath looks.
|
||||
false,
|
||||
false,
|
||||
List.of(
|
||||
"dynamic-target-pinning",
|
||||
"pool-saturation-evidence",
|
||||
"forward-proxy-tunnel",
|
||||
"negotiated-protocol-wire-proof"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the transport may be advertised as anything beyond Experimental.
|
||||
*
|
||||
* <p>Requires wire proof, not classpath proof. A complete set of QUIC classes says the code can
|
||||
* be constructed; it says nothing about whether a peer negotiated {@code h3}.
|
||||
*
|
||||
* @return {@code true} only when a real negotiated exchange has been observed
|
||||
*/
|
||||
public boolean promotableToBeta() {
|
||||
return quicNativeSupportPresent && tls13Available && wireVerified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the provider can build its transport at all.
|
||||
*
|
||||
* @return {@code true} when every class the provider constructs is loadable and TLS 1.3 is
|
||||
* offered
|
||||
*/
|
||||
public boolean constructible() {
|
||||
return quicNativeSupportPresent && tls13Available;
|
||||
}
|
||||
|
||||
static List<String> requiredQuicClasses() {
|
||||
return REQUIRED_QUIC_CLASSES;
|
||||
}
|
||||
|
||||
private static boolean quicClassesPresent() {
|
||||
for (String className : REQUIRED_QUIC_CLASSES) {
|
||||
try {
|
||||
Class.forName(className, false, Http3CapabilityReport.class.getClassLoader());
|
||||
} catch (ClassNotFoundException absent) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean detectTls13Support() {
|
||||
try {
|
||||
return List.of(
|
||||
javax.net.ssl.SSLContext.getDefault().getSupportedSSLParameters().getProtocols())
|
||||
.contains("TLSv1.3");
|
||||
} catch (java.security.NoSuchAlgorithmException unavailable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.http3;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportCapabilities;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportProvider;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.http.HttpVersion;
|
||||
import org.eclipse.jetty.http3.client.HTTP3Client;
|
||||
import org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3;
|
||||
import org.eclipse.jetty.io.Transport;
|
||||
import org.eclipse.jetty.quic.client.ClientQuicConfiguration;
|
||||
import org.springframework.http.client.reactive.ClientHttpConnector;
|
||||
import org.springframework.http.client.reactive.JettyClientHttpConnector;
|
||||
|
||||
/**
|
||||
* Experimental HTTP/3 transport (design D-08, §13.7, §32.7).
|
||||
*
|
||||
* <p>Three guards keep it Experimental in practice, not just in documentation: an exact
|
||||
* acknowledgement string, a capability report that must show QUIC and TLS 1.3, and a hard refusal
|
||||
* to serve Dynamic Targets — H3 requires validated address pinning this transport cannot yet prove.
|
||||
*
|
||||
* <p>The Stable starter never auto-configures this provider.
|
||||
*
|
||||
* <p>This class used to build {@code HttpClientTransportOverHTTP} — plain HTTP/1.1 over TCP — and
|
||||
* present it as HTTP/3. Every guard above passed, the profile declared {@code HTTP_3}, the
|
||||
* acknowledgement was checked, and the resulting connection negotiated HTTP/1.1. A caller who opted
|
||||
* into an experimental protocol got neither the protocol nor a warning. It now constructs the real
|
||||
* QUIC-backed transport and refuses outright when it cannot, so the failure mode is a startup error
|
||||
* rather than a silent downgrade.
|
||||
*
|
||||
* <p>Constructing the right transport is still not proof that HTTP/3 works. Nothing in this build
|
||||
* stands up an HTTP/3 server, so {@link Http3CapabilityReport#promotableToBeta()} stays false
|
||||
* regardless of how complete the classpath is.
|
||||
*/
|
||||
public final class JettyHttp3TransportProvider implements ReactiveTransportProvider {
|
||||
|
||||
private static final TransportId ID = new TransportId("jetty-http3");
|
||||
|
||||
private final JettyHttp3FailureClassifier classifier = new JettyHttp3FailureClassifier();
|
||||
private final Map<TransportResourceKey, HttpClient> clients = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public TransportId id() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReactiveTransportCapabilities capabilities() {
|
||||
return ReactiveTransportCapabilities.jettyHttp3Experimental();
|
||||
}
|
||||
|
||||
public Http3CapabilityReport capabilityReport() {
|
||||
return Http3CapabilityReport.detect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientHttpConnector create(
|
||||
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) {
|
||||
requireExperimentalAcknowledgement(profile);
|
||||
if (profile.mode() == ClientMode.DYNAMIC) {
|
||||
throw new HttpConfigurationException(
|
||||
"the experimental http/3 transport does not support dynamic targets",
|
||||
HttpFailureMetadata.startup(profile.name()));
|
||||
}
|
||||
HttpClient client = newHttp3Client(profile);
|
||||
clients.put(new TransportResourceKey(profile.name(), generation), client);
|
||||
listener.onRuntimeCreated(profile.name(), ID);
|
||||
|
||||
JettyClientHttpConnector connector = new JettyClientHttpConnector(client);
|
||||
return connector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the QUIC-backed Jetty client, or refuses.
|
||||
*
|
||||
* <p>Package-visible so a test can assert which transport was constructed. Asserting on the
|
||||
* connector cannot do it — {@code JettyClientHttpConnector} does not expose the client — and the
|
||||
* defect this replaces was invisible precisely because nothing looked.
|
||||
*
|
||||
* @param profile the profile whose timeouts and pool bounds configure the client
|
||||
* @return a client whose transport is HTTP/3 over QUIC
|
||||
* @throws HttpConfigurationException when the runtime cannot offer TLS 1.3 or the QUIC classes
|
||||
* are absent; never a downgrade to TCP
|
||||
*/
|
||||
static HttpClient newHttp3Client(ClientProfile profile) {
|
||||
HttpClient client = new HttpClient(newHttp3Transport(profile));
|
||||
client.setFollowRedirects(false);
|
||||
client.setConnectTimeout(profile.timeout().connect().toMillis());
|
||||
client.setIdleTimeout(profile.timeout().readIdle().toMillis());
|
||||
client.setMaxConnectionsPerDestination(profile.pool().maxConnectionsPerRoute());
|
||||
client.setMaxRequestsQueuedPerDestination(Math.max(1, profile.pool().maxPendingAcquires()));
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* The QUIC-backed transport, or a refusal.
|
||||
*
|
||||
* <p>Returned as its own value rather than read back off the client, because Jetty 12.1
|
||||
* deprecated {@code HttpClient#getTransport()} for removal — a test that reached through the
|
||||
* client would be asserting on an API scheduled to disappear.
|
||||
*
|
||||
* @param profile the profile whose connect and idle budgets configure the QUIC session
|
||||
* @return an HTTP/3-over-QUIC transport
|
||||
* @throws HttpConfigurationException when TLS 1.3 or the QUIC classes are unavailable
|
||||
*/
|
||||
static HttpClientTransportOverHTTP3 newHttp3Transport(ClientProfile profile) {
|
||||
Http3CapabilityReport report = Http3CapabilityReport.detect();
|
||||
if (!report.tls13Available()) {
|
||||
throw new HttpConfigurationException(
|
||||
"http/3 requires TLS 1.3, which this runtime does not offer",
|
||||
HttpFailureMetadata.startup(profile.name()));
|
||||
}
|
||||
if (!report.quicNativeSupportPresent()) {
|
||||
throw new HttpConfigurationException(
|
||||
"http/3 requires the Jetty QUIC client stack "
|
||||
+ Http3CapabilityReport.requiredQuicClasses()
|
||||
+ ", which is not on the classpath; refusing rather than falling back to TCP",
|
||||
HttpFailureMetadata.startup(profile.name()));
|
||||
}
|
||||
|
||||
HTTP3Client http3Client = new HTTP3Client(new ClientQuicConfiguration());
|
||||
http3Client.getClientConnector().setConnectTimeout(profile.timeout().connect());
|
||||
http3Client
|
||||
.getHTTP3Configuration()
|
||||
.setStreamIdleTimeout(profile.timeout().readIdle().toMillis());
|
||||
return new HttpClientTransportOverHTTP3(http3Client, Transport.UDP_IP);
|
||||
}
|
||||
|
||||
/**
|
||||
* The wire version this transport is configured to speak.
|
||||
*
|
||||
* <p>Named for what it is. The previous {@code negotiatedVersion} claimed to describe the wire
|
||||
* while reading only a classpath probe, and returned HTTP/2 for a client that was speaking
|
||||
* HTTP/1.1. A negotiated version can only come from an exchange, and this build has none.
|
||||
*
|
||||
* @return always HTTP/3 — the provider refuses to build anything else
|
||||
*/
|
||||
public HttpVersion configuredVersion() {
|
||||
return HttpVersion.HTTP_3;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportFailureClassifier failureClassifier() {
|
||||
return classifier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close(ClientProfile profile, RuntimeGeneration generation) {
|
||||
HttpClient client = clients.remove(new TransportResourceKey(profile.name(), generation));
|
||||
if (client == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
client.stop();
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("jetty http/3 client did not stop cleanly", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireExperimentalAcknowledgement(ClientProfile profile) {
|
||||
String acknowledgement =
|
||||
profile
|
||||
.experimentalAcknowledgement()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new HttpConfigurationException(
|
||||
"http/3 requires an explicit experimental acknowledgement",
|
||||
HttpFailureMetadata.startup(profile.name())));
|
||||
try {
|
||||
Http3ExperimentalAcknowledgement unused =
|
||||
new Http3ExperimentalAcknowledgement(acknowledgement);
|
||||
assert unused != null;
|
||||
} catch (IllegalArgumentException invalid) {
|
||||
throw new HttpConfigurationException(
|
||||
"http/3 requires an explicit experimental acknowledgement",
|
||||
HttpFailureMetadata.startup(profile.name()),
|
||||
invalid);
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.jdk;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportCapabilities;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportProvider;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey;
|
||||
import java.net.http.HttpClient;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
|
||||
/**
|
||||
* Lightweight blocking alternative (design D-06, §13.5).
|
||||
*
|
||||
* <p>Capability validation runs before any client is built: a profile this transport cannot honour
|
||||
* fails without ever reaching the network.
|
||||
*/
|
||||
public final class JdkBlockingTransportProvider implements BlockingTransportProvider {
|
||||
|
||||
private static final TransportId ID = new TransportId("jdk");
|
||||
|
||||
private final JdkClientFactory clientFactory = new JdkClientFactory();
|
||||
private final JdkFailureClassifier classifier = new JdkFailureClassifier();
|
||||
private final JdkTransportCapabilityPolicy capabilityPolicy = new JdkTransportCapabilityPolicy();
|
||||
private final Map<TransportResourceKey, HttpClient> clients = new ConcurrentHashMap<>();
|
||||
private final Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver;
|
||||
|
||||
public JdkBlockingTransportProvider() {
|
||||
this(profile -> Optional.empty());
|
||||
}
|
||||
|
||||
public JdkBlockingTransportProvider(
|
||||
Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver) {
|
||||
this.tlsMaterialResolver = Objects.requireNonNull(tlsMaterialResolver, "tls material resolver");
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportId id() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BlockingTransportCapabilities capabilities() {
|
||||
return BlockingTransportCapabilities.lightweightHttp11AndHttp2();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientHttpRequestFactory create(
|
||||
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) {
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
Objects.requireNonNull(listener, "lifecycle listener");
|
||||
capabilityPolicy.validate(profile);
|
||||
|
||||
HttpClient client = clientFactory.create(profile, tlsMaterialResolver.apply(profile));
|
||||
clients.put(new TransportResourceKey(profile.name(), generation), client);
|
||||
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(client);
|
||||
factory.setReadTimeout(profile.timeout().responseHeader());
|
||||
listener.onRuntimeCreated(profile.name(), ID);
|
||||
return factory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportFailureClassifier failureClassifier() {
|
||||
return classifier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close(ClientProfile profile, RuntimeGeneration generation) {
|
||||
HttpClient client = clients.remove(new TransportResourceKey(profile.name(), generation));
|
||||
if (client != null) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.jdk;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.ProxySelector;
|
||||
import java.net.http.HttpClient;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
|
||||
/**
|
||||
* Builds the JDK HttpClient runtime for one profile (design §13.5).
|
||||
*
|
||||
* <p>Redirects are always {@code NEVER}: the platform re-validates every hop itself, and the JDK's
|
||||
* own follower would silently forward credentials across origins.
|
||||
*/
|
||||
public final class JdkClientFactory {
|
||||
|
||||
public HttpClient create(ClientProfile profile, Optional<SslContextMaterial> tlsMaterial) {
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
HttpClient.Builder builder =
|
||||
HttpClient.newBuilder()
|
||||
.connectTimeout(profile.timeout().connect())
|
||||
.followRedirects(HttpClient.Redirect.NEVER)
|
||||
.version(
|
||||
profile.protocols().contains(HttpProtocol.HTTP_2)
|
||||
? HttpClient.Version.HTTP_2
|
||||
: HttpClient.Version.HTTP_1_1);
|
||||
|
||||
// TLS parameters are applied whether or not the profile supplies custom material. They used to
|
||||
// be set only inside this ifPresent, so a profile that declared `tls.protocols: [TLSv1.3]` and
|
||||
// used the JVM trust store — the common case — configured nothing at all and negotiated
|
||||
// whatever the platform default allowed, TLS 1.2 included. A declared TLS floor that only
|
||||
// applies when you also supply a custom truststore is not a floor.
|
||||
SSLParameters parameters = new SSLParameters();
|
||||
parameters.setProtocols(
|
||||
tlsMaterial
|
||||
.map(SslContextMaterial::protocolArray)
|
||||
.orElseGet(() -> profile.tls().protocols().toArray(String[]::new)));
|
||||
// Endpoint identification is set explicitly: the JDK default for a raw SSLParameters
|
||||
// instance is "no hostname check", which design §21.2 forbids.
|
||||
parameters.setEndpointIdentificationAlgorithm("HTTPS");
|
||||
// ALPN is declared explicitly. The JDK client also derives it from the requested version,
|
||||
// so this is not load-bearing today (verified by NegotiatedProtocolContractTest, which
|
||||
// still passes without it) — it makes the advertised protocol set a property of the
|
||||
// profile rather than of a JDK internal.
|
||||
parameters.setApplicationProtocols(
|
||||
profile.protocols().contains(HttpProtocol.HTTP_2)
|
||||
? new String[] {"h2", "http/1.1"}
|
||||
: new String[] {"http/1.1"});
|
||||
tlsMaterial.ifPresent(material -> builder.sslContext(material.sslContext()));
|
||||
builder.sslParameters(parameters);
|
||||
|
||||
if (profile.proxy().enabled()) {
|
||||
builder.proxy(
|
||||
ProxySelector.of(new InetSocketAddress(profile.proxy().host(), profile.proxy().port())));
|
||||
} else {
|
||||
builder.proxy(ProxySelector.of(null));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.jdk;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.net.ConnectException;
|
||||
import java.net.NoRouteToHostException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.net.http.HttpConnectTimeoutException;
|
||||
import java.net.http.HttpTimeoutException;
|
||||
import java.security.cert.CertificateException;
|
||||
import javax.net.ssl.SSLException;
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import javax.net.ssl.SSLPeerUnverifiedException;
|
||||
|
||||
/**
|
||||
* Maps JDK HttpClient failures onto stable evidence (design §13.3, §13.5).
|
||||
*
|
||||
* <p>The JDK exposes fewer stage-specific exceptions than Apache, so anything other than a proven
|
||||
* pre-send failure stays conservative.
|
||||
*/
|
||||
public final class JdkFailureClassifier implements TransportFailureClassifier {
|
||||
|
||||
@Override
|
||||
public TransportFailure classify(Throwable failure, AttemptStage lastObservedStage) {
|
||||
// Spring and the JDK both wrap engine exceptions; the chain is inspected so a wrapped
|
||||
// ConnectException still classifies as provably NOT_SENT.
|
||||
for (Throwable cause : chain(failure)) {
|
||||
TransportFailure recognized = recognize(cause, lastObservedStage);
|
||||
if (recognized != null) {
|
||||
return recognized;
|
||||
}
|
||||
}
|
||||
return fallback(lastObservedStage);
|
||||
}
|
||||
|
||||
private TransportFailure recognize(Throwable cause, AttemptStage lastObservedStage) {
|
||||
if (cause instanceof HttpConnectTimeoutException
|
||||
|| cause instanceof ConnectException
|
||||
|| cause instanceof NoRouteToHostException) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.CONNECT, FailureCategory.CONNECT, "CONNECT_FAILED");
|
||||
}
|
||||
if (cause instanceof UnknownHostException) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.DNS, FailureCategory.DNS, "DNS_RESOLUTION_FAILED");
|
||||
}
|
||||
if (cause instanceof SSLPeerUnverifiedException || cause instanceof CertificateException) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_TRUST_FAILED");
|
||||
}
|
||||
if (cause instanceof SSLHandshakeException) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_HANDSHAKE_FAILED");
|
||||
}
|
||||
if (cause instanceof SSLException
|
||||
&& !lastObservedStage.isAtLeast(AttemptStage.REQUEST_HEADERS)) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_TRANSIENT_FAILURE");
|
||||
}
|
||||
if (cause instanceof InterruptedIOException
|
||||
&& !(cause instanceof HttpTimeoutException)
|
||||
&& !(cause instanceof SocketTimeoutException)) {
|
||||
// Cancellation, not a timeout. Classifying it as a timeout let the retry engine reissue a
|
||||
// request the caller had just cancelled, and dropped the interrupt so the thread could not
|
||||
// see its own cancellation either.
|
||||
Thread.currentThread().interrupt();
|
||||
if (lastObservedStage.provesNotSent()) {
|
||||
return TransportFailure.notSent(
|
||||
lastObservedStage, FailureCategory.CANCELLED, "ATTEMPT_INTERRUPTED");
|
||||
}
|
||||
return TransportFailure.sentNoResponse(
|
||||
lastObservedStage, FailureCategory.CANCELLED, "ATTEMPT_INTERRUPTED");
|
||||
}
|
||||
if (cause instanceof HttpTimeoutException || cause instanceof SocketTimeoutException) {
|
||||
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
|
||||
return new TransportFailure(
|
||||
lastObservedStage,
|
||||
ExecutionEvidence.PARTIAL_RESPONSE,
|
||||
FailureCategory.RESPONSE_TIMEOUT,
|
||||
"RESPONSE_BODY_TIMEOUT");
|
||||
}
|
||||
return TransportFailure.sentNoResponse(
|
||||
AttemptStage.RESPONSE_HEADERS,
|
||||
FailureCategory.RESPONSE_TIMEOUT,
|
||||
"RESPONSE_HEADER_TIMEOUT");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private TransportFailure fallback(AttemptStage lastObservedStage) {
|
||||
if (lastObservedStage.provesNotSent()) {
|
||||
return TransportFailure.notSent(
|
||||
lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
|
||||
}
|
||||
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
|
||||
return new TransportFailure(
|
||||
lastObservedStage,
|
||||
ExecutionEvidence.PARTIAL_RESPONSE,
|
||||
FailureCategory.RESPONSE_TRUNCATED,
|
||||
"TRANSPORT_FAILURE");
|
||||
}
|
||||
return TransportFailure.sentNoResponse(
|
||||
lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
|
||||
}
|
||||
|
||||
private java.util.List<Throwable> chain(Throwable failure) {
|
||||
java.util.List<Throwable> chain = new java.util.ArrayList<>();
|
||||
Throwable current = failure;
|
||||
while (current != null && !chain.contains(current)) {
|
||||
chain.add(current);
|
||||
current = current.getCause();
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
}
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.profile;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Fail-closed startup validation for a Named Client Profile (design §11.2, §30.1).
|
||||
*
|
||||
* <p>Every guard in the design has exactly one stable violation code here. The result is sorted so
|
||||
* a configuration error reports deterministically across runs and machines.
|
||||
*/
|
||||
public final class ClientProfileValidator {
|
||||
|
||||
public List<ClientProfileViolation> validate(
|
||||
ClientProfile profile, RuntimeEnvironment environment) {
|
||||
List<ClientProfileViolation> violations = new ArrayList<>();
|
||||
validateTarget(profile, environment, violations);
|
||||
validateRedirect(profile, violations);
|
||||
validateTimeouts(profile, violations);
|
||||
validateUnsupportedSettings(profile, violations);
|
||||
validateLimits(profile, violations);
|
||||
validateTransportCapability(profile, environment, violations);
|
||||
validateCredentials(profile, violations);
|
||||
validateTls(profile, environment, violations);
|
||||
validateRetry(profile, violations);
|
||||
validateObservability(profile, environment, violations);
|
||||
validateProductionCompleteness(profile, environment, violations);
|
||||
violations.sort(ClientProfileViolation::compareTo);
|
||||
return List.copyOf(violations);
|
||||
}
|
||||
|
||||
private void validateTarget(
|
||||
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
|
||||
URI baseUrl = profile.baseUrl();
|
||||
if (profile.trusted() && baseUrl == null) {
|
||||
out.add(violation("TRUSTED_BASE_URL_REQUIRED", profile, "base-url"));
|
||||
return;
|
||||
}
|
||||
if (baseUrl == null) {
|
||||
return;
|
||||
}
|
||||
if (baseUrl.getUserInfo() != null) {
|
||||
out.add(violation("BASE_URL_USERINFO_FORBIDDEN", profile, "base-url"));
|
||||
}
|
||||
if (baseUrl.getRawQuery() != null) {
|
||||
out.add(violation("BASE_URL_QUERY_FORBIDDEN", profile, "base-url"));
|
||||
}
|
||||
String scheme = baseUrl.getScheme() == null ? "" : baseUrl.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!"https".equals(scheme) && environment.production()) {
|
||||
out.add(violation("PLAINTEXT_PRODUCTION_TARGET", profile, "base-url"));
|
||||
}
|
||||
String host = baseUrl.getHost();
|
||||
if (host != null
|
||||
&& !profile.allowedHosts().isEmpty()
|
||||
&& !profile.allowedHosts().contains(host.toLowerCase(Locale.ROOT))) {
|
||||
out.add(violation("ALLOWED_HOST_MISMATCH", profile, "allowed-hosts"));
|
||||
}
|
||||
int port = baseUrl.getPort() >= 0 ? baseUrl.getPort() : defaultPort(scheme);
|
||||
if (!profile.allowedPorts().isEmpty() && !profile.allowedPorts().contains(port)) {
|
||||
out.add(violation("ALLOWED_PORT_MISMATCH", profile, "allowed-ports"));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRedirect(ClientProfile profile, List<ClientProfileViolation> out) {
|
||||
RedirectSettings redirect = profile.redirect();
|
||||
if (!redirect.enabled()) {
|
||||
return;
|
||||
}
|
||||
if (redirect.maxHops() == 0) {
|
||||
out.add(violation("REDIRECT_POLICY_INVALID", profile, "redirect.max-hops"));
|
||||
}
|
||||
if (redirect.allowCrossOrigin()
|
||||
&& profile.authentication().type().attachesDefaultCredential()) {
|
||||
out.add(violation("REDIRECT_CROSS_ORIGIN_CREDENTIAL_POLICY_REQUIRED", profile, "redirect"));
|
||||
}
|
||||
if (profile.api() == ClientApiType.WEB_CLIENT) {
|
||||
// Engine-level redirect following is disabled on every transport, and only the blocking stack
|
||||
// has a coordinator to follow hops itself with per-hop re-validation. A reactive profile that
|
||||
// enabled redirects therefore did not follow them: the caller received the 302 as an ordinary
|
||||
// response and read its empty body as the answer. Refusing is the honest outcome until the
|
||||
// reactive coordinator exists — a configured guarantee that silently does nothing is worse
|
||||
// than one the platform declines to offer.
|
||||
out.add(violation("REACTIVE_REDIRECT_UNSUPPORTED", profile, "redirect.enabled"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings that bind but reach no transport are refused rather than ignored.
|
||||
*
|
||||
* <p>Three of them had no consumer anywhere: {@code timeout.dns}, {@code
|
||||
* proxy.credential-provider} and {@code proxy.import-ambient-no-proxy}. An operator who set a DNS
|
||||
* timeout believed resolution was bounded and it was not; one who named a proxy credential
|
||||
* provider believed the proxy was authenticated and it was not. Neither Apache nor the JDK client
|
||||
* exposes a DNS-resolution timeout, and no proxy credential path exists in this platform yet, so
|
||||
* the honest position is to refuse a value the platform cannot honour instead of accepting it and
|
||||
* doing nothing.
|
||||
*
|
||||
* <p>The default values are accepted, so an operator who never touched these settings is
|
||||
* unaffected — only a deliberate, unmet request fails.
|
||||
*/
|
||||
private void validateUnsupportedSettings(
|
||||
ClientProfile profile, List<ClientProfileViolation> out) {
|
||||
if (!TimeoutSettings.DEFAULT_DNS.equals(profile.timeout().dns())) {
|
||||
out.add(violation("DNS_TIMEOUT_UNSUPPORTED", profile, "timeout.dns"));
|
||||
}
|
||||
if (profile.proxy().credentialProvider().isPresent()) {
|
||||
out.add(violation("PROXY_CREDENTIAL_UNSUPPORTED", profile, "proxy.credential-provider"));
|
||||
}
|
||||
if (profile.proxy().importAmbientNoProxy()) {
|
||||
out.add(
|
||||
violation(
|
||||
"PROXY_AMBIENT_NO_PROXY_UNSUPPORTED", profile, "proxy.import-ambient-no-proxy"));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateTimeouts(ClientProfile profile, List<ClientProfileViolation> out) {
|
||||
TimeoutSettings timeout = profile.timeout();
|
||||
if (timeout.totalCall().compareTo(timeout.connect()) < 0
|
||||
|| timeout.totalCall().compareTo(timeout.responseHeader()) < 0) {
|
||||
out.add(violation("INVALID_TIMEOUT_BUDGET", profile, "timeout.total-call"));
|
||||
}
|
||||
if (timeout.totalCall().isZero() || timeout.totalCall().isNegative()) {
|
||||
out.add(violation("INVALID_TIMEOUT_BUDGET", profile, "timeout.total-call"));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateLimits(ClientProfile profile, List<ClientProfileViolation> out) {
|
||||
if (profile.response().maxDecodedBytes() > ResponseLimits.GLOBAL_HARD_MAXIMUM_BYTES) {
|
||||
out.add(violation("RESPONSE_HARD_MAXIMUM_EXCEEDED", profile, "response.max-decoded-bytes"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observability switches that describe an unsafe intent are refused in production.
|
||||
*
|
||||
* <p>Both settings were bindable and inert: nothing read {@code full-url-recording}, and {@code
|
||||
* body-logging} reached only the actuator report. Leaving them that way is the worse of the two
|
||||
* failure modes — an operator who set them believed the platform was recording full URLs or
|
||||
* bodies, and an operator who left them false had no assurance that it was not. Recording an
|
||||
* expanded URL puts path identifiers and query strings into unbounded metric tags and logs;
|
||||
* recording bodies puts someone else's data there. Neither belongs in production, so the intent
|
||||
* is representable and rejectable rather than silently ignored.
|
||||
*/
|
||||
private void validateObservability(
|
||||
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
|
||||
if (!environment.production()) {
|
||||
return;
|
||||
}
|
||||
if (profile.observability().fullUrlRecording()) {
|
||||
out.add(
|
||||
violation("FULL_URL_RECORDING_FORBIDDEN", profile, "observability.full-url-recording"));
|
||||
}
|
||||
if (profile.observability().bodyLogging()) {
|
||||
out.add(violation("BODY_LOGGING_FORBIDDEN", profile, "observability.body-logging"));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateTransportCapability(
|
||||
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
|
||||
if (profile.transport() == TransportType.SIMPLE && environment.production()) {
|
||||
out.add(violation("PRODUCTION_SIMPLE_FACTORY_FORBIDDEN", profile, "transport"));
|
||||
}
|
||||
if (profile.transport() == TransportType.JDK
|
||||
&& (profile.pool().requiresRoutePool() || profile.pool().requiresBoundedPendingQueue())) {
|
||||
out.add(violation("JDK_FINE_GRAINED_POOL_UNSUPPORTED", profile, "transport"));
|
||||
}
|
||||
if (profile.protocols().contains(HttpProtocol.HTTP_3)
|
||||
&& profile.experimentalAcknowledgement().isEmpty()) {
|
||||
out.add(violation("HTTP3_STABLE_FORBIDDEN", profile, "protocols"));
|
||||
}
|
||||
if (profile.mode() == ClientMode.DYNAMIC
|
||||
&& (profile.transport() == TransportType.JDK
|
||||
|| profile.transport() == TransportType.JETTY)) {
|
||||
out.add(violation("DYNAMIC_TARGET_TRANSPORT_UNSUPPORTED", profile, "transport"));
|
||||
}
|
||||
if (profile.mode() == ClientMode.DYNAMIC && profile.baseUrl() == null) {
|
||||
// A DYNAMIC profile takes its destination per call, but the runtime factory still builds its
|
||||
// client from a base URL and called toString() on it unconditionally. The profile was
|
||||
// accepted at startup and produced a NullPointerException while assembling the runtime.
|
||||
out.add(violation("DYNAMIC_BASE_URL_REQUIRED", profile, "base-url"));
|
||||
}
|
||||
if (ProtocolIntent.of(profile.protocols()).requiresHttp2()
|
||||
&& profile.transport() != TransportType.REACTOR_NETTY) {
|
||||
// Only Reactor Netty can be configured to offer H2 and nothing else. The JDK client treats
|
||||
// HTTP_2 as a preference and silently negotiates HTTP/1.1; Apache's classic client is
|
||||
// HTTP/1.1
|
||||
// only. A profile that requires H2 on either of them was getting HTTP/1.1 with no signal.
|
||||
out.add(violation("HTTP2_REQUIRED_TRANSPORT_UNSUPPORTED", profile, "protocols"));
|
||||
}
|
||||
if (profile.pool().maxConnectionsPerRoute() > profile.pool().maxTotalConnections()) {
|
||||
// A per-route ceiling above the total is incoherent, and on Reactor — where the per-route
|
||||
// knob is the only one that exists — it silently becomes the effective limit.
|
||||
out.add(violation("POOL_ROUTE_EXCEEDS_TOTAL", profile, "pool.max-connections-per-route"));
|
||||
}
|
||||
if (profile.tls().protocols().isEmpty()) {
|
||||
// An empty set passed validation and then let the JVM pick, so a profile that meant to pin a
|
||||
// TLS floor got whatever the platform default happened to be — including TLS 1.2 on a profile
|
||||
// whose operator had deliberately emptied the list to "tighten" it.
|
||||
out.add(violation("TLS_PROTOCOL_SET_REQUIRED", profile, "tls.protocols"));
|
||||
}
|
||||
if (profile.mode() == ClientMode.DYNAMIC && profile.proxy().enabled()) {
|
||||
// A forward proxy re-resolves the hostname on its own side, so the addresses this platform
|
||||
// validated and pinned are not the addresses the connection reaches. The SSRF defence would
|
||||
// be present, correct, and bypassed.
|
||||
out.add(violation("DYNAMIC_TARGET_PROXY_UNSUPPORTED", profile, "proxy.enabled"));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCredentials(ClientProfile profile, List<ClientProfileViolation> out) {
|
||||
if (profile.mode() == ClientMode.DYNAMIC
|
||||
&& profile.authentication().type().attachesDefaultCredential()) {
|
||||
out.add(violation("DYNAMIC_DEFAULT_CREDENTIAL_FORBIDDEN", profile, "authentication.type"));
|
||||
}
|
||||
if (profile.authentication().type() == AuthenticationType.OAUTH2_CLIENT_CREDENTIALS
|
||||
&& profile.authentication().registrationId().isEmpty()) {
|
||||
out.add(violation("OAUTH2_REGISTRATION_REQUIRED", profile, "authentication.registration-id"));
|
||||
}
|
||||
if (profile.authentication().type() == AuthenticationType.API_KEY_HEADER
|
||||
&& profile.authentication().headerName().isEmpty()) {
|
||||
out.add(violation("API_KEY_HEADER_NAME_REQUIRED", profile, "authentication.header-name"));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateTls(
|
||||
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
|
||||
TlsSettings tls = profile.tls();
|
||||
if (tls.trustAll()) {
|
||||
out.add(violation("TRUST_ALL_FORBIDDEN", profile, "tls.trust-all"));
|
||||
}
|
||||
if (!tls.hostnameVerification()) {
|
||||
out.add(violation("HOSTNAME_VERIFICATION_REQUIRED", profile, "tls.hostname-verification"));
|
||||
}
|
||||
if (tls.allowPlainHttp() && environment.production()) {
|
||||
out.add(violation("PLAINTEXT_FALLBACK_FORBIDDEN", profile, "tls.allow-plain-http"));
|
||||
}
|
||||
boolean unsupportedProtocol =
|
||||
tls.protocols().stream()
|
||||
.anyMatch(value -> !"TLSv1.2".equals(value) && !"TLSv1.3".equals(value));
|
||||
if (unsupportedProtocol) {
|
||||
out.add(violation("TLS_PROTOCOL_FORBIDDEN", profile, "tls.protocols"));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateRetry(ClientProfile profile, List<ClientProfileViolation> out) {
|
||||
if (profile.retry().enabled()
|
||||
&& profile.retry().baseBackoff().isZero()
|
||||
&& profile.retry().jitter() == JitterStrategy.NONE) {
|
||||
out.add(violation("RETRY_BACKOFF_REQUIRED", profile, "retry.base-backoff"));
|
||||
}
|
||||
// `policy` and `max-attempts` must agree. Nothing on the execution path read `policy` — only
|
||||
// `max-attempts` decided whether a call retried — so the actuator could report
|
||||
// `retryPolicy: none` for a profile that was retrying three times, and a profile named after a
|
||||
// policy could have retry switched off by a `max-attempts` nobody re-read. A displayed policy
|
||||
// that cannot contradict behaviour is worth more than one that describes an intention.
|
||||
boolean declaredNone = "none".equalsIgnoreCase(profile.retry().policy());
|
||||
if (declaredNone && profile.retry().enabled()) {
|
||||
out.add(violation("RETRY_POLICY_CONTRADICTS_ATTEMPTS", profile, "retry.policy"));
|
||||
}
|
||||
if (!declaredNone && !profile.retry().enabled()) {
|
||||
out.add(violation("RETRY_POLICY_CONTRADICTS_ATTEMPTS", profile, "retry.max-attempts"));
|
||||
}
|
||||
}
|
||||
|
||||
private void validateProductionCompleteness(
|
||||
ClientProfile profile, RuntimeEnvironment environment, List<ClientProfileViolation> out) {
|
||||
if (!environment.production()) {
|
||||
return;
|
||||
}
|
||||
if (profile.trusted() && profile.allowedHosts().isEmpty()) {
|
||||
out.add(violation("MISSING_PRODUCTION_SETTING", profile, "allowed-hosts"));
|
||||
}
|
||||
if (profile.request().maxBodyBytes() == 0) {
|
||||
out.add(violation("MISSING_PRODUCTION_SETTING", profile, "request.max-body-bytes"));
|
||||
}
|
||||
if (profile.tls().profileId().isEmpty()) {
|
||||
out.add(violation("MISSING_PRODUCTION_SETTING", profile, "tls.profile-id"));
|
||||
}
|
||||
}
|
||||
|
||||
private static int defaultPort(String scheme) {
|
||||
return "http".equals(scheme) ? 80 : 443;
|
||||
}
|
||||
|
||||
private static ClientProfileViolation violation(
|
||||
String code, ClientProfile profile, String setting) {
|
||||
return new ClientProfileViolation(
|
||||
code, "profile=" + profile.name().value() + " setting=" + setting);
|
||||
}
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.profile;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Atomic pointer from profile name to its current runtime generation (design §7.2).
|
||||
*
|
||||
* <p>A swap publishes the replacement first and drains the predecessor afterwards, so a rotation is
|
||||
* never observable as a gap. The single scheduled executor exists only to enforce drain deadlines
|
||||
* and is created lazily; it is shut down with the registry so no thread outlives it.
|
||||
*/
|
||||
public final class ClientRuntimeRegistry implements AutoCloseable {
|
||||
|
||||
private static final long SHUTDOWN_AWAIT_MILLIS = 5_000L;
|
||||
|
||||
private final ConcurrentMap<ClientProfileName, AtomicReference<ClientRuntime>> runtimes =
|
||||
new ConcurrentHashMap<>();
|
||||
private final AtomicReference<ScheduledExecutorService> drainScheduler = new AtomicReference<>();
|
||||
|
||||
/**
|
||||
* Generations that have been replaced but are not yet closed.
|
||||
*
|
||||
* <p>Held so shutdown can reach them. A rotation moved the old generation out of {@code runtimes}
|
||||
* and left it owned only by a scheduled drain task, so a registry that closed before that task
|
||||
* fired leaked the whole generation — and the resource-bound suite could not see it, because
|
||||
* nothing enumerated it.
|
||||
*/
|
||||
private final Set<ClientRuntime> retired = java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
|
||||
public ClientRuntimeRegistry(Map<ClientProfileName, ClientRuntime> initial) {
|
||||
Objects.requireNonNull(initial, "initial runtimes");
|
||||
initial.forEach((name, runtime) -> runtimes.put(name, new AtomicReference<>(runtime)));
|
||||
}
|
||||
|
||||
public static ClientRuntimeRegistry empty() {
|
||||
return new ClientRuntimeRegistry(Map.of());
|
||||
}
|
||||
|
||||
public Set<ClientProfileName> names() {
|
||||
return Set.copyOf(runtimes.keySet());
|
||||
}
|
||||
|
||||
public void register(ClientRuntime runtime) {
|
||||
Objects.requireNonNull(runtime, "runtime");
|
||||
runtimes.put(runtime.name(), new AtomicReference<>(runtime));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves the current generation. Retries against the newly published generation when the
|
||||
* observed one began draining between the read and the reservation.
|
||||
*/
|
||||
public ClientRuntimeLease acquire(ClientProfileName name) {
|
||||
AtomicReference<ClientRuntime> holder = holder(name);
|
||||
while (true) {
|
||||
ClientRuntime runtime = holder.get();
|
||||
if (runtime.tryAcquire()) {
|
||||
return new ClientRuntimeLease(runtime, runtime::release);
|
||||
}
|
||||
if (holder.get() == runtime) {
|
||||
throw new IllegalStateException("http client runtime is shutting down: " + name.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ClientRuntime current(ClientProfileName name) {
|
||||
return holder(name).get();
|
||||
}
|
||||
|
||||
public boolean contains(ClientProfileName name) {
|
||||
return runtimes.containsKey(name);
|
||||
}
|
||||
|
||||
/** Publishes {@code replacement} and drains the previous generation (design §7.2 steps 3-6). */
|
||||
public void swap(ClientProfileName name, ClientRuntime replacement, Duration drainTimeout) {
|
||||
Objects.requireNonNull(replacement, "replacement runtime");
|
||||
Objects.requireNonNull(drainTimeout, "drain timeout");
|
||||
ClientRuntime previous = holder(name).getAndSet(replacement);
|
||||
if (previous == replacement) {
|
||||
return;
|
||||
}
|
||||
// Tracked until it is actually closed. A retired generation that was still draining when the
|
||||
// registry shut down was reachable from nothing: close() walked only the current generations,
|
||||
// so its pool, its connections and its drain task outlived the registry that created them.
|
||||
retired.add(previous);
|
||||
previous.beginDrain(drainTimeout);
|
||||
if (previous.state() != ClientRuntimeState.CLOSED && !drainTimeout.isZero()) {
|
||||
ScheduledFuture<?> unusedDrainDeadline =
|
||||
scheduler()
|
||||
.schedule(
|
||||
() -> {
|
||||
try {
|
||||
previous.forceClose();
|
||||
} finally {
|
||||
retired.remove(previous);
|
||||
}
|
||||
},
|
||||
drainTimeout.toMillis(),
|
||||
TimeUnit.MILLISECONDS);
|
||||
assert unusedDrainDeadline != null;
|
||||
} else {
|
||||
retired.remove(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
List<ClientRuntime> all = new ArrayList<>();
|
||||
runtimes.values().forEach(holder -> all.add(holder.get()));
|
||||
// Retired-but-still-draining generations are closed too; they used to survive registry
|
||||
// shutdown entirely.
|
||||
all.addAll(retired);
|
||||
// Every runtime is closed even when one refuses. forEach stopped at the first exception, so a
|
||||
// single misbehaving pool left every remaining connection, thread and socket open — shutdown
|
||||
// leaked more the worse the failure was.
|
||||
RuntimeException firstFailure = null;
|
||||
for (ClientRuntime runtime : all) {
|
||||
try {
|
||||
runtime.forceClose();
|
||||
} catch (RuntimeException failure) {
|
||||
if (firstFailure == null) {
|
||||
firstFailure = failure;
|
||||
} else {
|
||||
firstFailure.addSuppressed(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
retired.clear();
|
||||
runtimes.clear();
|
||||
if (firstFailure != null) {
|
||||
throw firstFailure;
|
||||
}
|
||||
ScheduledExecutorService scheduler = drainScheduler.getAndSet(null);
|
||||
if (scheduler != null) {
|
||||
// Await termination: a registry that returns while its drain thread is still alive would
|
||||
// leak a thread per rotation cycle, which the resource-bound suite exists to catch.
|
||||
scheduler.shutdownNow();
|
||||
try {
|
||||
if (!scheduler.awaitTermination(SHUTDOWN_AWAIT_MILLIS, TimeUnit.MILLISECONDS)) {
|
||||
throw new IllegalStateException("http client drain scheduler did not terminate");
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private AtomicReference<ClientRuntime> holder(ClientProfileName name) {
|
||||
AtomicReference<ClientRuntime> holder = runtimes.get(name);
|
||||
if (holder == null) {
|
||||
throw new NoSuchElementException("unregistered http client profile: " + name.value());
|
||||
}
|
||||
return holder;
|
||||
}
|
||||
|
||||
private ScheduledExecutorService scheduler() {
|
||||
ScheduledExecutorService existing = drainScheduler.get();
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
ScheduledExecutorService created =
|
||||
Executors.newSingleThreadScheduledExecutor(
|
||||
runnable -> {
|
||||
Thread thread = new Thread(runnable, "httpclient-runtime-drain");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
if (drainScheduler.compareAndSet(null, created)) {
|
||||
return created;
|
||||
}
|
||||
created.shutdownNow();
|
||||
return drainScheduler.get();
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.profile;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What a profile's declared protocol set actually asks for (design §6.3, §24).
|
||||
*
|
||||
* <p>A bare set of protocols does not say whether HTTP/2 is a preference or a requirement, and
|
||||
* every transport resolved that ambiguity in the direction that could not fail. The JDK client
|
||||
* treats {@code HTTP_2} as "try H2, fall back to H1"; Reactor Netty was configured with {@code {H2,
|
||||
* HTTP11}} whenever H2 appeared at all. A profile that declared only {@code HTTP_2} — the way an
|
||||
* operator states a requirement — therefore ran happily over HTTP/1.1, and nothing anywhere said
|
||||
* so. gRPC-style upstreams, header-compression assumptions and concurrency budgets all quietly
|
||||
* changed meaning.
|
||||
*
|
||||
* <p>Naming the intent makes the requirement expressible, and lets a transport that cannot honour
|
||||
* it refuse at startup instead of downgrading at runtime.
|
||||
*/
|
||||
public enum ProtocolIntent {
|
||||
|
||||
/** Only HTTP/1.1 is acceptable. */
|
||||
H1_ONLY,
|
||||
|
||||
/** Prefer HTTP/2, accept HTTP/1.1. The safe default for a general-purpose upstream. */
|
||||
NEGOTIATE_H2_H1,
|
||||
|
||||
/** HTTP/2 is required; falling back to HTTP/1.1 is a failure, not a degradation. */
|
||||
H2_REQUIRED,
|
||||
|
||||
/** Experimental HTTP/3, gated by the acknowledgement and its own transport. */
|
||||
H3_EXPERIMENTAL;
|
||||
|
||||
/**
|
||||
* Derives the intent a declared protocol set expresses.
|
||||
*
|
||||
* @param protocols the profile's declared protocols
|
||||
* @return the intent; declaring HTTP/2 alone means it is required
|
||||
*/
|
||||
public static ProtocolIntent of(Set<HttpProtocol> protocols) {
|
||||
if (protocols.contains(HttpProtocol.HTTP_3)) {
|
||||
return H3_EXPERIMENTAL;
|
||||
}
|
||||
boolean h2 = protocols.contains(HttpProtocol.HTTP_2);
|
||||
boolean h1 = protocols.contains(HttpProtocol.HTTP_1_1);
|
||||
if (h2 && h1) {
|
||||
return NEGOTIATE_H2_H1;
|
||||
}
|
||||
if (h2) {
|
||||
return H2_REQUIRED;
|
||||
}
|
||||
return H1_ONLY;
|
||||
}
|
||||
|
||||
public boolean requiresHttp2() {
|
||||
return this == H2_REQUIRED;
|
||||
}
|
||||
|
||||
public boolean allowsHttp2() {
|
||||
return this == NEGOTIATE_H2_H1 || this == H2_REQUIRED;
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.profile;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Stage timeouts and the total call budget (design §15.1).
|
||||
*
|
||||
* <p>{@link #totalCall()} is the upper budget for everything, including pool acquire and retry
|
||||
* backoff. {@link #streamingIdle()} is deliberately separate so a long-lived SSE stream is not
|
||||
* killed by the request-shaped total budget (design §15.3).
|
||||
*/
|
||||
public record TimeoutSettings(
|
||||
// Hostname resolution budget. Currently unenforced: neither the Apache classic client nor the
|
||||
// JDK client exposes a DNS resolution timeout, so the platform refuses a non-default value
|
||||
// rather than accepting one it cannot honour. See DNS_TIMEOUT_UNSUPPORTED.
|
||||
Duration dns,
|
||||
Duration connect,
|
||||
Duration tlsHandshake,
|
||||
Duration proxyConnect,
|
||||
Duration requestWriteIdle,
|
||||
Duration responseHeader,
|
||||
Duration readIdle,
|
||||
Duration totalCall,
|
||||
Duration streamingIdle) {
|
||||
|
||||
/**
|
||||
* The shipped {@code timeout.dns} default.
|
||||
*
|
||||
* <p>Named so the validator can tell "the operator left this alone" from "the operator asked for
|
||||
* a DNS budget the platform cannot deliver". Only the second is refused.
|
||||
*/
|
||||
public static final Duration DEFAULT_DNS = Duration.ofMillis(300);
|
||||
|
||||
public TimeoutSettings {
|
||||
Objects.requireNonNull(dns, "dns timeout");
|
||||
Objects.requireNonNull(connect, "connect timeout");
|
||||
Objects.requireNonNull(tlsHandshake, "tls handshake timeout");
|
||||
Objects.requireNonNull(proxyConnect, "proxy connect timeout");
|
||||
Objects.requireNonNull(requestWriteIdle, "request write idle timeout");
|
||||
Objects.requireNonNull(responseHeader, "response header timeout");
|
||||
Objects.requireNonNull(readIdle, "read idle timeout");
|
||||
Objects.requireNonNull(totalCall, "total call timeout");
|
||||
Objects.requireNonNull(streamingIdle, "streaming idle timeout");
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.reactor;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import java.util.Objects;
|
||||
import reactor.netty.resources.ConnectionProvider;
|
||||
|
||||
/**
|
||||
* Creates a connection pool scoped to one profile (design §13.6).
|
||||
*
|
||||
* <p>A shared global pool would let one slow upstream starve every other one, so each profile gets
|
||||
* its own named provider with its own limits and eviction.
|
||||
*/
|
||||
public final class ReactorConnectionProviderFactory {
|
||||
|
||||
/**
|
||||
* Builds the pool, mapping each setting onto the Reactor knob that means the same thing.
|
||||
*
|
||||
* <p>{@code ConnectionProvider.maxConnections} is a <em>per-remote-host</em> ceiling, and it was
|
||||
* being handed {@code maxTotalConnections}. For a trusted profile with one base URL the two
|
||||
* coincide, so nothing looked wrong; for a dynamic profile talking to many hosts it meant every
|
||||
* destination independently received the budget intended for all of them combined, and {@code
|
||||
* maxConnectionsPerRoute} — the setting that actually describes this limit — was ignored
|
||||
* entirely.
|
||||
*
|
||||
* <p>Reactor Netty has no cross-destination ceiling to map {@code maxTotalConnections} onto. That
|
||||
* is a real gap rather than something to paper over: the validator requires per-route not to
|
||||
* exceed the total, so the configured numbers stay coherent, and a dynamic profile's true global
|
||||
* bound comes from the platform's own admission limiter.
|
||||
*/
|
||||
public ConnectionProvider create(ClientProfile profile) {
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
return ConnectionProvider.builder(profile.name().value())
|
||||
.maxConnections(profile.pool().maxConnectionsPerRoute())
|
||||
.pendingAcquireMaxCount(profile.pool().maxPendingAcquires())
|
||||
.pendingAcquireTimeout(profile.pool().pendingAcquireTimeout())
|
||||
.maxIdleTime(profile.pool().maxIdleTime())
|
||||
.maxLifeTime(profile.pool().maxLifeTime())
|
||||
.evictInBackground(profile.pool().evictionInterval())
|
||||
.metrics(true)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.reactor;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
|
||||
import java.net.ConnectException;
|
||||
import java.net.NoRouteToHostException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import javax.net.ssl.SSLException;
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
import javax.net.ssl.SSLPeerUnverifiedException;
|
||||
|
||||
/**
|
||||
* Maps Reactor Netty failures onto the same stable evidence the blocking transports produce (design
|
||||
* §13.3).
|
||||
*
|
||||
* <p>Reactor wraps causes, so the chain is unwrapped before classification; a cancellation is
|
||||
* reported as such rather than as a timeout, because the two have different retry meaning.
|
||||
*/
|
||||
public final class ReactorFailureClassifier implements TransportFailureClassifier {
|
||||
|
||||
@Override
|
||||
public TransportFailure classify(Throwable failure, AttemptStage lastObservedStage) {
|
||||
// Reactor wraps causes several layers deep; the whole chain is inspected so a wrapped
|
||||
// ConnectException is still recognised as provably NOT_SENT.
|
||||
for (Throwable cause : chain(failure)) {
|
||||
TransportFailure recognized = recognize(cause, lastObservedStage);
|
||||
if (recognized != null) {
|
||||
return recognized;
|
||||
}
|
||||
}
|
||||
return fallback(lastObservedStage);
|
||||
}
|
||||
|
||||
private TransportFailure recognize(Throwable cause, AttemptStage lastObservedStage) {
|
||||
if (cause instanceof UnknownHostException) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.DNS, FailureCategory.DNS, "DNS_RESOLUTION_FAILED");
|
||||
}
|
||||
if (cause instanceof ConnectException || cause instanceof NoRouteToHostException) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.CONNECT, FailureCategory.CONNECT, "CONNECT_FAILED");
|
||||
}
|
||||
if (cause instanceof io.netty.handler.ssl.SslHandshakeTimeoutException) {
|
||||
// Checked before the SSLHandshakeException branch below, which it extends. Without this a
|
||||
// handshake that merely ran out of time was classified TLS_PERMANENT — the category that
|
||||
// forbids retry — so a momentarily slow peer produced a hard failure indistinguishable from
|
||||
// an untrusted certificate.
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_HANDSHAKE_TIMEOUT");
|
||||
}
|
||||
if (cause instanceof SSLPeerUnverifiedException || cause instanceof CertificateException) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_TRUST_FAILED");
|
||||
}
|
||||
if (cause instanceof SSLHandshakeException) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_PERMANENT, "TLS_HANDSHAKE_FAILED");
|
||||
}
|
||||
if (cause instanceof SSLException
|
||||
&& !lastObservedStage.isAtLeast(AttemptStage.REQUEST_HEADERS)) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.TLS_HANDSHAKE, FailureCategory.TLS_TRANSIENT, "TLS_TRANSIENT_FAILURE");
|
||||
}
|
||||
if (isPoolAcquireTimeout(cause)) {
|
||||
return TransportFailure.notSent(
|
||||
AttemptStage.POOL_ACQUIRE, FailureCategory.POOL_ACQUIRE_TIMEOUT, "POOL_ACQUIRE_TIMEOUT");
|
||||
}
|
||||
if (cause instanceof java.util.concurrent.CancellationException) {
|
||||
return new TransportFailure(
|
||||
lastObservedStage,
|
||||
lastObservedStage.provesNotSent()
|
||||
? ExecutionEvidence.NOT_SENT
|
||||
: ExecutionEvidence.SENT_NO_RESPONSE,
|
||||
FailureCategory.CANCELLED,
|
||||
"CANCELLED");
|
||||
}
|
||||
if (cause instanceof io.netty.handler.timeout.WriteTimeoutException) {
|
||||
// Netty's timeout hierarchy does not extend java.util.concurrent.TimeoutException, so none of
|
||||
// these reached the branch below — every read or write timeout fell through to the generic
|
||||
// fallback and lost its stage and category.
|
||||
return TransportFailure.sentNoResponse(
|
||||
AttemptStage.REQUEST_BODY, FailureCategory.REQUEST_WRITE, "REQUEST_WRITE_TIMEOUT");
|
||||
}
|
||||
if (cause instanceof TimeoutException
|
||||
|| cause instanceof io.netty.handler.timeout.ReadTimeoutException) {
|
||||
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
|
||||
return new TransportFailure(
|
||||
lastObservedStage,
|
||||
ExecutionEvidence.PARTIAL_RESPONSE,
|
||||
FailureCategory.RESPONSE_TIMEOUT,
|
||||
"RESPONSE_BODY_TIMEOUT");
|
||||
}
|
||||
return TransportFailure.sentNoResponse(
|
||||
AttemptStage.RESPONSE_HEADERS,
|
||||
FailureCategory.RESPONSE_TIMEOUT,
|
||||
"RESPONSE_HEADER_TIMEOUT");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private TransportFailure fallback(AttemptStage lastObservedStage) {
|
||||
if (lastObservedStage.provesNotSent()) {
|
||||
return TransportFailure.notSent(
|
||||
lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
|
||||
}
|
||||
if (lastObservedStage.isAtLeast(AttemptStage.RESPONSE_BODY)) {
|
||||
return new TransportFailure(
|
||||
lastObservedStage,
|
||||
ExecutionEvidence.PARTIAL_RESPONSE,
|
||||
FailureCategory.RESPONSE_TRUNCATED,
|
||||
"TRANSPORT_FAILURE");
|
||||
}
|
||||
return TransportFailure.sentNoResponse(
|
||||
lastObservedStage, FailureCategory.UNKNOWN, "TRANSPORT_FAILURE");
|
||||
}
|
||||
|
||||
private boolean isPoolAcquireTimeout(Throwable cause) {
|
||||
String message = cause.getMessage();
|
||||
return cause.getClass().getName().contains("PoolAcquireTimeoutException")
|
||||
|| (message != null && message.contains("Pool#acquire(Duration)"));
|
||||
}
|
||||
|
||||
private java.util.List<Throwable> chain(Throwable failure) {
|
||||
java.util.List<Throwable> chain = new java.util.ArrayList<>();
|
||||
Throwable current = failure;
|
||||
while (current != null && !chain.contains(current)) {
|
||||
chain.add(current);
|
||||
current = current.getCause();
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.reactor;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
|
||||
import io.netty.channel.ChannelOption;
|
||||
import io.netty.handler.ssl.SslContextBuilder;
|
||||
import io.netty.resolver.AddressResolverGroup;
|
||||
import java.net.InetAddress;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import reactor.netty.http.Http11SslContextSpec;
|
||||
import reactor.netty.http.Http2SslContextSpec;
|
||||
import reactor.netty.http.HttpProtocol;
|
||||
import reactor.netty.http.client.HttpClient;
|
||||
import reactor.netty.resources.ConnectionProvider;
|
||||
import reactor.netty.tcp.SslProvider;
|
||||
import reactor.netty.transport.ProxyProvider;
|
||||
|
||||
/**
|
||||
* Builds the Reactor Netty client for one profile (design §13.6).
|
||||
*
|
||||
* <p>Redirect following is disabled here for the same reason as in the blocking transports: the
|
||||
* platform re-validates each hop and strips credentials across origins, and the engine's follower
|
||||
* does neither.
|
||||
*/
|
||||
public final class ReactorHttpClientFactory {
|
||||
|
||||
public HttpClient create(
|
||||
ClientProfile profile,
|
||||
ConnectionProvider connectionProvider,
|
||||
Optional<SslContextMaterial> tlsMaterial,
|
||||
Optional<Function<String, List<InetAddress>>> approvedAddresses) {
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
Objects.requireNonNull(connectionProvider, "connection provider");
|
||||
|
||||
HttpClient client =
|
||||
HttpClient.create(connectionProvider)
|
||||
.option(
|
||||
ChannelOption.CONNECT_TIMEOUT_MILLIS,
|
||||
Math.toIntExact(profile.timeout().connect().toMillis()))
|
||||
.responseTimeout(profile.timeout().responseHeader())
|
||||
.followRedirect(false)
|
||||
.compress(profile.request().compression())
|
||||
.protocol(protocols(profile))
|
||||
.metrics(true, Function.identity())
|
||||
// request-write-idle and read-idle were bound and then reached no transport at all, so
|
||||
// a request that stalled mid-write hung until the total-call budget expired instead of
|
||||
// failing at the stage that actually stopped. Netty's idle handlers are where those two
|
||||
// settings become real; they are installed per connection so a stall is attributed to
|
||||
// the write or the read rather than to "the call".
|
||||
.doOnConnected(
|
||||
connection ->
|
||||
connection
|
||||
.addHandlerLast(
|
||||
new io.netty.handler.timeout.WriteTimeoutHandler(
|
||||
profile.timeout().requestWriteIdle().toMillis(),
|
||||
java.util.concurrent.TimeUnit.MILLISECONDS))
|
||||
.addHandlerLast(
|
||||
new io.netty.handler.timeout.ReadTimeoutHandler(
|
||||
profile.timeout().readIdle().toMillis(),
|
||||
java.util.concurrent.TimeUnit.MILLISECONDS)));
|
||||
|
||||
if (approvedAddresses.isPresent()) {
|
||||
AddressResolverGroup<?> resolver = new ValidatedAddressResolverGroup(approvedAddresses.get());
|
||||
client = client.resolver(resolver);
|
||||
}
|
||||
// Configured whether or not custom material is supplied. It used to be inside this isPresent,
|
||||
// so a profile declaring `tls.protocols: [TLSv1.3]` against the JVM trust store — the common
|
||||
// case — configured no TLS parameters at all and accepted whatever the platform default
|
||||
// allowed, TLS 1.2 included. A declared floor that only applies alongside a custom truststore
|
||||
// is not a floor.
|
||||
SslProvider.GenericSslContextSpec<SslContextBuilder> contextSpec =
|
||||
sslContextSpec(profile, tlsMaterial.orElse(null));
|
||||
client = client.secure(spec -> spec.sslContext(contextSpec));
|
||||
if (profile.proxy().enabled()) {
|
||||
client =
|
||||
client.proxy(
|
||||
spec ->
|
||||
spec.type(
|
||||
profile.proxy().type()
|
||||
== dev.caskeleton.adapter.outbound.httpclient.profile.ProxyType
|
||||
.SOCKS
|
||||
? ProxyProvider.Proxy.SOCKS5
|
||||
: ProxyProvider.Proxy.HTTP)
|
||||
.host(profile.proxy().host())
|
||||
.port(profile.proxy().port())
|
||||
.connectTimeoutMillis(profile.proxy().connectTimeout().toMillis()));
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* The TLS spec, with or without custom material.
|
||||
*
|
||||
* <p>{@code material} is nullable on purpose: a profile using the JVM trust store still declares
|
||||
* a protocol floor, and that floor has to reach the SSL context.
|
||||
*/
|
||||
private SslProvider.GenericSslContextSpec<SslContextBuilder> sslContextSpec(
|
||||
ClientProfile profile, SslContextMaterial material) {
|
||||
String[] tlsProtocols =
|
||||
material != null
|
||||
? material.protocolArray()
|
||||
: profile.tls().protocols().toArray(String[]::new);
|
||||
java.util.function.Consumer<SslContextBuilder> configurer =
|
||||
builder -> {
|
||||
if (material != null) {
|
||||
material.trustManagerFactory().ifPresent(builder::trustManager);
|
||||
material.keyManagerFactory().ifPresent(builder::keyManager);
|
||||
}
|
||||
builder.protocols(tlsProtocols);
|
||||
};
|
||||
return profile
|
||||
.protocols()
|
||||
.contains(dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol.HTTP_2)
|
||||
? Http2SslContextSpec.forClient().configure(configurer)
|
||||
: Http11SslContextSpec.forClient().configure(configurer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures exactly the protocols the profile asked for.
|
||||
*
|
||||
* <p>{@code {H2, HTTP11}} used to be configured whenever HTTP/2 appeared in the set at all, so a
|
||||
* profile that declared only HTTP/2 — an operator stating a requirement — negotiated HTTP/1.1
|
||||
* against any peer that offered it, silently. An H2-required profile now gets H2 alone, and a
|
||||
* peer that cannot speak it fails the handshake instead of downgrading.
|
||||
*/
|
||||
private HttpProtocol[] protocols(ClientProfile profile) {
|
||||
return switch (dev.caskeleton.adapter.outbound.httpclient.profile.ProtocolIntent.of(
|
||||
profile.protocols())) {
|
||||
case H2_REQUIRED -> new HttpProtocol[] {HttpProtocol.H2};
|
||||
case NEGOTIATE_H2_H1 -> new HttpProtocol[] {HttpProtocol.H2, HttpProtocol.HTTP11};
|
||||
default -> new HttpProtocol[] {HttpProtocol.HTTP11};
|
||||
};
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.reactor;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.SslContextMaterial;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportCapabilities;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportProvider;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportResourceKey;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import java.net.InetAddress;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
import org.springframework.http.client.reactive.ClientHttpConnector;
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||
import reactor.netty.resources.ConnectionProvider;
|
||||
|
||||
/**
|
||||
* Reactive default transport (design D-07, §13.6).
|
||||
*
|
||||
* <p>Returns a Spring {@code ClientHttpConnector}; the Reactor Netty {@code HttpClient} stays
|
||||
* inside this package so application code cannot bypass profile configuration.
|
||||
*/
|
||||
public final class ReactorNettyTransportProvider implements ReactiveTransportProvider {
|
||||
|
||||
private static final TransportId ID = new TransportId("reactor-netty");
|
||||
|
||||
private final ReactorConnectionProviderFactory poolFactory =
|
||||
new ReactorConnectionProviderFactory();
|
||||
private final ReactorHttpClientFactory clientFactory = new ReactorHttpClientFactory();
|
||||
private final ReactorFailureClassifier classifier = new ReactorFailureClassifier();
|
||||
private final Map<TransportResourceKey, ConnectionProvider> pools = new ConcurrentHashMap<>();
|
||||
private final Optional<MeterRegistry> meterRegistry;
|
||||
private final Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver;
|
||||
private final Function<ClientProfile, Optional<Function<String, List<InetAddress>>>>
|
||||
resolverFactory;
|
||||
|
||||
public ReactorNettyTransportProvider() {
|
||||
this(Optional.empty(), profile -> Optional.empty(), profile -> Optional.empty());
|
||||
}
|
||||
|
||||
public ReactorNettyTransportProvider(
|
||||
Optional<MeterRegistry> meterRegistry,
|
||||
Function<ClientProfile, Optional<SslContextMaterial>> tlsMaterialResolver,
|
||||
Function<ClientProfile, Optional<Function<String, List<InetAddress>>>> resolverFactory) {
|
||||
this.meterRegistry = Objects.requireNonNull(meterRegistry, "meter registry");
|
||||
this.tlsMaterialResolver = Objects.requireNonNull(tlsMaterialResolver, "tls material resolver");
|
||||
this.resolverFactory = Objects.requireNonNull(resolverFactory, "dns resolver factory");
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportId id() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReactiveTransportCapabilities capabilities() {
|
||||
return ReactiveTransportCapabilities.reactorNetty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientHttpConnector create(
|
||||
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener) {
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
Objects.requireNonNull(listener, "lifecycle listener");
|
||||
ConnectionProvider pool = poolFactory.create(profile);
|
||||
pools.put(new TransportResourceKey(profile.name(), generation), pool);
|
||||
meterRegistry.ifPresent(
|
||||
registry -> ReactorPoolMetricsBinder.bind(registry, profile.name(), pool));
|
||||
listener.onRuntimeCreated(profile.name(), ID);
|
||||
return new ReactorClientHttpConnector(
|
||||
clientFactory.create(
|
||||
profile, pool, tlsMaterialResolver.apply(profile), resolverFactory.apply(profile)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportFailureClassifier failureClassifier() {
|
||||
return classifier;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close(ClientProfile profile, RuntimeGeneration generation) {
|
||||
ConnectionProvider pool = pools.remove(new TransportResourceKey(profile.name(), generation));
|
||||
if (pool != null) {
|
||||
pool.disposeLater().block(profile.pool().shutdownTimeout());
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.resilience;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpBulkheadRejectedException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpCircuitOpenException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Fixed guard order for every physical attempt (design D-11, §18): Circuit Breaker → Rate Limiter →
|
||||
* Bulkhead → HTTP call, released in reverse.
|
||||
*
|
||||
* <p>The order is not cosmetic. An open circuit must reject before a rate token or a bulkhead
|
||||
* permit is spent, otherwise a dead upstream keeps consuming the quota and concurrency that healthy
|
||||
* upstreams need.
|
||||
*
|
||||
* <p>A local rejection (rate limiter or bulkhead) is deliberately <em>not</em> recorded as a
|
||||
* circuit error: the upstream never saw the request, and counting our own back-pressure as upstream
|
||||
* failure would open the breaker on a healthy dependency.
|
||||
*/
|
||||
public final class AttemptResiliencePipeline {
|
||||
|
||||
private final AttemptCircuitBreaker circuitBreaker;
|
||||
private final AttemptRateLimiter rateLimiter;
|
||||
private final BlockingAttemptBulkhead bulkhead;
|
||||
private final Supplier<HttpFailureMetadata> metadataSupplier;
|
||||
private final ResilienceRejectionRecorder rejections;
|
||||
|
||||
/** Keeps the existing four-argument shape for callers that do not record metrics. */
|
||||
public AttemptResiliencePipeline(
|
||||
AttemptCircuitBreaker circuitBreaker,
|
||||
AttemptRateLimiter rateLimiter,
|
||||
BlockingAttemptBulkhead bulkhead,
|
||||
Supplier<HttpFailureMetadata> metadataSupplier) {
|
||||
this(
|
||||
circuitBreaker,
|
||||
rateLimiter,
|
||||
bulkhead,
|
||||
metadataSupplier,
|
||||
ResilienceRejectionRecorder.noop());
|
||||
}
|
||||
|
||||
public AttemptResiliencePipeline(
|
||||
AttemptCircuitBreaker circuitBreaker,
|
||||
AttemptRateLimiter rateLimiter,
|
||||
BlockingAttemptBulkhead bulkhead,
|
||||
Supplier<HttpFailureMetadata> metadataSupplier,
|
||||
ResilienceRejectionRecorder rejections) {
|
||||
this.circuitBreaker = Objects.requireNonNull(circuitBreaker, "circuit breaker");
|
||||
this.rateLimiter = Objects.requireNonNull(rateLimiter, "rate limiter");
|
||||
this.bulkhead = Objects.requireNonNull(bulkhead, "bulkhead");
|
||||
this.metadataSupplier = Objects.requireNonNull(metadataSupplier, "failure metadata supplier");
|
||||
this.rejections = Objects.requireNonNull(rejections, "rejection recorder");
|
||||
}
|
||||
|
||||
public String circuitState() {
|
||||
return circuitBreaker.state();
|
||||
}
|
||||
|
||||
public <T> T execute(AttemptCall<T> call) {
|
||||
return execute(call, result -> Optional.empty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one attempt and records the breaker outcome from the <em>remote</em> result.
|
||||
*
|
||||
* <p>The classifier exists because a returned value is not necessarily a success. The blocking
|
||||
* executor used to run only the raw send inside this pipeline and map the response to a stable
|
||||
* exception afterwards, outside it — so a 503 completed the call normally, the breaker recorded a
|
||||
* success, and an upstream that answered nothing but 503 never opened its circuit. The thing the
|
||||
* breaker is for was the one thing it could not see.
|
||||
*
|
||||
* @param call the attempt, including any redirect hops it follows
|
||||
* @param remoteFailure returns the failure to record when the value represents an upstream error
|
||||
* @return the attempt's value, whether or not it represents a remote failure
|
||||
*/
|
||||
public <T> T execute(AttemptCall<T> call, Function<T, Optional<Throwable>> remoteFailure) {
|
||||
Objects.requireNonNull(remoteFailure, "remote failure classifier");
|
||||
if (!circuitBreaker.tryAcquirePermission()) {
|
||||
rejections.circuitOpen();
|
||||
throw new HttpCircuitOpenException(
|
||||
"upstream circuit breaker is open", metadataSupplier.get());
|
||||
}
|
||||
if (!rateLimiter.tryAcquirePermission()) {
|
||||
rejections.rateLimited();
|
||||
throw new HttpRateLimitRejectedException(
|
||||
"local attempt rate limit reached", metadataSupplier.get());
|
||||
}
|
||||
if (!bulkhead.tryAcquire()) {
|
||||
rateLimiter.onCompleted();
|
||||
rejections.bulkheadRejected();
|
||||
throw new HttpBulkheadRejectedException(
|
||||
"attempt bulkhead has no permit available", metadataSupplier.get());
|
||||
}
|
||||
|
||||
long started = System.nanoTime();
|
||||
try {
|
||||
T result = call.call();
|
||||
releaseAttemptPermits();
|
||||
Optional<Throwable> upstreamFailure = remoteFailure.apply(result);
|
||||
if (upstreamFailure.isPresent()) {
|
||||
circuitBreaker.onError(System.nanoTime() - started, upstreamFailure.get());
|
||||
} else {
|
||||
circuitBreaker.onSuccess(System.nanoTime() - started);
|
||||
}
|
||||
return result;
|
||||
} catch (Throwable failure) {
|
||||
releaseAttemptPermits();
|
||||
circuitBreaker.onError(System.nanoTime() - started, failure);
|
||||
throw translate(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseAttemptPermits() {
|
||||
bulkhead.release();
|
||||
rateLimiter.onCompleted();
|
||||
}
|
||||
|
||||
private RuntimeException translate(Throwable failure) {
|
||||
if (failure instanceof HttpClientException stable) {
|
||||
return stable;
|
||||
}
|
||||
if (failure instanceof RuntimeException runtime) {
|
||||
return runtime;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
return new IllegalStateException("outbound http attempt failed", failure);
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.resilience;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
|
||||
import java.time.Duration;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The complete ordered retry decision table (design §17.3).
|
||||
*
|
||||
* <p>The order is the point. Cheap absolute blockers come first (attempts, budget, replayability,
|
||||
* first byte, deadline, draining), then ambiguity, then status- and failure-specific rules. A later
|
||||
* rule can never re-enable something an earlier rule forbade.
|
||||
*/
|
||||
public final class DefaultRetryEligibilityEngine implements RetryEligibilityEngine {
|
||||
|
||||
@Override
|
||||
public RetryDecision decide(RetryContext context) {
|
||||
if (context.attempt() >= context.maxAttempts()) {
|
||||
return RetryDenied.maxAttempts();
|
||||
}
|
||||
if (!context.budget().available()) {
|
||||
return RetryDenied.budgetExhausted();
|
||||
}
|
||||
if (!context.replayability().canReplay()) {
|
||||
return RetryDenied.bodyNotReplayable();
|
||||
}
|
||||
if (context.firstByteDelivered()) {
|
||||
return RetryDenied.responseAlreadyDelivered();
|
||||
}
|
||||
if (context.runtimeDraining()) {
|
||||
return RetryDenied.runtimeDraining();
|
||||
}
|
||||
if (context.remainingDeadline().compareTo(context.minimumAttemptBudget()) <= 0) {
|
||||
return RetryDenied.deadline();
|
||||
}
|
||||
if (context.failureCategory().permanent()) {
|
||||
return RetryDenied.permanentFailure(context.failureCategory().name());
|
||||
}
|
||||
if (context.evidence() == ExecutionEvidence.PARTIAL_RESPONSE) {
|
||||
// A partial response that never reached the caller may still be retried for a safe
|
||||
// operation; once a byte was delivered the earlier guard has already denied it.
|
||||
return context.safelyIdempotent()
|
||||
? RetryAllowed.of("PARTIAL_RESPONSE")
|
||||
: AmbiguousFailure.remoteOutcomeUnknown();
|
||||
}
|
||||
if (context.evidence() == ExecutionEvidence.SENT_NO_RESPONSE && !context.safelyIdempotent()) {
|
||||
return AmbiguousFailure.remoteOutcomeUnknown();
|
||||
}
|
||||
return statusOrFailureDecision(context);
|
||||
}
|
||||
|
||||
private RetryDecision statusOrFailureDecision(RetryContext context) {
|
||||
Optional<HttpStatus> status = context.responseStatus();
|
||||
if (status.isPresent()) {
|
||||
return statusDecision(context, status.get().value());
|
||||
}
|
||||
return failureDecision(context);
|
||||
}
|
||||
|
||||
private RetryDecision statusDecision(RetryContext context, int status) {
|
||||
return switch (status) {
|
||||
// 408, 425 and 429 all mean the request reached the upstream and was answered, so repeating
|
||||
// one is only safe under the same rule as every other repeat. These three used to skip that
|
||||
// check: a non-idempotent POST answered 429 was retried, and a rate-limited upstream that had
|
||||
// already accepted the work got it a second time. A 429 is a scheduling signal, never a
|
||||
// statement that nothing happened.
|
||||
case 408 ->
|
||||
context.safelyIdempotent()
|
||||
? allowWithin(context, "REQUEST_TIMEOUT")
|
||||
: AmbiguousFailure.remoteOutcomeUnknown();
|
||||
// 425 Too Early: repeating once without early data is safe; repeating repeatedly is not.
|
||||
case 425 -> {
|
||||
if (!context.safelyIdempotent()) {
|
||||
yield AmbiguousFailure.remoteOutcomeUnknown();
|
||||
}
|
||||
yield context.attempt() == 1
|
||||
? allowWithin(context, "TOO_EARLY")
|
||||
: RetryDenied.maxAttempts();
|
||||
}
|
||||
case 429 ->
|
||||
context.safelyIdempotent()
|
||||
? allowWithin(context, "RATE_LIMITED")
|
||||
: RetryDenied.notRetryableStatus(status);
|
||||
case 401 ->
|
||||
context.credentialRefreshAvailable()
|
||||
&& context.attempt() == 1
|
||||
&& context.safelyIdempotent()
|
||||
? RetryAllowed.of("UNAUTHORIZED_REFRESH")
|
||||
: RetryDenied.notRetryableStatus(status);
|
||||
case 500 ->
|
||||
context.transientServerErrorStatuses().contains(500) && context.safelyIdempotent()
|
||||
? allowWithin(context, "UPSTREAM_TRANSIENT")
|
||||
: RetryDenied.notRetryableStatus(status);
|
||||
case 502, 503, 504 ->
|
||||
context.safelyIdempotent()
|
||||
? allowWithin(context, "UPSTREAM_UNAVAILABLE")
|
||||
: AmbiguousFailure.remoteOutcomeUnknown();
|
||||
default -> RetryDenied.notRetryableStatus(status);
|
||||
};
|
||||
}
|
||||
|
||||
private RetryDecision failureDecision(RetryContext context) {
|
||||
return switch (context.failureCategory()) {
|
||||
case POOL_ACQUIRE_TIMEOUT -> RetryAllowed.of("POOL_ACQUIRE_TIMEOUT");
|
||||
case DNS -> RetryAllowed.of("DNS");
|
||||
case CONNECT -> RetryAllowed.of("CONNECT");
|
||||
case PROXY -> RetryAllowed.of("PROXY");
|
||||
case TLS_TRANSIENT -> RetryAllowed.of("TLS_TRANSIENT");
|
||||
case REQUEST_WRITE, RESPONSE_TIMEOUT, RESPONSE_TRUNCATED ->
|
||||
context.safelyIdempotent()
|
||||
? RetryAllowed.of(context.failureCategory().name())
|
||||
: AmbiguousFailure.remoteOutcomeUnknown();
|
||||
case NONE -> RetryDenied.success();
|
||||
case CIRCUIT_OPEN, RATE_LIMIT_REJECTED, BULKHEAD_REJECTED ->
|
||||
RetryDenied.permanentFailure(context.failureCategory().name());
|
||||
default -> RetryDenied.permanentFailure(context.failureCategory().name());
|
||||
};
|
||||
}
|
||||
|
||||
/** Honors {@code Retry-After} only when the wait still fits inside the remaining deadline. */
|
||||
private RetryDecision allowWithin(RetryContext context, String reason) {
|
||||
Optional<Duration> retryAfter = context.retryAfter();
|
||||
if (retryAfter.isEmpty()) {
|
||||
return RetryAllowed.of(reason);
|
||||
}
|
||||
Duration required = retryAfter.get().plus(context.minimumAttemptBudget());
|
||||
if (required.compareTo(context.remainingDeadline()) > 0) {
|
||||
return RetryDenied.deadline();
|
||||
}
|
||||
return RetryAllowed.after(reason, retryAfter.get());
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.resilience;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.JitterStrategy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.RetryAfterPolicy;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.random.RandomGenerator;
|
||||
|
||||
/**
|
||||
* Exponential backoff with jitter, bounded by max backoff, {@code Retry-After}, and the remaining
|
||||
* deadline (design §17.5).
|
||||
*
|
||||
* <p>Jitter is not decoration: without it, a fleet that failed together retries together, and the
|
||||
* upstream recovery window never opens. The random source is injectable so the schedule is
|
||||
* testable.
|
||||
*/
|
||||
public final class ExponentialFullJitterBackoff implements BackoffStrategy {
|
||||
|
||||
private final Duration baseBackoff;
|
||||
private final Duration maxBackoff;
|
||||
private final JitterStrategy jitter;
|
||||
private final RetryAfterPolicy retryAfterPolicy;
|
||||
private final RandomGenerator random;
|
||||
private Duration previousDelay;
|
||||
|
||||
public ExponentialFullJitterBackoff(
|
||||
Duration baseBackoff,
|
||||
Duration maxBackoff,
|
||||
JitterStrategy jitter,
|
||||
RetryAfterPolicy retryAfterPolicy,
|
||||
RandomGenerator random) {
|
||||
this.baseBackoff = Objects.requireNonNull(baseBackoff, "base backoff");
|
||||
this.maxBackoff = Objects.requireNonNull(maxBackoff, "max backoff");
|
||||
this.jitter = Objects.requireNonNull(jitter, "jitter strategy");
|
||||
this.retryAfterPolicy = Objects.requireNonNull(retryAfterPolicy, "retry-after policy");
|
||||
this.random = Objects.requireNonNull(random, "random generator");
|
||||
this.previousDelay = baseBackoff;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wait before the next attempt.
|
||||
*
|
||||
* <p>An honoured {@code Retry-After} is <em>not</em> clamped to {@code maxBackoff}. It used to
|
||||
* be, which made {@link RetryAfterPolicy#HONOR} and {@link RetryAfterPolicy#CAP} the same policy:
|
||||
* a profile that chose to honour a rate limiter's instruction still retried after its own 200ms
|
||||
* ceiling, hammering an upstream that had asked for thirty seconds. Two settings that cannot
|
||||
* differ are one setting and a false promise.
|
||||
*
|
||||
* <p>The remaining deadline still bounds everything, because waiting past the point where the
|
||||
* next attempt could finish is not a retry — it is a slower failure. The retry engine separately
|
||||
* refuses to honour a {@code Retry-After} that does not fit, so the two agree.
|
||||
*/
|
||||
@Override
|
||||
public Duration delay(
|
||||
int completedAttempts, Optional<Duration> retryAfter, Duration remainingDeadline) {
|
||||
Optional<Duration> honoured = honoredRetryAfter(retryAfter);
|
||||
Duration candidate = honoured.orElseGet(() -> min(computed(completedAttempts), maxBackoff));
|
||||
previousDelay = candidate.isZero() ? baseBackoff : min(candidate, maxBackoff);
|
||||
// Never wait past the point where the following attempt could still finish.
|
||||
return min(candidate, remainingDeadline);
|
||||
}
|
||||
|
||||
private Optional<Duration> honoredRetryAfter(Optional<Duration> retryAfter) {
|
||||
return switch (retryAfterPolicy) {
|
||||
case IGNORE -> Optional.empty();
|
||||
case HONOR -> retryAfter;
|
||||
case CAP -> retryAfter.map(value -> min(value, maxBackoff));
|
||||
};
|
||||
}
|
||||
|
||||
private Duration computed(int completedAttempts) {
|
||||
long exponent = Math.max(0, completedAttempts - 1);
|
||||
long scaled = baseBackoff.toMillis() << Math.min(exponent, 20);
|
||||
long capped = Math.min(scaled, maxBackoff.toMillis());
|
||||
return switch (jitter) {
|
||||
case NONE -> Duration.ofMillis(capped);
|
||||
case FULL -> Duration.ofMillis(capped <= 0 ? 0 : random.nextLong(capped + 1));
|
||||
case DECORRELATED -> {
|
||||
long lower = baseBackoff.toMillis();
|
||||
long upper = Math.min(maxBackoff.toMillis(), Math.max(lower, previousDelay.toMillis() * 3));
|
||||
yield Duration.ofMillis(upper <= lower ? lower : random.nextLong(lower, upper + 1));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Duration min(Duration left, Duration right) {
|
||||
if (left.isNegative()) {
|
||||
return Duration.ZERO;
|
||||
}
|
||||
return left.compareTo(right) <= 0 ? left : right;
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.resilience;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.observation.HttpClientObservationNames;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.Tag;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Publishes the local back-pressure signals the platform already produced but never recorded.
|
||||
*
|
||||
* <p>{@code http.client.rate_limit.rejected}, {@code http.client.bulkhead.rejected} and {@code
|
||||
* http.client.circuit.state} were declared in the metric vocabulary, documented in the support
|
||||
* matrix, and emitted by nothing. That is the worst arrangement of the three possibilities: an
|
||||
* operator building a dashboard finds the names, charts them, and sees a flat zero during the exact
|
||||
* incident the metrics exist to explain — a saturated bulkhead and an open breaker look identical
|
||||
* to a healthy system.
|
||||
*
|
||||
* <p>The circuit state is a gauge rather than a counter because "how long was it open" is the
|
||||
* question an incident actually asks; the rejections are counters because each one is a request
|
||||
* that did not happen.
|
||||
*/
|
||||
public interface ResilienceRejectionRecorder {
|
||||
|
||||
void circuitOpen();
|
||||
|
||||
void rateLimited();
|
||||
|
||||
void bulkheadRejected();
|
||||
|
||||
/** Used where metrics are not wired, such as hand-constructed test pipelines. */
|
||||
static ResilienceRejectionRecorder noop() {
|
||||
return new ResilienceRejectionRecorder() {
|
||||
@Override
|
||||
public void circuitOpen() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rateLimited() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bulkheadRejected() {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the counters and the circuit-state gauge for one profile.
|
||||
*
|
||||
* @param registry the meter registry
|
||||
* @param clientName the profile the meters are tagged with
|
||||
* @param circuitState supplies the breaker's current state name for the gauge
|
||||
* @return a recorder that publishes to {@code registry}
|
||||
*/
|
||||
static ResilienceRejectionRecorder micrometer(
|
||||
MeterRegistry registry, ClientProfileName clientName, Supplier<String> circuitState) {
|
||||
Objects.requireNonNull(registry, "meter registry");
|
||||
Objects.requireNonNull(clientName, "client name");
|
||||
Objects.requireNonNull(circuitState, "circuit state supplier");
|
||||
List<Tag> tags = List.of(Tag.of("clientName", clientName.value()));
|
||||
|
||||
// 1 while the breaker is refusing traffic, 0 otherwise. A state *name* cannot be a gauge value,
|
||||
// and putting it in a tag would make the series change identity every time the breaker moved.
|
||||
registry.gauge(
|
||||
HttpClientObservationNames.CIRCUIT_STATE,
|
||||
tags,
|
||||
circuitState,
|
||||
supplier -> "OPEN".equalsIgnoreCase(supplier.get()) ? 1.0 : 0.0);
|
||||
|
||||
return new ResilienceRejectionRecorder() {
|
||||
@Override
|
||||
public void circuitOpen() {
|
||||
registry.counter(HttpClientObservationNames.CIRCUIT_STATE + ".rejected", tags).increment();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rateLimited() {
|
||||
registry.counter(HttpClientObservationNames.RATE_LIMIT_REJECTED, tags).increment();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bulkheadRejected() {
|
||||
registry.counter(HttpClientObservationNames.BULKHEAD_REJECTED, tags).increment();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.resilience;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.OperationIdempotency;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Everything the retry decision is allowed to depend on (design §17.1).
|
||||
*
|
||||
* <p>The HTTP method is deliberately absent: design D-09 makes idempotency an explicit operation
|
||||
* property, so a POST with a registered idempotency key and a GET against a non-idempotent RPC
|
||||
* endpoint are both handled correctly instead of by method-name folklore.
|
||||
*/
|
||||
public record RetryContext(
|
||||
OperationIdempotency idempotency,
|
||||
Optional<IdempotencyKey> idempotencyKey,
|
||||
boolean idempotencyKeySent,
|
||||
BodyReplayability replayability,
|
||||
ExecutionEvidence evidence,
|
||||
FailureCategory failureCategory,
|
||||
Optional<HttpStatus> responseStatus,
|
||||
Optional<Duration> retryAfter,
|
||||
int attempt,
|
||||
int maxAttempts,
|
||||
boolean firstByteDelivered,
|
||||
Duration remainingDeadline,
|
||||
Duration minimumAttemptBudget,
|
||||
RetryBudgetSnapshot budget,
|
||||
Set<Integer> transientServerErrorStatuses,
|
||||
boolean credentialRefreshAvailable,
|
||||
boolean runtimeDraining) {
|
||||
|
||||
public RetryContext {
|
||||
Objects.requireNonNull(idempotency, "idempotency");
|
||||
Objects.requireNonNull(idempotencyKey, "idempotency key");
|
||||
Objects.requireNonNull(replayability, "replayability");
|
||||
Objects.requireNonNull(evidence, "evidence");
|
||||
Objects.requireNonNull(failureCategory, "failure category");
|
||||
Objects.requireNonNull(responseStatus, "response status");
|
||||
Objects.requireNonNull(retryAfter, "retry-after");
|
||||
Objects.requireNonNull(remainingDeadline, "remaining deadline");
|
||||
Objects.requireNonNull(minimumAttemptBudget, "minimum attempt budget");
|
||||
Objects.requireNonNull(budget, "retry budget snapshot");
|
||||
Objects.requireNonNull(transientServerErrorStatuses, "transient server error statuses");
|
||||
if (attempt < 1) {
|
||||
throw new IllegalArgumentException("attempt must be at least 1");
|
||||
}
|
||||
if (maxAttempts < 1) {
|
||||
throw new IllegalArgumentException("max attempts must be at least 1");
|
||||
}
|
||||
transientServerErrorStatuses = Set.copyOf(transientServerErrorStatuses);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when repeating a request that may already have been processed is contractually safe.
|
||||
*
|
||||
* <p>For a key-bearing operation this requires that the key was actually written to the request,
|
||||
* not merely that the caller supplied one. The two used to be conflated: the platform read {@code
|
||||
* idempotencyKey.isPresent()}, concluded the upstream could deduplicate, and retried — while the
|
||||
* header was never sent, so the upstream had nothing to deduplicate against and processed the
|
||||
* request twice. Possession of a key is the caller's intent; transmission is the upstream's
|
||||
* ability to honour it, and only the second one makes a repeat safe.
|
||||
*/
|
||||
public boolean safelyIdempotent() {
|
||||
return idempotency.safeToRepeatWithoutKey()
|
||||
|| (idempotency == OperationIdempotency.IDEMPOTENCY_KEY_REQUIRED
|
||||
&& idempotencyKey.isPresent()
|
||||
&& idempotencyKeySent);
|
||||
}
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.restclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptProgressTracker;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.ProtocolEvidence;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedTarget;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* Executes exactly one physical attempt, including its redirect hops (design §7.1 steps 9-12).
|
||||
*
|
||||
* <p>Progress is tracked as the attempt advances so the evidence classifier has real observations
|
||||
* to work from rather than an exception type. Failures become {@link AttemptOutcome} values instead
|
||||
* of escaping, because the coordinator — not the transport — decides whether a failure is final.
|
||||
*/
|
||||
public final class BlockingAttemptExecutor {
|
||||
|
||||
public <T> AttemptOutcome<T> execute(
|
||||
BlockingClientRuntime runtime,
|
||||
PreparedOperation prepared,
|
||||
ResponseType<T> responseType,
|
||||
StatusHandlingPolicy statusHandlingPolicy,
|
||||
RequestCredentials credentials,
|
||||
int attemptNumber,
|
||||
Instant startedAt,
|
||||
HttpFailureMetadata baseMetadata) {
|
||||
|
||||
Objects.requireNonNull(runtime, "runtime");
|
||||
BlockingExecutionSupport support = runtime.support();
|
||||
AttemptProgressTracker tracker = new AttemptProgressTracker();
|
||||
ResponseSizeLimiter limiter =
|
||||
new ResponseSizeLimiter(
|
||||
prepared.maxResponseWireBytes(), prepared.maxResponseDecodedBytes(), baseMetadata);
|
||||
|
||||
try {
|
||||
// Send, follow redirects, and map the response all inside one set of resilience permits.
|
||||
// Two things depended on that: the breaker now sees the mapped remote outcome rather than
|
||||
// "the socket returned bytes", and a redirect hop no longer re-enters the pipeline while the
|
||||
// permits for its own attempt are still held — which with a single-permit bulkhead was a
|
||||
// guaranteed self-rejection, and with any configuration double-counted the rate limiter.
|
||||
return runtime
|
||||
.resiliencePipeline()
|
||||
.execute(
|
||||
() -> {
|
||||
RestClientResponseReader.RawResponse response =
|
||||
sendWithRedirects(
|
||||
runtime, prepared, credentials, tracker, limiter, baseMetadata);
|
||||
Duration elapsed = Duration.between(startedAt, support.clock().instant());
|
||||
try {
|
||||
return AttemptOutcome.succeeded(
|
||||
support
|
||||
.responseMapper()
|
||||
.map(
|
||||
response,
|
||||
responseType,
|
||||
runtime.profile().response(),
|
||||
statusHandlingPolicy,
|
||||
attemptNumber,
|
||||
elapsed,
|
||||
baseMetadata));
|
||||
} catch (HttpRemoteErrorException remoteError) {
|
||||
// Retry-After is read from the response the mapper already bounded, so the
|
||||
// decision engine can honour it without the transport interpreting it.
|
||||
return AttemptOutcome.<T>failed(
|
||||
remoteError,
|
||||
FailureCategory.REMOTE_STATUS,
|
||||
retryAfter(response),
|
||||
tracker.firstByteDelivered());
|
||||
} catch (HttpClientException stable) {
|
||||
return AttemptOutcome.<T>failed(
|
||||
stable, categoryOf(stable), Optional.empty(), tracker.firstByteDelivered());
|
||||
}
|
||||
},
|
||||
outcome ->
|
||||
outcome.failureCategory() == FailureCategory.REMOTE_STATUS
|
||||
? outcome.failure().map(failure -> (Throwable) failure)
|
||||
: Optional.empty());
|
||||
} catch (HttpClientException stable) {
|
||||
return AttemptOutcome.failed(
|
||||
stable, categoryOf(stable), Optional.empty(), tracker.firstByteDelivered());
|
||||
} catch (RuntimeException engineFailure) {
|
||||
TransportFailure classified =
|
||||
runtime.failureClassifier().classify(engineFailure, tracker.stage());
|
||||
HttpFailureMetadata metadata =
|
||||
baseMetadata
|
||||
.withEvidence(
|
||||
support
|
||||
.evidenceClassifier()
|
||||
.classify(tracker.snapshot(), ProtocolEvidence.none()))
|
||||
.withStage(classified.stage());
|
||||
HttpClientException mapped =
|
||||
support.exceptionMapper().map(classified, metadata, engineFailure);
|
||||
return AttemptOutcome.failed(
|
||||
mapped, classified.category(), Optional.empty(), tracker.firstByteDelivered());
|
||||
}
|
||||
}
|
||||
|
||||
private RestClientResponseReader.RawResponse sendWithRedirects(
|
||||
BlockingClientRuntime runtime,
|
||||
PreparedOperation prepared,
|
||||
RequestCredentials credentials,
|
||||
AttemptProgressTracker tracker,
|
||||
ResponseSizeLimiter limiter,
|
||||
HttpFailureMetadata metadata) {
|
||||
|
||||
Map<String, List<String>> headers = withCredentials(prepared.headers(), credentials);
|
||||
PreparedTarget target = prepared.target();
|
||||
URI initialUri = withCredentialQuery(target.uri(), credentials);
|
||||
|
||||
RestClientResponseReader.RawResponse first =
|
||||
send(
|
||||
runtime,
|
||||
initialUri,
|
||||
prepared.operation().method(),
|
||||
headers,
|
||||
prepared.operation().body(),
|
||||
tracker,
|
||||
limiter,
|
||||
metadata);
|
||||
|
||||
BlockingRedirectCoordinator coordinator =
|
||||
new BlockingRedirectCoordinator(
|
||||
runtime.support().redirectEvaluator(),
|
||||
runtime.support().headerStripper(),
|
||||
runtime.targetPolicy()::requireAllowedTarget);
|
||||
return coordinator.follow(
|
||||
first,
|
||||
runtime.redirectPolicy(),
|
||||
PreparedTarget.of(initialUri, target.uriTemplate()),
|
||||
prepared.operation().method(),
|
||||
prepared.operation().body(),
|
||||
headers,
|
||||
// No nested pipeline. A hop runs under the permits its own attempt already holds; taking a
|
||||
// second set would deadlock a single-permit bulkhead against itself and charge the rate
|
||||
// limiter twice for one logical attempt.
|
||||
(hopTarget, hopMethod, hopHeaders, hopBody) ->
|
||||
send(
|
||||
runtime,
|
||||
hopTarget,
|
||||
hopMethod,
|
||||
hopHeaders,
|
||||
hopBody,
|
||||
new AttemptProgressTracker(),
|
||||
limiter,
|
||||
metadata),
|
||||
metadata);
|
||||
}
|
||||
|
||||
private RestClientResponseReader.RawResponse send(
|
||||
BlockingClientRuntime runtime,
|
||||
URI uri,
|
||||
HttpMethod method,
|
||||
Map<String, List<String>> headers,
|
||||
dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource body,
|
||||
AttemptProgressTracker tracker,
|
||||
ResponseSizeLimiter limiter,
|
||||
HttpFailureMetadata metadata) {
|
||||
|
||||
tracker.enter(AttemptStage.POOL_ACQUIRE);
|
||||
RestClient.RequestBodySpec spec =
|
||||
runtime
|
||||
.restClient()
|
||||
.method(org.springframework.http.HttpMethod.valueOf(method.name()))
|
||||
.uri(uri);
|
||||
headers.forEach((name, values) -> values.forEach(value -> spec.header(name, value)));
|
||||
|
||||
tracker.enter(AttemptStage.REQUEST_HEADERS);
|
||||
RestClient.RequestHeadersSpec<?> request =
|
||||
runtime.support().bodyWriter().write(spec, body, runtime.bodyLimitPolicy(), metadata);
|
||||
tracker.enter(AttemptStage.REQUEST_BODY);
|
||||
tracker.requestWriteStarted();
|
||||
|
||||
return request.exchange(
|
||||
(httpRequest, httpResponse) -> {
|
||||
tracker.enter(AttemptStage.RESPONSE_HEADERS);
|
||||
tracker.responseHeadersReceived();
|
||||
RestClientResponseReader.RawResponse response =
|
||||
runtime
|
||||
.support()
|
||||
.responseReader()
|
||||
.readBounded(
|
||||
httpResponse.getStatusCode().value(),
|
||||
httpResponse.getHeaders(),
|
||||
httpResponse.getBody(),
|
||||
limiter);
|
||||
tracker.enter(AttemptStage.COMPLETE);
|
||||
return response;
|
||||
},
|
||||
true);
|
||||
}
|
||||
|
||||
private Map<String, List<String>> withCredentials(
|
||||
Map<String, List<String>> headers, RequestCredentials credentials) {
|
||||
if (credentials.empty()) {
|
||||
return headers;
|
||||
}
|
||||
Map<String, List<String>> merged = new LinkedHashMap<>(headers);
|
||||
credentials.headers().forEach((name, value) -> merged.put(name, List.of(value)));
|
||||
return Map.copyOf(merged);
|
||||
}
|
||||
|
||||
private URI withCredentialQuery(URI uri, RequestCredentials credentials) {
|
||||
if (credentials.queryParameters().isEmpty()) {
|
||||
return uri;
|
||||
}
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromUri(uri);
|
||||
credentials.queryParameters().forEach(builder::queryParam);
|
||||
return builder.build(true).toUri();
|
||||
}
|
||||
|
||||
/** Reads {@code Retry-After} from a response the mapper already bounded. */
|
||||
public static Optional<Duration> retryAfter(RestClientResponseReader.RawResponse response) {
|
||||
Optional<String> header = response.firstHeader(HttpHeaders.RETRY_AFTER);
|
||||
if (header.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(Duration.ofSeconds(Long.parseLong(header.get().trim())));
|
||||
} catch (NumberFormatException httpDate) {
|
||||
// An HTTP-date Retry-After is valid but its value depends on clock agreement we do not have;
|
||||
// falling back to the platform backoff is safer than trusting a skewed absolute time.
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private FailureCategory categoryOf(HttpClientException failure) {
|
||||
return switch (failure.getClass().getSimpleName()) {
|
||||
case "HttpDnsException" -> FailureCategory.DNS;
|
||||
case "HttpPoolAcquireTimeoutException" -> FailureCategory.POOL_ACQUIRE_TIMEOUT;
|
||||
case "HttpConnectException" -> FailureCategory.CONNECT;
|
||||
case "HttpProxyException" -> FailureCategory.PROXY;
|
||||
case "HttpTlsException" -> FailureCategory.TLS_PERMANENT;
|
||||
case "HttpRequestWriteException" -> FailureCategory.REQUEST_WRITE;
|
||||
case "HttpResponseTimeoutException" -> FailureCategory.RESPONSE_TIMEOUT;
|
||||
case "HttpResponseTruncatedException" -> FailureCategory.RESPONSE_TRUNCATED;
|
||||
case "HttpResponseTooLargeException" -> FailureCategory.RESPONSE_TOO_LARGE;
|
||||
case "HttpSerializationException" -> FailureCategory.SERIALIZATION;
|
||||
case "HttpTargetRejectedException" -> FailureCategory.TARGET_REJECTED;
|
||||
case "HttpRedirectRejectedException" -> FailureCategory.REDIRECT_REJECTED;
|
||||
case "HttpAuthenticationException" -> FailureCategory.AUTHENTICATION;
|
||||
case "HttpCircuitOpenException" -> FailureCategory.CIRCUIT_OPEN;
|
||||
case "HttpRateLimitRejectedException" -> FailureCategory.RATE_LIMIT_REJECTED;
|
||||
case "HttpBulkheadRejectedException" -> FailureCategory.BULKHEAD_REJECTED;
|
||||
case "HttpDeadlineExceededException" -> FailureCategory.DEADLINE_EXCEEDED;
|
||||
case "HttpConfigurationException" -> FailureCategory.CONFIGURATION;
|
||||
default -> FailureCategory.UNKNOWN;
|
||||
};
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.restclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentialProvider;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptResiliencePipeline;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.LogicalAdmissionLimiter;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.BodyLimitPolicy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectPolicy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.TrustedTargetPolicy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* One immutable blocking generation: a RestClient plus everything needed to execute against it
|
||||
* (design §7.2, §26.1).
|
||||
*
|
||||
* <p>The {@code RestClient} is created once and never mutated. Callers cannot obtain its builder,
|
||||
* which is what stops a caller from quietly removing an interceptor or changing a timeout.
|
||||
*/
|
||||
public final class BlockingClientRuntime extends ClientRuntime {
|
||||
|
||||
private final RestClient restClient;
|
||||
private final TransportId transportId;
|
||||
private final TransportFailureClassifier failureClassifier;
|
||||
private final TrustedTargetPolicy targetPolicy;
|
||||
private final BodyLimitPolicy bodyLimitPolicy;
|
||||
private final RedirectPolicy redirectPolicy;
|
||||
private final AttemptResiliencePipeline resiliencePipeline;
|
||||
private final LogicalAdmissionLimiter admissionLimiter;
|
||||
private final RetryBudget retryBudget;
|
||||
private final Supplier<BackoffStrategy> backoffFactory;
|
||||
private final RequestCredentialProvider credentialProvider;
|
||||
private final BlockingExecutionSupport support;
|
||||
|
||||
public BlockingClientRuntime(
|
||||
ClientProfile profile,
|
||||
RuntimeGeneration generation,
|
||||
Runnable resourceCloser,
|
||||
RestClient restClient,
|
||||
TransportId transportId,
|
||||
TransportFailureClassifier failureClassifier,
|
||||
AttemptResiliencePipeline resiliencePipeline,
|
||||
LogicalAdmissionLimiter admissionLimiter,
|
||||
RetryBudget retryBudget,
|
||||
Supplier<BackoffStrategy> backoffFactory,
|
||||
RequestCredentialProvider credentialProvider,
|
||||
BlockingExecutionSupport support) {
|
||||
super(profile, generation, resourceCloser);
|
||||
this.restClient = Objects.requireNonNull(restClient, "rest client");
|
||||
this.transportId = Objects.requireNonNull(transportId, "transport id");
|
||||
this.failureClassifier = Objects.requireNonNull(failureClassifier, "failure classifier");
|
||||
this.resiliencePipeline = Objects.requireNonNull(resiliencePipeline, "resilience pipeline");
|
||||
this.admissionLimiter = Objects.requireNonNull(admissionLimiter, "admission limiter");
|
||||
this.retryBudget = Objects.requireNonNull(retryBudget, "retry budget");
|
||||
this.backoffFactory = Objects.requireNonNull(backoffFactory, "backoff factory");
|
||||
this.credentialProvider = Objects.requireNonNull(credentialProvider, "credential provider");
|
||||
this.support = Objects.requireNonNull(support, "execution support");
|
||||
this.targetPolicy = new TrustedTargetPolicy(profile);
|
||||
this.bodyLimitPolicy = BodyLimitPolicy.maxRequestBytes(profile.request().maxBodyBytes());
|
||||
this.redirectPolicy =
|
||||
profile.mode() == dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode.DYNAMIC
|
||||
? RedirectPolicy.managedByCaller()
|
||||
: RedirectPolicy.from(profile.redirect());
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine client, visible only inside this package.
|
||||
*
|
||||
* <p>It used to be public, which meant any caller holding a runtime could execute a request that
|
||||
* skipped target policy, credentials, admission, deadline, resilience, byte limits, stable error
|
||||
* mapping and observation — every guarantee the profile exists to provide. The typed registries
|
||||
* did exactly that. Package-private is what makes the platform's guarantees structural rather
|
||||
* than a convention, and {@code PublicApiArchitectureTest} keeps Spring's client types confined
|
||||
* here.
|
||||
*
|
||||
* @return the profile's immutable {@code RestClient}
|
||||
*/
|
||||
RestClient restClient() {
|
||||
return restClient;
|
||||
}
|
||||
|
||||
public TransportId transportId() {
|
||||
return transportId;
|
||||
}
|
||||
|
||||
public TransportFailureClassifier failureClassifier() {
|
||||
return failureClassifier;
|
||||
}
|
||||
|
||||
public TrustedTargetPolicy targetPolicy() {
|
||||
return targetPolicy;
|
||||
}
|
||||
|
||||
public BodyLimitPolicy bodyLimitPolicy() {
|
||||
return bodyLimitPolicy;
|
||||
}
|
||||
|
||||
public RedirectPolicy redirectPolicy() {
|
||||
return redirectPolicy;
|
||||
}
|
||||
|
||||
public AttemptResiliencePipeline resiliencePipeline() {
|
||||
return resiliencePipeline;
|
||||
}
|
||||
|
||||
public LogicalAdmissionLimiter admissionLimiter() {
|
||||
return admissionLimiter;
|
||||
}
|
||||
|
||||
public RetryBudget retryBudget() {
|
||||
return retryBudget;
|
||||
}
|
||||
|
||||
public BackoffStrategy newBackoff() {
|
||||
return backoffFactory.get();
|
||||
}
|
||||
|
||||
public RequestCredentialProvider credentialProvider() {
|
||||
return credentialProvider;
|
||||
}
|
||||
|
||||
public BlockingExecutionSupport support() {
|
||||
return support;
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.restclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRedirectRejectedException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedTarget;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectContext;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectDecision;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectEvaluator;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.RedirectPolicy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.SensitiveHeaderStripper;
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Follows redirects explicitly, one evaluated hop at a time (design §12.4).
|
||||
*
|
||||
* <p>Engine-level redirect following is disabled in every transport, so this is the only place a
|
||||
* hop can happen. Each hop re-applies target policy and, when the origin changes, drops the
|
||||
* credentials — which is exactly what an engine's built-in follower does not do.
|
||||
*
|
||||
* <p>A hop is a physical request: it passes through the resilience pipeline via the supplied
|
||||
* sender, so it consumes rate and bulkhead capacity. It is not a retry, because nothing failed.
|
||||
*/
|
||||
public final class BlockingRedirectCoordinator {
|
||||
|
||||
/** Issues one physical request; supplied by the executor so hops share the attempt pipeline. */
|
||||
@FunctionalInterface
|
||||
public interface HopSender {
|
||||
RestClientResponseReader.RawResponse send(
|
||||
URI target, HttpMethod method, Map<String, List<String>> headers, BodySource body);
|
||||
}
|
||||
|
||||
/** Re-applies the profile's own origin allowlist to a hop target. */
|
||||
@FunctionalInterface
|
||||
public interface TargetGuard {
|
||||
void requireAllowed(URI target, HttpFailureMetadata metadata);
|
||||
}
|
||||
|
||||
private final RedirectEvaluator evaluator;
|
||||
private final SensitiveHeaderStripper headerStripper;
|
||||
private final TargetGuard targetGuard;
|
||||
|
||||
public BlockingRedirectCoordinator(
|
||||
RedirectEvaluator evaluator,
|
||||
SensitiveHeaderStripper headerStripper,
|
||||
TargetGuard targetGuard) {
|
||||
this.evaluator = Objects.requireNonNull(evaluator, "redirect evaluator");
|
||||
this.headerStripper = Objects.requireNonNull(headerStripper, "sensitive header stripper");
|
||||
this.targetGuard = Objects.requireNonNull(targetGuard, "target guard");
|
||||
}
|
||||
|
||||
public RestClientResponseReader.RawResponse follow(
|
||||
RestClientResponseReader.RawResponse initial,
|
||||
RedirectPolicy policy,
|
||||
PreparedTarget initialTarget,
|
||||
HttpMethod method,
|
||||
BodySource body,
|
||||
Map<String, List<String>> headers,
|
||||
HopSender sender,
|
||||
HttpFailureMetadata metadata) {
|
||||
if (policy.callerManaged()) {
|
||||
// A Dynamic Target owns its own hop validation; following here would skip it.
|
||||
return initial;
|
||||
}
|
||||
RestClientResponseReader.RawResponse response = initial;
|
||||
PreparedTarget currentTarget = initialTarget;
|
||||
Map<String, List<String>> currentHeaders = headers;
|
||||
HttpMethod currentMethod = method;
|
||||
BodySource currentBody = body;
|
||||
|
||||
for (int hop = 0; isRedirect(response.status()); hop++) {
|
||||
Optional<String> location = response.firstHeader("Location");
|
||||
if (location.isEmpty()) {
|
||||
return response;
|
||||
}
|
||||
URI target = currentTarget.uri().resolve(location.get());
|
||||
RedirectContext context =
|
||||
RedirectContext.of(policy, hop, response.status(), currentBody, currentTarget, target);
|
||||
RedirectDecision decision = evaluator.evaluate(context);
|
||||
if (decision instanceof RedirectDecision.Reject reject) {
|
||||
throw new HttpRedirectRejectedException("redirect rejected: " + reject.code(), metadata);
|
||||
}
|
||||
RedirectDecision.Follow follow = (RedirectDecision.Follow) decision;
|
||||
// The redirect policy decides whether a hop is permissible in shape; the profile decides
|
||||
// whether its destination is permissible at all. Only the first check existed, so an upstream
|
||||
// could redirect a trusted profile to an origin its allowlist excluded.
|
||||
targetGuard.requireAllowed(follow.target(), metadata);
|
||||
if (follow.crossOrigin()) {
|
||||
currentHeaders = headerStripper.stripForCrossOrigin(currentHeaders);
|
||||
}
|
||||
// 303 explicitly converts to GET, which means dropping the body as well as changing the
|
||||
// method. Changing only the method sent the original payload as a GET body to a destination
|
||||
// the upstream chose. 301/302 keep the method because the platform refuses to guess a rewrite
|
||||
// the caller did not ask for.
|
||||
if (response.status() == 303) {
|
||||
currentMethod = HttpMethod.GET;
|
||||
currentBody = EmptyBody.instance();
|
||||
}
|
||||
currentTarget = PreparedTarget.of(follow.target(), currentTarget.uriTemplate());
|
||||
response = sender.send(follow.target(), currentMethod, currentHeaders, currentBody);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private boolean isRedirect(int status) {
|
||||
return status == 301 || status == 302 || status == 303 || status == 307 || status == 308;
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.restclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.BlockingStreamingResponse;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.DeadlineGuard;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* Streaming download for the blocking stack (design §23.2, §23.3).
|
||||
*
|
||||
* <p>The status is validated before any body byte is handed over, and retries are already
|
||||
* impossible once the caller reads: the platform hands out an {@link BlockingStreamingResponse}
|
||||
* rather than a raw stream so the connection is released on every exit path.
|
||||
*/
|
||||
public final class BlockingStreamingGateway {
|
||||
|
||||
private final ClientRuntimeRegistry runtimes;
|
||||
|
||||
public BlockingStreamingGateway(ClientRuntimeRegistry runtimes) {
|
||||
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
|
||||
}
|
||||
|
||||
public BlockingStreamingResponse download(
|
||||
ClientProfileName profileName, HttpOperation operation) {
|
||||
Objects.requireNonNull(profileName, "profile name");
|
||||
Objects.requireNonNull(operation, "operation");
|
||||
|
||||
ClientRuntimeLease lease = runtimes.acquire(profileName);
|
||||
boolean handedOff = false;
|
||||
try {
|
||||
if (!(lease.runtime() instanceof BlockingClientRuntime runtime)) {
|
||||
throw new IllegalStateException(
|
||||
"profile " + profileName.value() + " is not configured for the blocking api");
|
||||
}
|
||||
HttpFailureMetadata metadata =
|
||||
HttpFailureMetadata.validation(
|
||||
profileName,
|
||||
operation.operationName(),
|
||||
operation.method(),
|
||||
operation.uriTemplate(),
|
||||
operation.body().replayability());
|
||||
PreparedOperation prepared = runtime.targetPolicy().prepare(operation);
|
||||
ResponseSizeLimiter limiter =
|
||||
new ResponseSizeLimiter(
|
||||
prepared.maxResponseWireBytes(), prepared.maxResponseDecodedBytes(), metadata);
|
||||
|
||||
// A streaming download is a call, not an exemption. This path used to apply target policy and
|
||||
// the wire-byte limit and nothing else: no credential was resolved, no admission permit
|
||||
// taken,
|
||||
// no deadline checked, no breaker or rate limiter entered. A profile whose upstream was down
|
||||
// could therefore be hammered indefinitely through its streaming surface while its
|
||||
// non-streaming surface sat behind an open circuit.
|
||||
new DeadlineGuard(runtime.support().clock())
|
||||
.requireTimeRemaining(
|
||||
runtime
|
||||
.support()
|
||||
.deadlineCalculator()
|
||||
.effective(
|
||||
operation.deadline(),
|
||||
runtime.profile().timeout().totalCall(),
|
||||
runtime.support().clock()),
|
||||
metadata);
|
||||
|
||||
RequestCredentials credentials =
|
||||
runtime
|
||||
.credentialProvider()
|
||||
.resolve(
|
||||
new CredentialRequest(
|
||||
runtime.name(),
|
||||
operation.operationName(),
|
||||
runtime.profile().authentication(),
|
||||
prepared.target().uri(),
|
||||
java.util.Optional.empty(),
|
||||
runtime.profile().tls().keyMaterialReference(),
|
||||
false));
|
||||
|
||||
Map<String, List<String>> headers = new LinkedHashMap<>(prepared.headers());
|
||||
credentials.headers().forEach((name, value) -> headers.put(name, List.of(value)));
|
||||
|
||||
RestClient.RequestBodySpec spec =
|
||||
runtime
|
||||
.restClient()
|
||||
.method(org.springframework.http.HttpMethod.valueOf(operation.method().name()))
|
||||
.uri(prepared.target().uri());
|
||||
headers.forEach((name, values) -> values.forEach(value -> spec.header(name, value)));
|
||||
|
||||
// The admission permit is held for the life of the stream, not just the request: a streamed
|
||||
// body occupies a connection until the caller closes it, and counting only the handshake made
|
||||
// the limiter blind to exactly the calls that hold resources longest.
|
||||
AutoCloseable admission = runtime.admissionLimiter().admit(metadata);
|
||||
BlockingStreamingResponse response;
|
||||
try {
|
||||
response =
|
||||
spec.exchange(
|
||||
(request, rawResponse) -> {
|
||||
int status = rawResponse.getStatusCode().value();
|
||||
Map<String, List<String>> responseHeaders = new LinkedHashMap<>();
|
||||
rawResponse
|
||||
.getHeaders()
|
||||
.forEach(
|
||||
(name, values) ->
|
||||
responseHeaders.put(name, List.copyOf(new ArrayList<>(values))));
|
||||
if (status < 200 || status >= 300) {
|
||||
// Status is validated before any byte is delivered, so a failed download never
|
||||
// becomes a half-consumed stream the caller has to reason about.
|
||||
rawResponse.close();
|
||||
throw new HttpRemoteErrorException(
|
||||
"streaming download returned an error status",
|
||||
metadata
|
||||
.withStatus(new HttpStatus(status))
|
||||
.withEvidence(ExecutionEvidence.RESPONSE_RECEIVED));
|
||||
}
|
||||
InputStream bounded =
|
||||
new CountingBoundedInputStream(
|
||||
rawResponse.getBody(), limiter::recordWireBytes);
|
||||
return new DefaultBlockingStreamingResponse(
|
||||
new HttpStatus(status), responseHeaders, bounded, rawResponse::close);
|
||||
},
|
||||
false);
|
||||
} catch (RuntimeException failure) {
|
||||
closeQuietly(admission);
|
||||
throw failure;
|
||||
}
|
||||
|
||||
BlockingStreamingResponse wrapped =
|
||||
new DefaultBlockingStreamingResponse(
|
||||
response.status(),
|
||||
response.headers(),
|
||||
response.body(),
|
||||
() -> {
|
||||
response.close();
|
||||
closeQuietly(admission);
|
||||
lease.close();
|
||||
});
|
||||
handedOff = true;
|
||||
return wrapped;
|
||||
} finally {
|
||||
if (!handedOff) {
|
||||
lease.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A permit release must not mask the failure that is already propagating. */
|
||||
private static void closeQuietly(AutoCloseable closeable) {
|
||||
try {
|
||||
closeable.close();
|
||||
} catch (Exception ignored) {
|
||||
// The admission limiter's release cannot fail meaningfully; swallowing keeps the original
|
||||
// failure attributable.
|
||||
}
|
||||
}
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.restclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAmbiguousExecutionException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.UnauthorizedRetryContext;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.observation.LogicalCallObservation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptBudgetCalculator;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.BlockingLogicalCall;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.BlockingRetryCoordinator;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.Deadline;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.DeadlineGuard;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryAllowed;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryContext;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.Sleeper;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* H2 Generic Exchange over the blocking stack (design §9.3, §7.1).
|
||||
*
|
||||
* <p>The logical call is assembled here and then handed to the retry coordinator: lease → admission
|
||||
* → target policy → deadline → credentials → attempts. Every one of those is a place a caller could
|
||||
* otherwise bypass a profile guarantee, which is why none of them are optional.
|
||||
*/
|
||||
public final class DefaultGenericHttpGateway implements GenericHttpGateway {
|
||||
|
||||
private final ClientRuntimeRegistry runtimes;
|
||||
private final BlockingAttemptExecutor executor;
|
||||
|
||||
public DefaultGenericHttpGateway(
|
||||
ClientRuntimeRegistry runtimes, BlockingAttemptExecutor executor) {
|
||||
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
|
||||
this.executor = Objects.requireNonNull(executor, "attempt executor");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> HttpCallResult<T> exchange(
|
||||
ClientProfileName profileName, HttpOperation operation, ResponseType<T> responseType) {
|
||||
return exchange(profileName, operation, responseType, StatusHandlingPolicy.THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> HttpCallResult<T> exchange(
|
||||
ClientProfileName profileName,
|
||||
HttpOperation operation,
|
||||
ResponseType<T> responseType,
|
||||
StatusHandlingPolicy statusHandlingPolicy) {
|
||||
Objects.requireNonNull(profileName, "profile name");
|
||||
Objects.requireNonNull(operation, "operation");
|
||||
Objects.requireNonNull(responseType, "response type");
|
||||
|
||||
try (ClientRuntimeLease lease = runtimes.acquire(profileName)) {
|
||||
BlockingClientRuntime runtime = requireBlockingRuntime(lease);
|
||||
BlockingExecutionSupport support = runtime.support();
|
||||
HttpFailureMetadata metadata =
|
||||
HttpFailureMetadata.validation(
|
||||
profileName,
|
||||
operation.operationName(),
|
||||
operation.method(),
|
||||
operation.uriTemplate(),
|
||||
operation.body().replayability());
|
||||
|
||||
try (AutoCloseable admission = runtime.admissionLimiter().admit(metadata);
|
||||
AutoCloseable context =
|
||||
BlockingOperationContext.set(profileName, operation.operationName())) {
|
||||
|
||||
PreparedOperation prepared = runtime.targetPolicy().prepare(operation);
|
||||
|
||||
Deadline deadline =
|
||||
support
|
||||
.deadlineCalculator()
|
||||
.effective(
|
||||
operation.deadline(), runtime.profile().timeout().totalCall(), support.clock());
|
||||
new DeadlineGuard(support.clock()).requireTimeRemaining(deadline, metadata);
|
||||
|
||||
LogicalCallObservation observation =
|
||||
LogicalCallObservation.start(
|
||||
support.meterRegistry(),
|
||||
support.tagPolicy(),
|
||||
profileName,
|
||||
operation.operationName(),
|
||||
operation.method().name(),
|
||||
operation.uriTemplate());
|
||||
|
||||
BlockingRetryCoordinator coordinator =
|
||||
new BlockingRetryCoordinator(
|
||||
support.eligibilityEngine(),
|
||||
runtime.newBackoff(),
|
||||
runtime.retryBudget(),
|
||||
Sleeper.threadSleep(),
|
||||
support.clock());
|
||||
|
||||
GenericLogicalCall<T> call =
|
||||
new GenericLogicalCall<>(
|
||||
runtime,
|
||||
prepared,
|
||||
responseType,
|
||||
statusHandlingPolicy,
|
||||
deadline,
|
||||
metadata,
|
||||
observation);
|
||||
try {
|
||||
HttpCallResult<T> result = coordinator.execute(call);
|
||||
observation.stop(Optional.of(result.status()), result.evidence(), "success");
|
||||
return result;
|
||||
} catch (HttpAmbiguousExecutionException ambiguous) {
|
||||
observation.recordAmbiguous();
|
||||
observation.stop(Optional.empty(), ambiguous.metadata().evidence(), "ambiguous");
|
||||
throw ambiguous;
|
||||
} catch (HttpClientException failure) {
|
||||
observation.stop(failure.metadata().status(), failure.metadata().evidence(), "failure");
|
||||
throw failure;
|
||||
}
|
||||
} catch (RuntimeException failure) {
|
||||
throw failure;
|
||||
} catch (Exception unexpected) {
|
||||
throw new IllegalStateException("outbound http call could not complete", unexpected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private BlockingClientRuntime requireBlockingRuntime(ClientRuntimeLease lease) {
|
||||
if (lease.runtime() instanceof BlockingClientRuntime blocking) {
|
||||
return blocking;
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"profile " + lease.runtime().name().value() + " is not configured for the blocking api");
|
||||
}
|
||||
|
||||
/** Bridges one prepared operation to the retry coordinator (design §7.1 steps 8-13). */
|
||||
private final class GenericLogicalCall<T> implements BlockingLogicalCall<T> {
|
||||
|
||||
private final BlockingClientRuntime runtime;
|
||||
private final PreparedOperation prepared;
|
||||
private final ResponseType<T> responseType;
|
||||
private final StatusHandlingPolicy statusHandlingPolicy;
|
||||
private final Deadline deadline;
|
||||
private final HttpFailureMetadata metadata;
|
||||
private final LogicalCallObservation observation;
|
||||
private final Instant startedAt;
|
||||
|
||||
private RequestCredentials credentials;
|
||||
private int credentialRefreshes;
|
||||
|
||||
private GenericLogicalCall(
|
||||
BlockingClientRuntime runtime,
|
||||
PreparedOperation prepared,
|
||||
ResponseType<T> responseType,
|
||||
StatusHandlingPolicy statusHandlingPolicy,
|
||||
Deadline deadline,
|
||||
HttpFailureMetadata metadata,
|
||||
LogicalCallObservation observation) {
|
||||
this.runtime = runtime;
|
||||
this.prepared = prepared;
|
||||
this.responseType = responseType;
|
||||
this.statusHandlingPolicy = statusHandlingPolicy;
|
||||
this.deadline = deadline;
|
||||
this.metadata = metadata;
|
||||
this.observation = observation;
|
||||
this.startedAt = runtime.support().clock().instant();
|
||||
this.credentials = resolveCredentials(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AttemptOutcome<T> attempt(int attemptNumber) {
|
||||
if (!runtime.acceptsNewAttempts() && attemptNumber > 1) {
|
||||
throw new HttpRateLimitRejectedException(
|
||||
"runtime is draining and refuses new attempts", metadata);
|
||||
}
|
||||
// Checked before every attempt, not only before the first. The deadline used to be verified
|
||||
// once at the start of the logical call, so a retry could begin after the budget had already
|
||||
// expired and run to its own transport timeout — the caller's deadline was a suggestion the
|
||||
// second attempt onwards ignored. AttemptBudgetCalculator and DeadlineGuard both existed for
|
||||
// this and neither was on the execution path.
|
||||
BlockingExecutionSupport attemptSupport = runtime.support();
|
||||
DeadlineGuard guard = new DeadlineGuard(attemptSupport.clock());
|
||||
guard.requireTimeRemaining(deadline, metadata.withAttempt(attemptNumber));
|
||||
guard.requireAttemptBudget(
|
||||
new AttemptBudgetCalculator(attemptSupport.clock())
|
||||
.nextAttempt(
|
||||
deadline, Duration.ZERO, attemptSupport.minimumAttemptBudget(), Duration.ZERO),
|
||||
metadata.withAttempt(attemptNumber));
|
||||
return executor.execute(
|
||||
runtime,
|
||||
prepared,
|
||||
responseType,
|
||||
statusHandlingPolicy,
|
||||
credentials,
|
||||
attemptNumber,
|
||||
startedAt,
|
||||
metadata.withAttempt(attemptNumber));
|
||||
}
|
||||
|
||||
@Override
|
||||
public RetryContext context(AttemptOutcome<T> outcome, int attemptNumber) {
|
||||
BlockingExecutionSupport support = runtime.support();
|
||||
boolean unauthorized = outcome.status().map(status -> status.value() == 401).orElse(false);
|
||||
boolean refreshAllowed =
|
||||
unauthorized
|
||||
&& support
|
||||
.unauthorizedRetryPolicy()
|
||||
.mayRetry(
|
||||
new UnauthorizedRetryContext(
|
||||
prepared.operation().idempotency(),
|
||||
prepared.operation().body().replayability(),
|
||||
credentialRefreshes,
|
||||
true));
|
||||
if (refreshAllowed) {
|
||||
runtime.credentialProvider().invalidate(credentialRequest(false));
|
||||
credentials = resolveCredentials(true);
|
||||
credentialRefreshes++;
|
||||
}
|
||||
return new RetryContext(
|
||||
prepared.operation().idempotency(),
|
||||
prepared.operation().idempotencyKey(),
|
||||
prepared.idempotencyKeySent(),
|
||||
prepared.operation().body().replayability(),
|
||||
outcome.evidence(),
|
||||
outcome.failureCategory(),
|
||||
outcome.status(),
|
||||
outcome.retryAfter(),
|
||||
attemptNumber,
|
||||
runtime.profile().retry().maxAttempts(),
|
||||
outcome.firstByteDelivered(),
|
||||
deadline.remaining(support.clock()),
|
||||
support.minimumAttemptBudget(),
|
||||
runtime.retryBudget().snapshot(),
|
||||
support.transientServerErrorStatuses(),
|
||||
refreshAllowed,
|
||||
!runtime.acceptsNewAttempts());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Deadline deadline() {
|
||||
return deadline;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpCallResult<T> finish(AttemptOutcome<T> outcome, int attemptNumber) {
|
||||
return outcome
|
||||
.result()
|
||||
.map(
|
||||
value ->
|
||||
new HttpCallResult<>(
|
||||
value.status(),
|
||||
value.headers(),
|
||||
value.body(),
|
||||
attemptNumber,
|
||||
Duration.between(startedAt, runtime.support().clock().instant()),
|
||||
value.evidence(),
|
||||
value.remoteProblem()))
|
||||
.orElseThrow(() -> outcome.failure().orElseThrow());
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpClientException ambiguous(AttemptOutcome<T> outcome, int attemptNumber) {
|
||||
return new HttpAmbiguousExecutionException(
|
||||
"request was sent but the remote outcome is unknown",
|
||||
metadata.withAttempt(attemptNumber).withEvidence(ExecutionEvidence.SENT_NO_RESPONSE));
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpClientException retryExhausted(int attemptNumber) {
|
||||
observation.recordRetryExhausted();
|
||||
return new HttpRateLimitRejectedException(
|
||||
"retry budget for this upstream is exhausted", metadata.withAttempt(attemptNumber));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRetryGranted(RetryAllowed allowed, int attemptNumber) {
|
||||
observation.recordRetry(allowed.reason());
|
||||
}
|
||||
|
||||
private RequestCredentials resolveCredentials(boolean forceRefresh) {
|
||||
return runtime.credentialProvider().resolve(credentialRequest(forceRefresh));
|
||||
}
|
||||
|
||||
private CredentialRequest credentialRequest(boolean forceRefresh) {
|
||||
return new CredentialRequest(
|
||||
runtime.name(),
|
||||
prepared.operation().operationName(),
|
||||
runtime.profile().authentication(),
|
||||
prepared.target().uri(),
|
||||
Optional.empty(),
|
||||
runtime.profile().tls().keyMaterialReference(),
|
||||
forceRefresh);
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.restclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpSerializationException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.ByteArrayResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.ClassResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.EmptyResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.GenericResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ResponseLimits;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpInputMessage;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
|
||||
import org.springframework.http.converter.FormHttpMessageConverter;
|
||||
import org.springframework.http.converter.GenericHttpMessageConverter;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.converter.StringHttpMessageConverter;
|
||||
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
|
||||
|
||||
/**
|
||||
* Reads and decodes a response inside the profile's byte budget (design §23.2).
|
||||
*
|
||||
* <p>The body is bounded while it is read and then decoded from memory by a Spring message
|
||||
* converter. Doing the bounding first is what makes a decompression bomb or an unexpectedly huge
|
||||
* payload a rejected request instead of an out-of-memory error.
|
||||
*/
|
||||
public final class RestClientResponseReader {
|
||||
|
||||
private final List<HttpMessageConverter<?>> converters;
|
||||
|
||||
public RestClientResponseReader() {
|
||||
this(defaultConverters());
|
||||
}
|
||||
|
||||
public RestClientResponseReader(List<HttpMessageConverter<?>> converters) {
|
||||
this.converters = List.copyOf(Objects.requireNonNull(converters, "message converters"));
|
||||
}
|
||||
|
||||
public static List<HttpMessageConverter<?>> defaultConverters() {
|
||||
List<HttpMessageConverter<?>> converters = new ArrayList<>();
|
||||
converters.add(new ByteArrayHttpMessageConverter());
|
||||
converters.add(new StringHttpMessageConverter());
|
||||
converters.add(new FormHttpMessageConverter());
|
||||
converters.add(new JacksonJsonHttpMessageConverter());
|
||||
return List.copyOf(converters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded snapshot of one HTTP response.
|
||||
*
|
||||
* <p>A final class rather than a record: the body is an array and must be copied on construction
|
||||
* and on every access so a caller cannot mutate a buffer the retry path may re-read.
|
||||
*/
|
||||
public static final class RawResponse {
|
||||
|
||||
private final int status;
|
||||
private final Map<String, List<String>> headers;
|
||||
private final byte[] body;
|
||||
|
||||
public RawResponse(int status, Map<String, List<String>> headers, byte[] body) {
|
||||
this.status = status;
|
||||
this.headers = Map.copyOf(Objects.requireNonNull(headers, "headers"));
|
||||
this.body = Objects.requireNonNull(body, "body").clone();
|
||||
}
|
||||
|
||||
public int status() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public Map<String, List<String>> headers() {
|
||||
return headers;
|
||||
}
|
||||
|
||||
public byte[] body() {
|
||||
return body.clone();
|
||||
}
|
||||
|
||||
public int bodyLength() {
|
||||
return body.length;
|
||||
}
|
||||
|
||||
public java.util.Optional<String> firstHeader(String name) {
|
||||
return headers.entrySet().stream()
|
||||
.filter(entry -> entry.getKey().equalsIgnoreCase(name))
|
||||
.flatMap(entry -> entry.getValue().stream())
|
||||
.findFirst();
|
||||
}
|
||||
}
|
||||
|
||||
public RawResponse readBounded(
|
||||
int status, HttpHeaders headers, InputStream body, ResponseSizeLimiter limiter)
|
||||
throws IOException {
|
||||
try (InputStream counted = new CountingBoundedInputStream(body, limiter::recordWireBytes)) {
|
||||
byte[] bytes = counted.readAllBytes();
|
||||
limiter.recordDecodedBytes(bytes.length);
|
||||
return new RawResponse(status, toMap(headers), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> toMap(HttpHeaders headers) {
|
||||
Map<String, List<String>> copy = new java.util.LinkedHashMap<>();
|
||||
headers.forEach((name, values) -> copy.put(name, List.copyOf(values)));
|
||||
return copy;
|
||||
}
|
||||
|
||||
public void requireAllowedContentType(
|
||||
RawResponse response, ResponseLimits limits, HttpFailureMetadata metadata) {
|
||||
if (response.bodyLength() == 0) {
|
||||
return;
|
||||
}
|
||||
String contentType = response.firstHeader(HttpHeaders.CONTENT_TYPE).orElse("");
|
||||
if (contentType.isEmpty() || limits.permits(contentType)) {
|
||||
return;
|
||||
}
|
||||
throw new HttpTargetRejectedException(
|
||||
"response content type is not permitted by the profile", metadata);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> T decode(
|
||||
RawResponse response, ResponseType<T> responseType, HttpFailureMetadata metadata) {
|
||||
if (responseType instanceof EmptyResponseType) {
|
||||
return null;
|
||||
}
|
||||
if (responseType instanceof ByteArrayResponseType) {
|
||||
return (T) response.body();
|
||||
}
|
||||
MediaType contentType =
|
||||
response
|
||||
.firstHeader(HttpHeaders.CONTENT_TYPE)
|
||||
.map(MediaType::parseMediaType)
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM);
|
||||
Type target = responseType.type();
|
||||
try {
|
||||
for (HttpMessageConverter<?> converter : converters) {
|
||||
if (responseType instanceof ClassResponseType<T> classType
|
||||
&& converter.canRead(classType.rawType(), contentType)) {
|
||||
HttpMessageConverter<T> typed = (HttpMessageConverter<T>) converter;
|
||||
return typed.read(classType.rawType(), inputMessage(response, contentType));
|
||||
}
|
||||
if (responseType instanceof GenericResponseType
|
||||
&& converter instanceof GenericHttpMessageConverter<?> generic
|
||||
&& generic.canRead(target, null, contentType)) {
|
||||
GenericHttpMessageConverter<T> typed = (GenericHttpMessageConverter<T>) generic;
|
||||
return typed.read(target, null, inputMessage(response, contentType));
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
throw new HttpSerializationException("response body could not be decoded", metadata, failure);
|
||||
}
|
||||
throw new HttpSerializationException(
|
||||
"no message converter can read the declared response type", metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for the typed client, which knows its target as a {@link
|
||||
* ParameterizedTypeReference}.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T> ResponseType<T> responseTypeOf(ParameterizedTypeReference<T> reference) {
|
||||
Type type = reference.getType();
|
||||
if (type == Void.class || type == void.class) {
|
||||
return (ResponseType<T>) ResponseType.empty();
|
||||
}
|
||||
if (type == byte[].class) {
|
||||
return (ResponseType<T>) ResponseType.ofBytes();
|
||||
}
|
||||
// A non-generic reference must become a ClassResponseType. Wrapping it as a generic one looked
|
||||
// harmless but sent every plain DTO down the GenericHttpMessageConverter branch, where the
|
||||
// converters this reader holds decline to read it — so a typed client that declared
|
||||
// `UserResponse` failed with "no message converter can read the declared response type".
|
||||
if (type instanceof Class<?> rawType) {
|
||||
return (ResponseType<T>) ResponseType.of(rawType);
|
||||
}
|
||||
return new GenericResponseType<>(type);
|
||||
}
|
||||
|
||||
private HttpInputMessage inputMessage(RawResponse response, MediaType contentType) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
response.headers().forEach(headers::addAll);
|
||||
headers.setContentType(contentType);
|
||||
byte[] body = response.body();
|
||||
return new HttpInputMessage() {
|
||||
@Override
|
||||
public InputStream getBody() {
|
||||
return new ByteArrayInputStream(body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders getHeaders() {
|
||||
return headers;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.restclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialProviderRegistry;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeFactory;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptResiliencePipeline;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.ExponentialFullJitterBackoff;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRegistry;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.BlockingTransportProvider;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportCapabilityValidator;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
|
||||
import java.time.Duration;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.random.RandomGenerator;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* Builds a blocking runtime generation for a profile (design §26.1).
|
||||
*
|
||||
* <p>Capability validation runs before any client exists, so a profile whose transport cannot
|
||||
* honour it fails at startup rather than at the first production request.
|
||||
*/
|
||||
public final class RestClientRuntimeFactory implements ClientRuntimeFactory {
|
||||
|
||||
private final Map<TransportType, BlockingTransportProvider> providers =
|
||||
new EnumMap<>(TransportType.class);
|
||||
private final TransportCapabilityValidator capabilityValidator =
|
||||
new TransportCapabilityValidator();
|
||||
private final ResilienceRegistry resilienceRegistry;
|
||||
private final CredentialProviderRegistry credentialProviders;
|
||||
private final BlockingExecutionSupport support;
|
||||
private final TransportLifecycleListener lifecycleListener;
|
||||
private final RandomGenerator random;
|
||||
|
||||
public RestClientRuntimeFactory(
|
||||
Map<TransportType, BlockingTransportProvider> providers,
|
||||
ResilienceRegistry resilienceRegistry,
|
||||
CredentialProviderRegistry credentialProviders,
|
||||
BlockingExecutionSupport support,
|
||||
TransportLifecycleListener lifecycleListener,
|
||||
RandomGenerator random) {
|
||||
Objects.requireNonNull(providers, "transport providers").forEach(this.providers::put);
|
||||
this.resilienceRegistry = Objects.requireNonNull(resilienceRegistry, "resilience registry");
|
||||
this.credentialProviders = Objects.requireNonNull(credentialProviders, "credential providers");
|
||||
this.support = Objects.requireNonNull(support, "execution support");
|
||||
this.lifecycleListener = Objects.requireNonNull(lifecycleListener, "lifecycle listener");
|
||||
this.random = Objects.requireNonNull(random, "random generator");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientRuntime create(ClientProfile profile, RuntimeGeneration generation) {
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
BlockingTransportProvider provider = providers.get(profile.transport());
|
||||
if (provider == null) {
|
||||
throw new HttpConfigurationException(
|
||||
"no blocking transport provider is registered for " + profile.transport(),
|
||||
HttpFailureMetadata.startup(profile.name()));
|
||||
}
|
||||
capabilityValidator.validate(profile, provider.capabilities());
|
||||
|
||||
ClientHttpRequestFactory requestFactory =
|
||||
provider.create(profile, generation, lifecycleListener);
|
||||
RestClient restClient =
|
||||
RestClient.builder()
|
||||
.requestFactory(requestFactory)
|
||||
.baseUrl(profile.baseUrl().toString())
|
||||
.build();
|
||||
|
||||
AttemptResiliencePipeline pipeline = newResiliencePipeline(profile);
|
||||
|
||||
RetryBudget retryBudget =
|
||||
profile
|
||||
.retry()
|
||||
.budget()
|
||||
.map(
|
||||
name ->
|
||||
resilienceRegistry.retryBudget(
|
||||
name, retryCapacity(profile), Duration.ofMinutes(1)))
|
||||
.orElseGet(RetryBudget::unlimited);
|
||||
|
||||
Supplier<BackoffStrategy> backoffFactory =
|
||||
() ->
|
||||
new ExponentialFullJitterBackoff(
|
||||
profile.retry().baseBackoff(),
|
||||
profile.retry().maxBackoff(),
|
||||
profile.retry().jitter(),
|
||||
profile.retry().retryAfter(),
|
||||
random);
|
||||
|
||||
return new BlockingClientRuntime(
|
||||
profile,
|
||||
generation,
|
||||
() -> provider.close(profile, generation),
|
||||
restClient,
|
||||
provider.id(),
|
||||
provider.failureClassifier(),
|
||||
pipeline,
|
||||
resilienceRegistry.admission(
|
||||
profile.name(),
|
||||
profile.pool().maxPendingAcquires() + profile.pool().maxTotalConnections()),
|
||||
retryBudget,
|
||||
backoffFactory,
|
||||
credentialProviders.require(profile.name(), profile.authentication().type()),
|
||||
support);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry capacity is derived from the pool budget rather than invented: a retry storm is bounded
|
||||
* by what the upstream can absorb, and the pool is the only declared statement of that.
|
||||
*/
|
||||
private long retryCapacity(ClientProfile profile) {
|
||||
return Math.max(1L, profile.pool().maxTotalConnections() / 10L);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the guard chain with its rejection metrics attached.
|
||||
*
|
||||
* <p>The three local back-pressure signals — circuit open, rate limited, bulkhead full — were
|
||||
* declared in the metric vocabulary and emitted by nothing, so a saturated client looked exactly
|
||||
* like a healthy one on a dashboard.
|
||||
*/
|
||||
private AttemptResiliencePipeline newResiliencePipeline(ClientProfile profile) {
|
||||
dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptCircuitBreaker breaker =
|
||||
resilienceRegistry.circuitBreaker(profile.name());
|
||||
return new AttemptResiliencePipeline(
|
||||
breaker,
|
||||
resilienceRegistry.rateLimiter(profile.name()),
|
||||
resilienceRegistry.bulkhead(profile.name(), profile.pool().maxTotalConnections()),
|
||||
() -> HttpFailureMetadata.startup(profile.name()),
|
||||
dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRejectionRecorder
|
||||
.micrometer(support.meterRegistry(), profile.name(), breaker::state));
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.security;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* An operation that has passed target, header, and body-size policy and is ready to execute (design
|
||||
* §12).
|
||||
*
|
||||
* <p>Nothing downstream re-derives a URL, re-adds a header, or re-checks a limit: the executor may
|
||||
* only use what this record already approved.
|
||||
*/
|
||||
public record PreparedOperation(
|
||||
HttpOperation operation,
|
||||
PreparedTarget target,
|
||||
Map<String, List<String>> headers,
|
||||
long maxRequestBytes,
|
||||
long maxResponseWireBytes,
|
||||
long maxResponseDecodedBytes) {
|
||||
|
||||
public PreparedOperation {
|
||||
Objects.requireNonNull(operation, "operation");
|
||||
Objects.requireNonNull(target, "target");
|
||||
Objects.requireNonNull(headers, "headers");
|
||||
headers = Map.copyOf(headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the operation's idempotency key is present in the headers that will be sent.
|
||||
*
|
||||
* <p>Read by the retry decision instead of {@code operation.idempotencyKey().isPresent()}. The
|
||||
* distinction is the whole point: a key the caller supplied but the platform never wrote gives
|
||||
* the upstream nothing to deduplicate against, so a repeat is a duplicate side effect rather than
|
||||
* a safe retry.
|
||||
*
|
||||
* @return {@code true} when a key exists and a header carries its exact value
|
||||
*/
|
||||
public boolean idempotencyKeySent() {
|
||||
return operation
|
||||
.idempotencyKey()
|
||||
.map(
|
||||
key ->
|
||||
headers.values().stream()
|
||||
.flatMap(List::stream)
|
||||
.anyMatch(value -> value.equals(key.value())))
|
||||
.orElse(false);
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.security;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Removes credentials when a redirect crosses an origin (design §12.4, §22.4).
|
||||
*
|
||||
* <p>Forwarding {@code Authorization} to a redirect target is a credential disclosure to whoever
|
||||
* controls that target, which for a Dynamic Target is by definition not us.
|
||||
*/
|
||||
public final class SensitiveHeaderStripper {
|
||||
|
||||
private static final Set<String> ALWAYS_STRIPPED =
|
||||
Set.of("authorization", "proxy-authorization", "cookie", "set-cookie");
|
||||
|
||||
/** Conventional API-key header names, stripped even when a profile names a different one. */
|
||||
private static final Set<String> CONVENTIONAL_API_KEY_HEADERS = Set.of("x-api-key", "api-key");
|
||||
|
||||
private final Set<String> additionalSensitiveHeaders;
|
||||
|
||||
private SensitiveHeaderStripper(Set<String> additionalSensitiveHeaders) {
|
||||
this.additionalSensitiveHeaders = additionalSensitiveHeaders;
|
||||
}
|
||||
|
||||
public static SensitiveHeaderStripper standard() {
|
||||
return new SensitiveHeaderStripper(CONVENTIONAL_API_KEY_HEADERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds profile-specific credential headers to the conventional set.
|
||||
*
|
||||
* <p>It adds rather than replaces, which its name always claimed and its behaviour did not. A
|
||||
* profile that named a custom API-key header — {@code X-Client-Key}, say — produced a stripper
|
||||
* that dropped only that one and forwarded {@code X-Api-Key} across an origin boundary, so
|
||||
* configuring a custom header made the default headers <em>less</em> protected than leaving it
|
||||
* alone.
|
||||
*
|
||||
* @param headerNames additional credential-bearing header names, case-insensitive
|
||||
* @return a stripper covering the conventional names plus these
|
||||
*/
|
||||
public static SensitiveHeaderStripper withAdditional(Set<String> headerNames) {
|
||||
Objects.requireNonNull(headerNames, "additional sensitive header names");
|
||||
Set<String> lower =
|
||||
java.util.stream.Stream.concat(
|
||||
CONVENTIONAL_API_KEY_HEADERS.stream(),
|
||||
headerNames.stream().map(name -> name.toLowerCase(Locale.ROOT)))
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
return new SensitiveHeaderStripper(lower);
|
||||
}
|
||||
|
||||
public Map<String, List<String>> stripForCrossOrigin(Map<String, List<String>> headers) {
|
||||
Map<String, List<String>> retained = new LinkedHashMap<>();
|
||||
headers.forEach(
|
||||
(name, values) -> {
|
||||
String lower = name.toLowerCase(Locale.ROOT);
|
||||
if (!ALWAYS_STRIPPED.contains(lower) && !additionalSensitiveHeaders.contains(lower)) {
|
||||
retained.put(name, List.copyOf(values));
|
||||
}
|
||||
});
|
||||
return Map.copyOf(retained);
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.security;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeFactory;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Rotates certificates and secrets by building a new runtime generation (design §7.2, §21.1).
|
||||
*
|
||||
* <p>Rotation is a generation swap rather than an in-place mutation because a connection pool holds
|
||||
* sockets that were established under the previous identity: replacing the material without
|
||||
* replacing the pool leaves live connections authenticated by a certificate that is meant to be
|
||||
* gone.
|
||||
*/
|
||||
public final class TlsRuntimeRotationCoordinator {
|
||||
|
||||
private final ClientRuntimeRegistry registry;
|
||||
private final ClientRuntimeFactory runtimeFactory;
|
||||
private final Duration drainTimeout;
|
||||
|
||||
public TlsRuntimeRotationCoordinator(
|
||||
ClientRuntimeRegistry registry, ClientRuntimeFactory runtimeFactory, Duration drainTimeout) {
|
||||
this.registry = Objects.requireNonNull(registry, "runtime registry");
|
||||
this.runtimeFactory = Objects.requireNonNull(runtimeFactory, "runtime factory");
|
||||
this.drainTimeout = Objects.requireNonNull(drainTimeout, "drain timeout");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates the profiles that actually use the rotated identity.
|
||||
*
|
||||
* <p>The identity argument used to be required and then ignored: every registered profile was
|
||||
* rotated whatever certificate had changed. Rotating a runtime is not free — it discards a warm
|
||||
* pool, forces fresh handshakes, and drains connections mid-flight — so a single certificate
|
||||
* renewal caused a connection storm across every upstream the service talks to, most of which had
|
||||
* nothing to do with that certificate. It also hid the real failure: if the rotation was wrong,
|
||||
* every profile degraded at once and nothing pointed at the cause.
|
||||
*
|
||||
* <p>A profile participates when its TLS settings name the rotated identity, either as the key
|
||||
* material reference or as the TLS profile id. A profile with no client certificate cannot be
|
||||
* affected by a client-certificate rotation at all.
|
||||
*
|
||||
* @param identity the certificate identity that changed
|
||||
* @return the profiles that were rotated, so a caller can log or assert on the blast radius
|
||||
*/
|
||||
public java.util.List<ClientProfileName> rotate(ClientCertificateIdentity identity) {
|
||||
Objects.requireNonNull(identity, "client certificate identity");
|
||||
java.util.List<ClientProfileName> rotated = new java.util.ArrayList<>();
|
||||
for (ClientProfileName name : registry.names()) {
|
||||
if (participatesIn(name, identity)) {
|
||||
rotateProfile(name);
|
||||
rotated.add(name);
|
||||
}
|
||||
}
|
||||
return java.util.List.copyOf(rotated);
|
||||
}
|
||||
|
||||
/** Whether this profile's TLS material is the one that rotated. */
|
||||
private boolean participatesIn(ClientProfileName name, ClientCertificateIdentity identity) {
|
||||
dev.caskeleton.adapter.outbound.httpclient.profile.TlsSettings tls =
|
||||
registry.current(name).profile().tls();
|
||||
if (tls.keyMaterialReference().isEmpty()) {
|
||||
// No client certificate: a client-certificate rotation cannot reach this profile.
|
||||
return false;
|
||||
}
|
||||
return tls.keyMaterialReference().filter(identity.value()::equals).isPresent()
|
||||
|| tls.profileId().filter(identity.value()::equals).isPresent();
|
||||
}
|
||||
|
||||
public void rotateProfile(ClientProfileName name) {
|
||||
ClientRuntime previous = registry.current(name);
|
||||
ClientRuntime replacement =
|
||||
runtimeFactory.create(previous.profile(), previous.generation().next());
|
||||
registry.swap(name, replacement, drainTimeout);
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.security;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpTargetRejectedException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.IdempotencyKeyRequirement;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Turns a caller's operation into a {@link PreparedOperation} for a Trusted profile (design §12.1).
|
||||
*
|
||||
* <p>An absolute URI is rejected here rather than sanitised: H2 exists to vary method, relative
|
||||
* path, query, approved headers, and body — not the destination. Changing the destination is what
|
||||
* H3 is for, and H3 has its own policy, credentials, and DNS validation.
|
||||
*/
|
||||
public final class TrustedTargetPolicy {
|
||||
|
||||
private final ClientProfile profile;
|
||||
private final UriTemplateExpander expander;
|
||||
private final BodyLimitPolicy bodyLimitPolicy;
|
||||
|
||||
public TrustedTargetPolicy(ClientProfile profile) {
|
||||
this.profile = Objects.requireNonNull(profile, "profile");
|
||||
this.expander = new UriTemplateExpander(profile.baseUrl());
|
||||
this.bodyLimitPolicy = BodyLimitPolicy.maxRequestBytes(profile.request().maxBodyBytes());
|
||||
}
|
||||
|
||||
public ClientProfile profile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares an operation, deriving its idempotency-key requirement from the operation itself.
|
||||
*
|
||||
* <p>Deriving rather than defaulting to {@link IdempotencyKeyRequirement#none()} is deliberate: a
|
||||
* default of "no key" silently dropped a key the caller had registered, and the request went out
|
||||
* without the one header that would have let the upstream deduplicate it.
|
||||
*
|
||||
* @param operation the caller's operation
|
||||
* @return the prepared operation, with the key rendered as a header when one exists
|
||||
*/
|
||||
public PreparedOperation prepare(HttpOperation operation) {
|
||||
return prepare(operation, requirementFor(operation));
|
||||
}
|
||||
|
||||
/**
|
||||
* The requirement an operation implies.
|
||||
*
|
||||
* @param operation the operation to inspect
|
||||
* @return {@code required} under the standard header name when the operation carries a key
|
||||
*/
|
||||
public static IdempotencyKeyRequirement requirementFor(HttpOperation operation) {
|
||||
return operation.idempotencyKey().isPresent()
|
||||
? IdempotencyKeyRequirement.required("Idempotency-Key")
|
||||
: IdempotencyKeyRequirement.none();
|
||||
}
|
||||
|
||||
public PreparedOperation prepare(
|
||||
HttpOperation operation, IdempotencyKeyRequirement idempotencyKeyRequirement) {
|
||||
Objects.requireNonNull(operation, "operation");
|
||||
HttpFailureMetadata metadata =
|
||||
HttpFailureMetadata.validation(
|
||||
profile.name(),
|
||||
operation.operationName(),
|
||||
operation.method(),
|
||||
operation.uriTemplate(),
|
||||
operation.body().replayability());
|
||||
|
||||
requireRelativeTemplate(operation.uriTemplate(), metadata);
|
||||
PreparedTarget target = expander.expand(operation.uriTemplate(), operation.uriVariables());
|
||||
requireAllowedOrigin(target, metadata);
|
||||
|
||||
Map<String, List<String>> headers =
|
||||
HeaderPolicy.forOperation(idempotencyKeyRequirement, false)
|
||||
.validate(operation.headers(), metadata);
|
||||
headers = withIdempotencyKey(operation, idempotencyKeyRequirement, headers, metadata);
|
||||
bodyLimitPolicy.validate(operation.body(), metadata);
|
||||
|
||||
return new PreparedOperation(
|
||||
operation,
|
||||
target,
|
||||
headers,
|
||||
profile.request().maxBodyBytes(),
|
||||
profile.response().maxWireBytes(),
|
||||
profile.response().maxDecodedBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the operation's idempotency key as the header the upstream will actually receive.
|
||||
*
|
||||
* <p>The platform owns this header. Before, the key was carried on the operation, checked for
|
||||
* presence by the retry engine, and never written to the wire: the upstream saw no key, could not
|
||||
* deduplicate, and the platform meanwhile treated a repeat as contractually safe. A duplicated
|
||||
* payment is the shape of that bug.
|
||||
*
|
||||
* <p>A caller-supplied value for the same header is refused rather than merged. Two keys for one
|
||||
* request is a contradiction, and silently preferring either one would decide on the caller's
|
||||
* behalf which request the upstream is allowed to deduplicate against.
|
||||
*/
|
||||
private Map<String, List<String>> withIdempotencyKey(
|
||||
HttpOperation operation,
|
||||
IdempotencyKeyRequirement requirement,
|
||||
Map<String, List<String>> headers,
|
||||
HttpFailureMetadata metadata) {
|
||||
if (operation.idempotencyKey().isEmpty()) {
|
||||
return headers;
|
||||
}
|
||||
if (!requirement.required()) {
|
||||
throw new HttpTargetRejectedException(
|
||||
"the operation carries an idempotency key but its descriptor does not register one, so "
|
||||
+ "the platform has no header to send it in",
|
||||
metadata);
|
||||
}
|
||||
String headerName = requirement.headerName();
|
||||
boolean callerSupplied =
|
||||
headers.keySet().stream()
|
||||
.anyMatch(
|
||||
name -> name.toLowerCase(Locale.ROOT).equals(headerName.toLowerCase(Locale.ROOT)));
|
||||
if (callerSupplied) {
|
||||
throw new HttpTargetRejectedException(
|
||||
"header " + headerName + " is owned by the platform and must not be supplied by a caller",
|
||||
metadata);
|
||||
}
|
||||
Map<String, List<String>> merged = new LinkedHashMap<>(headers);
|
||||
merged.put(headerName, List.of(operation.idempotencyKey().orElseThrow().value()));
|
||||
return Map.copyOf(merged);
|
||||
}
|
||||
|
||||
private void requireRelativeTemplate(String uriTemplate, HttpFailureMetadata metadata) {
|
||||
if (uriTemplate.isEmpty()) {
|
||||
throw new HttpTargetRejectedException("uri template must not be empty", metadata);
|
||||
}
|
||||
String lower = uriTemplate.toLowerCase(Locale.ROOT);
|
||||
if (lower.startsWith("//") || lower.contains("://")) {
|
||||
throw new HttpTargetRejectedException(
|
||||
"a trusted generic exchange accepts only a profile-relative uri template", metadata);
|
||||
}
|
||||
if (!uriTemplate.startsWith("/")) {
|
||||
throw new HttpTargetRejectedException(
|
||||
"uri template must start with '/' relative to the profile base url", metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-applies the profile's origin allowlist to a target the caller did not choose.
|
||||
*
|
||||
* <p>Public because a redirect hop needs it. The coordinator used to evaluate a hop against the
|
||||
* redirect policy alone — hop count, cross-origin flag, method rewrite — and never against the
|
||||
* profile's own allowed hosts and ports. An upstream could therefore redirect a trusted profile
|
||||
* to any origin the redirect policy tolerated, including one the operator had explicitly excluded
|
||||
* from the allowlist, and the platform would follow it.
|
||||
*
|
||||
* @param target the hop target
|
||||
* @param metadata failure metadata for the rejection
|
||||
* @throws HttpTargetRejectedException when the host or port is not on the profile's allowlist
|
||||
*/
|
||||
public void requireAllowedTarget(java.net.URI target, HttpFailureMetadata metadata) {
|
||||
requireAllowedOrigin(PreparedTarget.of(target, target.getPath()), metadata);
|
||||
}
|
||||
|
||||
private void requireAllowedOrigin(PreparedTarget target, HttpFailureMetadata metadata) {
|
||||
if (!profile.allowedHosts().isEmpty() && !profile.allowedHosts().contains(target.host())) {
|
||||
throw new HttpTargetRejectedException(
|
||||
"target host is not on the profile allowlist", metadata);
|
||||
}
|
||||
if (!profile.allowedPorts().isEmpty() && !profile.allowedPorts().contains(target.port())) {
|
||||
throw new HttpTargetRejectedException(
|
||||
"target port is not on the profile allowlist", metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.service;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingClientRuntime;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.GenericHttpGateway;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
|
||||
|
||||
/**
|
||||
* Builds validated typed clients over a profile's immutable RestClient (design §26.3).
|
||||
*
|
||||
* <p>Interfaces are scanned and validated at creation, and the resulting proxy is cached per
|
||||
* (profile, interface): building it per call would re-run reflection on every request and lose the
|
||||
* startup-failure guarantee.
|
||||
*/
|
||||
public final class DefaultHttpServiceRegistry implements HttpServiceRegistry {
|
||||
|
||||
private final ClientRuntimeRegistry runtimes;
|
||||
private final GenericHttpGateway gateway;
|
||||
private final ServiceOperationDescriptorScanner scanner;
|
||||
private final OperationContextHolder contextHolder;
|
||||
private final Map<String, Object> clients = new ConcurrentHashMap<>();
|
||||
|
||||
public DefaultHttpServiceRegistry(ClientRuntimeRegistry runtimes, GenericHttpGateway gateway) {
|
||||
this(
|
||||
runtimes,
|
||||
gateway,
|
||||
new ServiceOperationDescriptorScanner(),
|
||||
OperationContextHolder.instance());
|
||||
}
|
||||
|
||||
public DefaultHttpServiceRegistry(
|
||||
ClientRuntimeRegistry runtimes,
|
||||
GenericHttpGateway gateway,
|
||||
ServiceOperationDescriptorScanner scanner,
|
||||
OperationContextHolder contextHolder) {
|
||||
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
|
||||
this.gateway = Objects.requireNonNull(gateway, "generic http gateway");
|
||||
this.scanner = Objects.requireNonNull(scanner, "descriptor scanner");
|
||||
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T client(Class<T> serviceType) {
|
||||
return client(scanner.profileOf(serviceType), serviceType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T client(ClientProfileName profileName, Class<T> serviceType) {
|
||||
Objects.requireNonNull(profileName, "profile name");
|
||||
Objects.requireNonNull(serviceType, "service type");
|
||||
String key = profileName.value() + '|' + serviceType.getName();
|
||||
return serviceType.cast(
|
||||
clients.computeIfAbsent(key, ignored -> build(profileName, serviceType)));
|
||||
}
|
||||
|
||||
private <T> T build(ClientProfileName profileName, Class<T> serviceType) {
|
||||
List<ServiceOperationDescriptor> descriptors = scanner.scan(serviceType);
|
||||
ClientProfileName declared = scanner.profileOf(serviceType);
|
||||
if (!declared.equals(profileName)) {
|
||||
throw new HttpConfigurationException(
|
||||
"interface "
|
||||
+ serviceType.getName()
|
||||
+ " declares profile "
|
||||
+ declared.value()
|
||||
+ " but was requested for "
|
||||
+ profileName.value(),
|
||||
HttpFailureMetadata.startup(profileName));
|
||||
}
|
||||
if (descriptors.stream().anyMatch(ServiceOperationDescriptor::reactive)) {
|
||||
throw new HttpConfigurationException(
|
||||
"interface " + serviceType.getName() + " is reactive; use the reactive registry",
|
||||
HttpFailureMetadata.startup(profileName));
|
||||
}
|
||||
|
||||
try (ClientRuntimeLease lease = runtimes.acquire(profileName)) {
|
||||
if (!(lease.runtime() instanceof BlockingClientRuntime)) {
|
||||
throw new HttpConfigurationException(
|
||||
"profile " + profileName.value() + " is not configured for the blocking api",
|
||||
HttpFailureMetadata.startup(profileName));
|
||||
}
|
||||
}
|
||||
|
||||
// Deliberately not RestClientAdapter over the profile's RestClient. That adapter reaches the
|
||||
// network directly, so a typed call bypassed target policy, credentials, admission, deadline,
|
||||
// resilience, byte limits, stable error mapping and observation — every guarantee the profile
|
||||
// exists to provide. Routing through the gateway makes the typed surface the same code path as
|
||||
// the generic one rather than a parallel one that happens to look similar.
|
||||
T springProxy =
|
||||
HttpServiceProxyFactory.builderFor(
|
||||
new KernelHttpExchangeAdapter(gateway, profileName, contextHolder))
|
||||
.build()
|
||||
.createClient(serviceType);
|
||||
|
||||
Map<Method, ServiceOperationDescriptor> byMethod = new LinkedHashMap<>();
|
||||
descriptors.forEach(descriptor -> byMethod.put(descriptor.method(), descriptor));
|
||||
|
||||
InvocationHandler handler =
|
||||
new BlockingServiceInvocationHandler(springProxy, profileName, byMethod, contextHolder);
|
||||
return serviceType.cast(
|
||||
Proxy.newProxyInstance(
|
||||
serviceType.getClassLoader(), new Class<?>[] {serviceType}, handler));
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.service;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveClientRuntime;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveHttpGateway;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
|
||||
|
||||
/**
|
||||
* Builds validated reactive typed clients over a profile's immutable WebClient (design §26.3).
|
||||
*
|
||||
* <p>An interface that is entirely blocking is rejected here for the same reason its mirror image
|
||||
* is rejected by the blocking registry: one interface, one execution model.
|
||||
*/
|
||||
public final class DefaultReactiveHttpServiceRegistry implements ReactiveHttpServiceRegistry {
|
||||
|
||||
/**
|
||||
* Only reached by a caller that blocks on a reactive typed method, which the platform forbids.
|
||||
*/
|
||||
private static final Duration BLOCK_TIMEOUT = Duration.ofSeconds(30);
|
||||
|
||||
private final ClientRuntimeRegistry runtimes;
|
||||
private final ReactiveHttpGateway gateway;
|
||||
private final ServiceOperationDescriptorScanner scanner;
|
||||
private final OperationContextHolder contextHolder;
|
||||
private final Map<String, Object> clients = new ConcurrentHashMap<>();
|
||||
|
||||
public DefaultReactiveHttpServiceRegistry(
|
||||
ClientRuntimeRegistry runtimes, ReactiveHttpGateway gateway) {
|
||||
this(
|
||||
runtimes,
|
||||
gateway,
|
||||
new ServiceOperationDescriptorScanner(),
|
||||
OperationContextHolder.instance());
|
||||
}
|
||||
|
||||
public DefaultReactiveHttpServiceRegistry(
|
||||
ClientRuntimeRegistry runtimes,
|
||||
ReactiveHttpGateway gateway,
|
||||
ServiceOperationDescriptorScanner scanner,
|
||||
OperationContextHolder contextHolder) {
|
||||
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
|
||||
this.gateway = Objects.requireNonNull(gateway, "reactive http gateway");
|
||||
this.scanner = Objects.requireNonNull(scanner, "descriptor scanner");
|
||||
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T client(Class<T> serviceType) {
|
||||
return client(scanner.profileOf(serviceType), serviceType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T client(ClientProfileName profileName, Class<T> serviceType) {
|
||||
Objects.requireNonNull(profileName, "profile name");
|
||||
Objects.requireNonNull(serviceType, "service type");
|
||||
String key = profileName.value() + '|' + serviceType.getName();
|
||||
return serviceType.cast(
|
||||
clients.computeIfAbsent(key, ignored -> build(profileName, serviceType)));
|
||||
}
|
||||
|
||||
private <T> T build(ClientProfileName profileName, Class<T> serviceType) {
|
||||
List<ServiceOperationDescriptor> descriptors = scanner.scan(serviceType);
|
||||
if (descriptors.stream().anyMatch(descriptor -> !descriptor.reactive())) {
|
||||
throw new HttpConfigurationException(
|
||||
"interface " + serviceType.getName() + " is blocking; use the blocking registry",
|
||||
HttpFailureMetadata.startup(profileName));
|
||||
}
|
||||
|
||||
try (ClientRuntimeLease lease = runtimes.acquire(profileName)) {
|
||||
if (!(lease.runtime() instanceof ReactiveClientRuntime)) {
|
||||
throw new HttpConfigurationException(
|
||||
"profile " + profileName.value() + " is not configured for the reactive api",
|
||||
HttpFailureMetadata.startup(profileName));
|
||||
}
|
||||
}
|
||||
|
||||
// Not WebClientAdapter over the raw WebClient: that reached the network with no target policy,
|
||||
// credential, admission, deadline, resilience, byte limit, stable error mapping or observation.
|
||||
T springProxy =
|
||||
HttpServiceProxyFactory.builderFor(
|
||||
new ReactiveKernelHttpExchangeAdapter(
|
||||
gateway, profileName, contextHolder, BLOCK_TIMEOUT))
|
||||
.build()
|
||||
.createClient(serviceType);
|
||||
|
||||
Map<Method, ServiceOperationDescriptor> byMethod = new LinkedHashMap<>();
|
||||
descriptors.forEach(descriptor -> byMethod.put(descriptor.method(), descriptor));
|
||||
|
||||
InvocationHandler handler =
|
||||
new ReactiveServiceInvocationHandler(springProxy, profileName, byMethod, contextHolder);
|
||||
return serviceType.cast(
|
||||
Proxy.newProxyInstance(
|
||||
serviceType.getClassLoader(), new Class<?>[] {serviceType}, handler));
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.service;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.OperationName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.ObjectBody;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.GenericHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.RestClientResponseReader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.service.invoker.HttpExchangeAdapter;
|
||||
import org.springframework.web.service.invoker.HttpRequestValues;
|
||||
|
||||
/**
|
||||
* Runs every typed-client call through the platform kernel instead of a raw Spring client.
|
||||
*
|
||||
* <p>The typed registries used to hand {@code RestClientAdapter.create(runtime.restClient())} to
|
||||
* Spring's proxy factory, which meant an {@code @HttpExchange} method reached the network through
|
||||
* the profile's {@code RestClient} and nothing else. It passed no trusted-target policy, resolved
|
||||
* no credential, took no admission permit, respected no total deadline, entered no circuit breaker,
|
||||
* rate limiter or bulkhead, produced no evidence-based retry, applied no request or response byte
|
||||
* limit, mapped no stable exception, and recorded no logical or attempt observation. The proxy did
|
||||
* publish an operation descriptor into a thread local, but no code on the execution path read it —
|
||||
* so the descriptor described a call the platform was not making.
|
||||
*
|
||||
* <p>This adapter is the seam that closes that. Spring still owns argument binding and response
|
||||
* decoding, which is where its annotation model earns its place; the exchange itself is translated
|
||||
* into an {@link HttpOperation} and handed to {@link GenericHttpGateway}, the same entry the
|
||||
* generic H2 surface uses. Everything listed above therefore applies to a typed client because it
|
||||
* is the same code path, not because it was reimplemented alongside.
|
||||
*
|
||||
* <p>The descriptor comes from {@link OperationContextHolder}, bound by the invocation handler for
|
||||
* the duration of the call. A call with no binding is refused rather than executed with invented
|
||||
* defaults: a typed method whose descriptor the scanner never produced has no declared idempotency,
|
||||
* and guessing one is exactly the decision this platform exists to stop callers making by accident.
|
||||
*/
|
||||
public final class KernelHttpExchangeAdapter implements HttpExchangeAdapter {
|
||||
|
||||
private final GenericHttpGateway gateway;
|
||||
private final ClientProfileName profileName;
|
||||
private final OperationContextHolder contextHolder;
|
||||
|
||||
public KernelHttpExchangeAdapter(
|
||||
GenericHttpGateway gateway,
|
||||
ClientProfileName profileName,
|
||||
OperationContextHolder contextHolder) {
|
||||
this.gateway = Objects.requireNonNull(gateway, "generic http gateway");
|
||||
this.profileName = Objects.requireNonNull(profileName, "profile name");
|
||||
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
|
||||
}
|
||||
|
||||
/**
|
||||
* Request attributes are not supported.
|
||||
*
|
||||
* <p>They are a Spring-side side channel into the underlying client, and the platform's whole
|
||||
* position is that the underlying client is not reachable from a caller.
|
||||
*/
|
||||
@Override
|
||||
public boolean supportsRequestAttributes() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exchange(HttpRequestValues values) {
|
||||
execute(values, ResponseType.empty());
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders exchangeForHeaders(HttpRequestValues values) {
|
||||
return headersOf(execute(values, ResponseType.empty()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
|
||||
return execute(values, responseTypeOf(bodyType)).body();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Void> exchangeForBodilessEntity(HttpRequestValues values) {
|
||||
HttpCallResult<Void> result = execute(values, ResponseType.empty());
|
||||
return ResponseEntity.status(statusOf(result)).headers(headersOf(result)).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> ResponseEntity<T> exchangeForEntity(
|
||||
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
|
||||
HttpCallResult<T> result = execute(values, responseTypeOf(bodyType));
|
||||
return ResponseEntity.status(statusOf(result)).headers(headersOf(result)).body(result.body());
|
||||
}
|
||||
|
||||
private <T> HttpCallResult<T> execute(HttpRequestValues values, ResponseType<T> responseType) {
|
||||
ServiceOperationDescriptor descriptor = requireDescriptor();
|
||||
return gateway.exchange(profileName, operationOf(values, descriptor), responseType);
|
||||
}
|
||||
|
||||
private ServiceOperationDescriptor requireDescriptor() {
|
||||
return contextHolder
|
||||
.current()
|
||||
.filter(binding -> binding.clientName().equals(profileName))
|
||||
.map(OperationContextHolder.Binding::descriptor)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new HttpConfigurationException(
|
||||
"a typed client call for profile "
|
||||
+ profileName.value()
|
||||
+ " reached the platform without a registered operation descriptor",
|
||||
HttpFailureMetadata.startup(profileName)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates Spring's request values into a platform operation.
|
||||
*
|
||||
* <p>The URI template is carried through unexpanded. Spring hands over both the template and its
|
||||
* variables, and keeping them separate is what lets observability tags stay low-cardinality and
|
||||
* lets the trusted-target policy expand the path itself against the profile's base URL.
|
||||
*/
|
||||
private HttpOperation operationOf(
|
||||
HttpRequestValues values, ServiceOperationDescriptor descriptor) {
|
||||
String uriTemplate = values.getUriTemplate();
|
||||
if (uriTemplate == null) {
|
||||
// A pre-expanded URI would let a typed method choose its own destination, which is what H3
|
||||
// and its separate policy exist for.
|
||||
throw new HttpConfigurationException(
|
||||
"typed operation "
|
||||
+ descriptor.operationName().value()
|
||||
+ " must declare a profile-relative uri template rather than an absolute url",
|
||||
HttpFailureMetadata.startup(profileName));
|
||||
}
|
||||
org.springframework.http.HttpMethod method = values.getHttpMethod();
|
||||
if (method == null) {
|
||||
throw new HttpConfigurationException(
|
||||
"typed operation " + descriptor.operationName().value() + " declares no http method",
|
||||
HttpFailureMetadata.startup(profileName));
|
||||
}
|
||||
|
||||
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||
Optional<IdempotencyKey> idempotencyKey = Optional.empty();
|
||||
String keyHeader = descriptor.idempotencyKeyRequirement().headerName();
|
||||
// headerSet() rather than entrySet(): Spring 7's HttpHeaders is no longer a MultiValueMap.
|
||||
for (Map.Entry<String, List<String>> header : values.getHeaders().headerSet()) {
|
||||
if (descriptor.idempotencyKeyRequirement().required()
|
||||
&& header.getKey().equalsIgnoreCase(keyHeader)) {
|
||||
// Lifted out of the headers and onto the operation, so the trusted-target policy renders it
|
||||
// and the retry decision can see that it was actually sent.
|
||||
idempotencyKey = Optional.of(new IdempotencyKey(header.getValue().getFirst()));
|
||||
continue;
|
||||
}
|
||||
headers.put(header.getKey(), List.copyOf(new ArrayList<>(header.getValue())));
|
||||
}
|
||||
if (descriptor.idempotencyKeyRequirement().required() && idempotencyKey.isEmpty()) {
|
||||
throw new HttpConfigurationException(
|
||||
"typed operation "
|
||||
+ descriptor.operationName().value()
|
||||
+ " requires an idempotency key but the call supplied no "
|
||||
+ keyHeader
|
||||
+ " header",
|
||||
HttpFailureMetadata.startup(profileName));
|
||||
}
|
||||
|
||||
return new HttpOperation(
|
||||
descriptor.operationName(),
|
||||
HttpMethod.valueOf(method.name()),
|
||||
uriTemplate,
|
||||
Map.copyOf(values.getUriVariables()),
|
||||
Map.copyOf(headers),
|
||||
bodyOf(values),
|
||||
descriptor.idempotency(),
|
||||
idempotencyKey,
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
private BodySource bodyOf(HttpRequestValues values) {
|
||||
Object body = values.getBodyValue();
|
||||
return body == null ? EmptyBody.instance() : ObjectBody.json(body);
|
||||
}
|
||||
|
||||
private <T> ResponseType<T> responseTypeOf(ParameterizedTypeReference<T> bodyType) {
|
||||
return RestClientResponseReader.responseTypeOf(bodyType);
|
||||
}
|
||||
|
||||
private HttpStatusCode statusOf(HttpCallResult<?> result) {
|
||||
return HttpStatusCode.valueOf(result.status().value());
|
||||
}
|
||||
|
||||
private HttpHeaders headersOf(HttpCallResult<?> result) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
result.headers().forEach((name, valueList) -> valueList.forEach(v -> headers.add(name, v)));
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** The operation name a call is executing, for tests and diagnostics. */
|
||||
OperationName currentOperation() {
|
||||
return requireDescriptor().operationName();
|
||||
}
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.service;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.IdempotencyKey;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.ObjectBody;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.RestClientResponseReader;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.webclient.ReactiveHttpGateway;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.core.ReactiveAdapterRegistry;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.service.invoker.HttpRequestValues;
|
||||
import org.springframework.web.service.invoker.ReactorHttpExchangeAdapter;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* The reactive twin of {@link KernelHttpExchangeAdapter}.
|
||||
*
|
||||
* <p>The reactive typed registry had the same defect as the blocking one, with the same
|
||||
* consequence: {@code WebClientAdapter.create(runtime.webClient())} handed Spring the profile's raw
|
||||
* {@code WebClient}, so a reactive typed call reached the network with none of the platform's
|
||||
* guarantees. It also put a descriptor into the Reactor context that no code on the execution path
|
||||
* read.
|
||||
*
|
||||
* <p>Routing through {@link ReactiveHttpGateway} makes the reactive typed surface the same
|
||||
* execution path as the reactive generic one. The subscription is where the work happens, so the
|
||||
* descriptor is captured at assembly time — the thread that assembles a {@code Mono} is not
|
||||
* necessarily the thread that subscribes to it, and reading a thread local at subscription would
|
||||
* find nothing.
|
||||
*/
|
||||
public final class ReactiveKernelHttpExchangeAdapter implements ReactorHttpExchangeAdapter {
|
||||
|
||||
private final ReactiveHttpGateway gateway;
|
||||
private final ClientProfileName profileName;
|
||||
private final OperationContextHolder contextHolder;
|
||||
private final Duration blockTimeout;
|
||||
|
||||
public ReactiveKernelHttpExchangeAdapter(
|
||||
ReactiveHttpGateway gateway,
|
||||
ClientProfileName profileName,
|
||||
OperationContextHolder contextHolder,
|
||||
Duration blockTimeout) {
|
||||
this.gateway = Objects.requireNonNull(gateway, "reactive http gateway");
|
||||
this.profileName = Objects.requireNonNull(profileName, "profile name");
|
||||
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
|
||||
this.blockTimeout = Objects.requireNonNull(blockTimeout, "block timeout");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsRequestAttributes() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReactiveAdapterRegistry getReactiveAdapterRegistry() {
|
||||
return ReactiveAdapterRegistry.getSharedInstance();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Duration getBlockTimeout() {
|
||||
return blockTimeout;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> exchangeForMono(HttpRequestValues values) {
|
||||
return execute(values, ResponseType.empty()).then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<HttpHeaders> exchangeForHeadersMono(HttpRequestValues values) {
|
||||
return execute(values, ResponseType.empty()).map(this::headersOf);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> Mono<T> exchangeForBodyMono(
|
||||
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
|
||||
return execute(values, RestClientResponseReader.responseTypeOf(bodyType))
|
||||
.mapNotNull(HttpCallResult::body);
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@code Flux} return type is served by decoding the whole body and then emitting its elements.
|
||||
*
|
||||
* <p>It is deliberately not a streaming subscription. A streamed response cannot be bounded by
|
||||
* the profile's decoded-byte limit or retried on evidence, and a typed method that looks like an
|
||||
* ordinary call must not quietly acquire different safety properties because its return type is a
|
||||
* {@code Flux}. Genuine streaming has its own gateway, with its own contract.
|
||||
*/
|
||||
@Override
|
||||
public <T> Flux<T> exchangeForBodyFlux(
|
||||
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
|
||||
return execute(values, RestClientResponseReader.<T>responseTypeOf(bodyType))
|
||||
.flatMapMany(result -> elementsOf(result.body()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ResponseEntity<Void>> exchangeForBodilessEntityMono(HttpRequestValues values) {
|
||||
return execute(values, ResponseType.empty())
|
||||
.map(result -> ResponseEntity.status(statusOf(result)).headers(headersOf(result)).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> Mono<ResponseEntity<T>> exchangeForEntityMono(
|
||||
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
|
||||
return execute(values, RestClientResponseReader.responseTypeOf(bodyType))
|
||||
.map(
|
||||
result ->
|
||||
ResponseEntity.status(statusOf(result))
|
||||
.headers(headersOf(result))
|
||||
.body(result.body()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> Mono<ResponseEntity<Flux<T>>> exchangeForEntityFlux(
|
||||
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
|
||||
return execute(values, RestClientResponseReader.<T>responseTypeOf(bodyType))
|
||||
.map(
|
||||
result ->
|
||||
ResponseEntity.status(statusOf(result))
|
||||
.headers(headersOf(result))
|
||||
.body(elementsOf(result.body())));
|
||||
}
|
||||
|
||||
/** Blocking-surface members of the interface are unreachable for a reactive typed client. */
|
||||
@Override
|
||||
public void exchange(HttpRequestValues values) {
|
||||
exchangeForMono(values).block(blockTimeout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders exchangeForHeaders(HttpRequestValues values) {
|
||||
return exchangeForHeadersMono(values).block(blockTimeout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T exchangeForBody(HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
|
||||
return exchangeForBodyMono(values, bodyType).block(blockTimeout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Void> exchangeForBodilessEntity(HttpRequestValues values) {
|
||||
return exchangeForBodilessEntityMono(values).block(blockTimeout);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> ResponseEntity<T> exchangeForEntity(
|
||||
HttpRequestValues values, ParameterizedTypeReference<T> bodyType) {
|
||||
return exchangeForEntityMono(values, bodyType).block(blockTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the descriptor now and defers the call.
|
||||
*
|
||||
* <p>The descriptor lookup is eager on purpose: it reads a thread local the invocation handler
|
||||
* bound around the assembly of this {@code Mono}, and by the time anything subscribes that
|
||||
* binding is gone.
|
||||
*/
|
||||
private <T> Mono<HttpCallResult<T>> execute(
|
||||
HttpRequestValues values, ResponseType<T> responseType) {
|
||||
ServiceOperationDescriptor descriptor = requireDescriptor();
|
||||
HttpOperation operation = operationOf(values, descriptor);
|
||||
return Mono.defer(() -> gateway.exchange(profileName, operation, responseType));
|
||||
}
|
||||
|
||||
private ServiceOperationDescriptor requireDescriptor() {
|
||||
return contextHolder
|
||||
.current()
|
||||
.filter(binding -> binding.clientName().equals(profileName))
|
||||
.map(OperationContextHolder.Binding::descriptor)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new HttpConfigurationException(
|
||||
"a reactive typed client call for profile "
|
||||
+ profileName.value()
|
||||
+ " reached the platform without a registered operation descriptor",
|
||||
HttpFailureMetadata.startup(profileName)));
|
||||
}
|
||||
|
||||
private HttpOperation operationOf(
|
||||
HttpRequestValues values, ServiceOperationDescriptor descriptor) {
|
||||
String uriTemplate = values.getUriTemplate();
|
||||
if (uriTemplate == null) {
|
||||
throw new HttpConfigurationException(
|
||||
"typed operation "
|
||||
+ descriptor.operationName().value()
|
||||
+ " must declare a profile-relative uri template rather than an absolute url",
|
||||
HttpFailureMetadata.startup(profileName));
|
||||
}
|
||||
org.springframework.http.HttpMethod method = values.getHttpMethod();
|
||||
if (method == null) {
|
||||
throw new HttpConfigurationException(
|
||||
"typed operation " + descriptor.operationName().value() + " declares no http method",
|
||||
HttpFailureMetadata.startup(profileName));
|
||||
}
|
||||
|
||||
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||
Optional<IdempotencyKey> idempotencyKey = Optional.empty();
|
||||
String keyHeader = descriptor.idempotencyKeyRequirement().headerName();
|
||||
for (Map.Entry<String, List<String>> header : values.getHeaders().headerSet()) {
|
||||
if (descriptor.idempotencyKeyRequirement().required()
|
||||
&& header.getKey().equalsIgnoreCase(keyHeader)) {
|
||||
idempotencyKey = Optional.of(new IdempotencyKey(header.getValue().getFirst()));
|
||||
continue;
|
||||
}
|
||||
headers.put(header.getKey(), List.copyOf(new ArrayList<>(header.getValue())));
|
||||
}
|
||||
if (descriptor.idempotencyKeyRequirement().required() && idempotencyKey.isEmpty()) {
|
||||
throw new HttpConfigurationException(
|
||||
"typed operation "
|
||||
+ descriptor.operationName().value()
|
||||
+ " requires an idempotency key but the call supplied no "
|
||||
+ keyHeader
|
||||
+ " header",
|
||||
HttpFailureMetadata.startup(profileName));
|
||||
}
|
||||
|
||||
return new HttpOperation(
|
||||
descriptor.operationName(),
|
||||
HttpMethod.valueOf(method.name()),
|
||||
uriTemplate,
|
||||
Map.copyOf(values.getUriVariables()),
|
||||
Map.copyOf(headers),
|
||||
bodyOf(values),
|
||||
descriptor.idempotency(),
|
||||
idempotencyKey,
|
||||
Optional.empty());
|
||||
}
|
||||
|
||||
private BodySource bodyOf(HttpRequestValues values) {
|
||||
Object body = values.getBodyValue();
|
||||
return body == null ? EmptyBody.instance() : ObjectBody.json(body);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> Flux<T> elementsOf(Object body) {
|
||||
if (body == null) {
|
||||
return Flux.empty();
|
||||
}
|
||||
if (body instanceof Collection<?> elements) {
|
||||
return Flux.fromIterable((Collection<T>) elements);
|
||||
}
|
||||
return Flux.just((T) body);
|
||||
}
|
||||
|
||||
private HttpStatusCode statusOf(HttpCallResult<?> result) {
|
||||
return HttpStatusCode.valueOf(result.status().value());
|
||||
}
|
||||
|
||||
private HttpHeaders headersOf(HttpCallResult<?> result) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
result.headers().forEach((name, valueList) -> valueList.forEach(v -> headers.add(name, v)));
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.service;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Attaches the operation descriptor to the Reactor Context of the returned publisher (design §26.3
|
||||
* step 7).
|
||||
*
|
||||
* <p>A non-reactive return value is rejected rather than adapted: adapting it would mean blocking
|
||||
* somewhere, and design §18.2 forbids that on this path.
|
||||
*/
|
||||
public final class ReactiveServiceInvocationHandler implements InvocationHandler {
|
||||
|
||||
private final Object delegate;
|
||||
private final ClientProfileName profileName;
|
||||
private final Map<Method, ServiceOperationDescriptor> descriptors;
|
||||
private final OperationContextHolder contextHolder;
|
||||
|
||||
public ReactiveServiceInvocationHandler(
|
||||
Object delegate,
|
||||
ClientProfileName profileName,
|
||||
Map<Method, ServiceOperationDescriptor> descriptors,
|
||||
OperationContextHolder contextHolder) {
|
||||
this.delegate = Objects.requireNonNull(delegate, "delegate proxy");
|
||||
this.profileName = Objects.requireNonNull(profileName, "profile name");
|
||||
this.descriptors = Map.copyOf(Objects.requireNonNull(descriptors, "descriptors"));
|
||||
this.contextHolder = Objects.requireNonNull(contextHolder, "operation context holder");
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the descriptor for the duration of assembly, then attaches it to the returned publisher.
|
||||
*
|
||||
* <p>Two bindings for two readers. The Reactor context reaches operators that run at subscription
|
||||
* time; the thread-local reaches the exchange adapter, which translates the call into a platform
|
||||
* operation while this method is still on the stack. Only the second one existed as a consumer
|
||||
* before — and nothing read it, because the raw {@code WebClient} was doing the work.
|
||||
*/
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
|
||||
ServiceOperationDescriptor descriptor = descriptors.get(method);
|
||||
Object result;
|
||||
if (descriptor == null) {
|
||||
result = invokeDelegate(method, args);
|
||||
} else {
|
||||
try (AutoCloseable binding = contextHolder.bind(profileName, descriptor)) {
|
||||
result = invokeDelegate(method, args);
|
||||
}
|
||||
}
|
||||
if (descriptor == null) {
|
||||
return result;
|
||||
}
|
||||
if (result instanceof Mono<?> mono) {
|
||||
return mono.contextWrite(context -> context.put(ReactiveOperationContext.KEY, descriptor));
|
||||
}
|
||||
if (result instanceof Flux<?> flux) {
|
||||
return flux.contextWrite(context -> context.put(ReactiveOperationContext.KEY, descriptor));
|
||||
}
|
||||
throw new HttpConfigurationException(
|
||||
"reactive service method must return Mono or Flux",
|
||||
HttpFailureMetadata.startup(profileName));
|
||||
}
|
||||
|
||||
private Object invokeDelegate(Method method, Object[] args) throws Throwable {
|
||||
try {
|
||||
return method.invoke(delegate, args);
|
||||
} catch (InvocationTargetException invocationFailure) {
|
||||
throw invocationFailure.getCause();
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.transport;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
|
||||
/**
|
||||
* Blocking transport SPI (design §13.1).
|
||||
*
|
||||
* <p>The provider returns a Spring {@link ClientHttpRequestFactory} and never the native engine
|
||||
* client: design D-04 and §9.5 make native access an internal concern of this package.
|
||||
*
|
||||
* <p>Resources belong to a <em>generation</em>, not to a profile. Keying them by profile name meant
|
||||
* a rotation overwrote the map entry with the new generation's client, and the old generation's
|
||||
* closer — which runs after its drain completes — then closed the <em>replacement</em> while the
|
||||
* connections it was supposed to release stayed open. Every rotation leaked one pool and broke the
|
||||
* live one.
|
||||
*/
|
||||
public interface BlockingTransportProvider {
|
||||
|
||||
TransportId id();
|
||||
|
||||
BlockingTransportCapabilities capabilities();
|
||||
|
||||
ClientHttpRequestFactory create(
|
||||
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener);
|
||||
|
||||
TransportFailureClassifier failureClassifier();
|
||||
|
||||
/** Releases the engine resources created for exactly this profile generation. */
|
||||
void close(ClientProfile profile, RuntimeGeneration generation);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.transport;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
|
||||
import org.springframework.http.client.reactive.ClientHttpConnector;
|
||||
|
||||
/**
|
||||
* Reactive transport SPI (design §13.2).
|
||||
*
|
||||
* <p>Returns a Spring {@link ClientHttpConnector}; the native reactive client stays internal.
|
||||
*
|
||||
* <p>Resources are owned per generation, for the reason spelled out on {@link
|
||||
* BlockingTransportProvider}: a profile-keyed map lets a rotation's closer release the generation
|
||||
* that replaced it.
|
||||
*/
|
||||
public interface ReactiveTransportProvider {
|
||||
|
||||
TransportId id();
|
||||
|
||||
ReactiveTransportCapabilities capabilities();
|
||||
|
||||
ClientHttpConnector create(
|
||||
ClientProfile profile, RuntimeGeneration generation, TransportLifecycleListener listener);
|
||||
|
||||
TransportFailureClassifier failureClassifier();
|
||||
|
||||
void close(ClientProfile profile, RuntimeGeneration generation);
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.transport;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientMode;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.HttpProtocol;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Startup guard that fails when a transport is weaker than the profile it was selected for (design
|
||||
* §13.3, Task 8).
|
||||
*
|
||||
* <p>Messages name profile settings and capability names only — never a URL, address, or secret.
|
||||
*/
|
||||
public final class TransportCapabilityValidator {
|
||||
|
||||
public void validate(ClientProfile profile, BlockingTransportCapabilities capabilities) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
collectProtocolGaps(profile, capabilities.protocols(), missing);
|
||||
if (profile.pool().requiresRoutePool() && !capabilities.routeScopedPool()) {
|
||||
missing.add("route pool");
|
||||
}
|
||||
if (profile.pool().requiresBoundedPendingQueue()
|
||||
&& !capabilities.boundedPendingAcquireQueue()) {
|
||||
missing.add("bounded pending acquire queue");
|
||||
}
|
||||
if (profile.proxy().enabled() && !capabilities.proxySupport()) {
|
||||
missing.add("proxy");
|
||||
}
|
||||
if (profile.tls().mutualTls() && !capabilities.mutualTls()) {
|
||||
missing.add("mutual TLS");
|
||||
}
|
||||
if (profile.mode() == ClientMode.DYNAMIC && !capabilities.dynamicTargetStable()) {
|
||||
missing.add("validated DNS pinning for dynamic targets");
|
||||
}
|
||||
if (profile.mode() == ClientMode.DYNAMIC && !capabilities.validatedDnsPinning()) {
|
||||
// `validatedDnsPinning` was declared on every capability record and read by nothing. It is
|
||||
// the capability that decides whether the SSRF address validation survives to the socket, so
|
||||
// a transport that does not have it cannot serve a dynamic target no matter what its
|
||||
// `dynamicTargetStable` flag says — the two were being conflated.
|
||||
missing.add("call-scoped validated DNS pinning");
|
||||
}
|
||||
reject(profile, missing);
|
||||
}
|
||||
|
||||
public void validate(ClientProfile profile, ReactiveTransportCapabilities capabilities) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
collectProtocolGaps(profile, capabilities.protocols(), missing);
|
||||
if (profile.pool().requiresRoutePool() && !capabilities.routeScopedPool()) {
|
||||
missing.add("route pool");
|
||||
}
|
||||
if (profile.pool().requiresBoundedPendingQueue()
|
||||
&& !capabilities.boundedPendingAcquireQueue()) {
|
||||
missing.add("bounded pending acquire queue");
|
||||
}
|
||||
if (profile.proxy().enabled() && !capabilities.proxySupport()) {
|
||||
missing.add("proxy");
|
||||
}
|
||||
if (profile.tls().mutualTls() && !capabilities.mutualTls()) {
|
||||
missing.add("mutual TLS");
|
||||
}
|
||||
if (profile.mode() == ClientMode.DYNAMIC && !capabilities.dynamicTargetStable()) {
|
||||
missing.add("validated DNS pinning for dynamic targets");
|
||||
}
|
||||
reject(profile, missing);
|
||||
}
|
||||
|
||||
private void collectProtocolGaps(
|
||||
ClientProfile profile, java.util.Set<HttpProtocol> supported, List<String> missing) {
|
||||
for (HttpProtocol protocol : profile.protocols()) {
|
||||
if (!supported.contains(protocol)) {
|
||||
missing.add(protocol.name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void reject(ClientProfile profile, List<String> missing) {
|
||||
if (missing.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
throw new HttpConfigurationException(
|
||||
"transport capability is weaker than profile "
|
||||
+ profile.name().value()
|
||||
+ " requires: "
|
||||
+ String.join(", ", missing),
|
||||
HttpFailureMetadata.startup(profile.name()));
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.transport;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Identifies the engine resources belonging to one profile generation.
|
||||
*
|
||||
* <p>Transport providers used to key their pools and clients by profile name alone. A rotation
|
||||
* creates generation N+1 while generation N is still draining, so the new entry overwrote the old
|
||||
* one; when N's drain finished and its closer ran, it looked up the profile name and closed N+1 —
|
||||
* the generation that was serving traffic — while N's sockets stayed open. Each rotation therefore
|
||||
* leaked a pool and broke the live client, which is the opposite of what draining is for.
|
||||
*
|
||||
* @param profileName the profile the resources serve
|
||||
* @param generation the generation that owns them
|
||||
*/
|
||||
public record TransportResourceKey(ClientProfileName profileName, RuntimeGeneration generation) {
|
||||
|
||||
public TransportResourceKey {
|
||||
Objects.requireNonNull(profileName, "profile name");
|
||||
Objects.requireNonNull(generation, "runtime generation");
|
||||
}
|
||||
}
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.webclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpAmbiguousExecutionException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRateLimitRejectedException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.observation.LogicalCallObservation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.Deadline;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.ReactiveLogicalCall;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.ReactiveRetryCoordinator;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryAllowed;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryContext;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.StatusHandlingPolicy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Reactive H2 gateway (design §9.3, §26.2).
|
||||
*
|
||||
* <p>Runtime leases are acquired and released with {@code Mono.usingWhen} so a generation cannot be
|
||||
* closed underneath an in-flight subscription, and operation metadata travels in the Reactor
|
||||
* Context rather than a ThreadLocal — which would be wrong the moment the pipeline switches
|
||||
* threads.
|
||||
*/
|
||||
public final class DefaultReactiveHttpGateway implements ReactiveHttpGateway {
|
||||
|
||||
private final ClientRuntimeRegistry runtimes;
|
||||
private final ReactiveAttemptExecutor executor;
|
||||
|
||||
public DefaultReactiveHttpGateway(
|
||||
ClientRuntimeRegistry runtimes, ReactiveAttemptExecutor executor) {
|
||||
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
|
||||
this.executor = Objects.requireNonNull(executor, "reactive attempt executor");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> Mono<HttpCallResult<T>> exchange(
|
||||
ClientProfileName profileName, HttpOperation operation, ResponseType<T> responseType) {
|
||||
return exchange(
|
||||
profileName,
|
||||
operation,
|
||||
Optional.empty(),
|
||||
responseType,
|
||||
StatusHandlingPolicy.THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
public <T> Mono<HttpCallResult<T>> exchange(
|
||||
ClientProfileName profileName,
|
||||
HttpOperation operation,
|
||||
Optional<ReactiveBodySource> reactiveBody,
|
||||
ResponseType<T> responseType,
|
||||
StatusHandlingPolicy statusHandlingPolicy) {
|
||||
Objects.requireNonNull(profileName, "profile name");
|
||||
Objects.requireNonNull(operation, "operation");
|
||||
Objects.requireNonNull(responseType, "response type");
|
||||
|
||||
return Mono.usingWhen(
|
||||
Mono.fromSupplier(() -> runtimes.acquire(profileName)),
|
||||
lease -> execute(lease, operation, reactiveBody, responseType, statusHandlingPolicy),
|
||||
lease -> Mono.fromRunnable(lease::close),
|
||||
(lease, failure) -> Mono.fromRunnable(lease::close),
|
||||
lease -> Mono.fromRunnable(lease::close));
|
||||
}
|
||||
|
||||
private <T> Mono<HttpCallResult<T>> execute(
|
||||
ClientRuntimeLease lease,
|
||||
HttpOperation operation,
|
||||
Optional<ReactiveBodySource> reactiveBody,
|
||||
ResponseType<T> responseType,
|
||||
StatusHandlingPolicy statusHandlingPolicy) {
|
||||
if (!(lease.runtime() instanceof ReactiveClientRuntime runtime)) {
|
||||
return Mono.error(
|
||||
new IllegalStateException(
|
||||
"profile "
|
||||
+ lease.runtime().name().value()
|
||||
+ " is not configured for the reactive api"));
|
||||
}
|
||||
HttpFailureMetadata metadata =
|
||||
HttpFailureMetadata.validation(
|
||||
runtime.name(),
|
||||
operation.operationName(),
|
||||
operation.method(),
|
||||
operation.uriTemplate(),
|
||||
operation.body().replayability());
|
||||
|
||||
return Mono.usingWhen(
|
||||
Mono.fromSupplier(() -> runtime.admissionLimiter().admit(metadata)),
|
||||
admission ->
|
||||
runCall(runtime, operation, reactiveBody, responseType, statusHandlingPolicy, metadata),
|
||||
admission -> Mono.fromRunnable(() -> closeQuietly(admission)),
|
||||
(admission, failure) -> Mono.fromRunnable(() -> closeQuietly(admission)),
|
||||
admission -> Mono.fromRunnable(() -> closeQuietly(admission)));
|
||||
}
|
||||
|
||||
private <T> Mono<HttpCallResult<T>> runCall(
|
||||
ReactiveClientRuntime runtime,
|
||||
HttpOperation operation,
|
||||
Optional<ReactiveBodySource> reactiveBody,
|
||||
ResponseType<T> responseType,
|
||||
StatusHandlingPolicy statusHandlingPolicy,
|
||||
HttpFailureMetadata metadata) {
|
||||
|
||||
PreparedOperation prepared = runtime.targetPolicy().prepare(operation);
|
||||
Deadline deadline =
|
||||
runtime
|
||||
.support()
|
||||
.deadlineCalculator()
|
||||
.effective(
|
||||
operation.deadline(),
|
||||
runtime.profile().timeout().totalCall(),
|
||||
runtime.support().clock());
|
||||
|
||||
LogicalCallObservation observation =
|
||||
LogicalCallObservation.start(
|
||||
runtime.support().meterRegistry(),
|
||||
runtime.support().tagPolicy(),
|
||||
runtime.name(),
|
||||
operation.operationName(),
|
||||
operation.method().name(),
|
||||
operation.uriTemplate());
|
||||
|
||||
ReactiveRetryCoordinator coordinator =
|
||||
new ReactiveRetryCoordinator(
|
||||
runtime.support().eligibilityEngine(),
|
||||
runtime.newBackoff(),
|
||||
runtime.retryBudget(),
|
||||
runtime.support().clock());
|
||||
|
||||
return runtime
|
||||
.credentialProvider()
|
||||
.resolve(credentialRequest(runtime, prepared, false))
|
||||
.defaultIfEmpty(RequestCredentials.none())
|
||||
.flatMap(
|
||||
credentials ->
|
||||
coordinator.execute(
|
||||
new ReactiveGatewayCall<>(
|
||||
runtime,
|
||||
prepared,
|
||||
reactiveBody,
|
||||
responseType,
|
||||
statusHandlingPolicy,
|
||||
deadline,
|
||||
metadata,
|
||||
observation,
|
||||
credentials)))
|
||||
.doOnSuccess(
|
||||
result ->
|
||||
observation.stop(
|
||||
Optional.ofNullable(result).map(HttpCallResult::status),
|
||||
result == null ? ExecutionEvidence.NOT_SENT : result.evidence(),
|
||||
"success"))
|
||||
.doOnError(
|
||||
failure -> {
|
||||
if (failure instanceof HttpAmbiguousExecutionException ambiguous) {
|
||||
observation.recordAmbiguous();
|
||||
observation.stop(Optional.empty(), ambiguous.metadata().evidence(), "ambiguous");
|
||||
} else if (failure instanceof HttpClientException stable) {
|
||||
observation.stop(
|
||||
stable.metadata().status(), stable.metadata().evidence(), "failure");
|
||||
}
|
||||
})
|
||||
.contextWrite(
|
||||
context ->
|
||||
context.put(
|
||||
ReactiveOperationContextKeys.OPERATION_NAME,
|
||||
operation.operationName().value()));
|
||||
}
|
||||
|
||||
private void closeQuietly(AutoCloseable closeable) {
|
||||
try {
|
||||
closeable.close();
|
||||
} catch (Exception ignored) {
|
||||
// Releasing an admission permit cannot fail meaningfully.
|
||||
}
|
||||
}
|
||||
|
||||
private CredentialRequest credentialRequest(
|
||||
ReactiveClientRuntime runtime, PreparedOperation prepared, boolean forceRefresh) {
|
||||
return new CredentialRequest(
|
||||
runtime.name(),
|
||||
prepared.operation().operationName(),
|
||||
runtime.profile().authentication(),
|
||||
prepared.target().uri(),
|
||||
Optional.empty(),
|
||||
runtime.profile().tls().keyMaterialReference(),
|
||||
forceRefresh);
|
||||
}
|
||||
|
||||
/** Reactor Context keys used to carry operation metadata without a ThreadLocal. */
|
||||
public static final class ReactiveOperationContextKeys {
|
||||
public static final String OPERATION_NAME = "httpclient.operationName";
|
||||
|
||||
private ReactiveOperationContextKeys() {}
|
||||
}
|
||||
|
||||
private final class ReactiveGatewayCall<T> implements ReactiveLogicalCall<T> {
|
||||
|
||||
private final ReactiveClientRuntime runtime;
|
||||
private final PreparedOperation prepared;
|
||||
private final Optional<ReactiveBodySource> reactiveBody;
|
||||
private final ResponseType<T> responseType;
|
||||
private final StatusHandlingPolicy statusHandlingPolicy;
|
||||
private final Deadline deadline;
|
||||
private final HttpFailureMetadata metadata;
|
||||
private final LogicalCallObservation observation;
|
||||
private final RequestCredentials credentials;
|
||||
private final Instant startedAt;
|
||||
|
||||
private ReactiveGatewayCall(
|
||||
ReactiveClientRuntime runtime,
|
||||
PreparedOperation prepared,
|
||||
Optional<ReactiveBodySource> reactiveBody,
|
||||
ResponseType<T> responseType,
|
||||
StatusHandlingPolicy statusHandlingPolicy,
|
||||
Deadline deadline,
|
||||
HttpFailureMetadata metadata,
|
||||
LogicalCallObservation observation,
|
||||
RequestCredentials credentials) {
|
||||
this.runtime = runtime;
|
||||
this.prepared = prepared;
|
||||
this.reactiveBody = reactiveBody;
|
||||
this.responseType = responseType;
|
||||
this.statusHandlingPolicy = statusHandlingPolicy;
|
||||
this.deadline = deadline;
|
||||
this.metadata = metadata;
|
||||
this.observation = observation;
|
||||
this.credentials = credentials;
|
||||
this.startedAt = runtime.support().clock().instant();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<AttemptOutcome<T>> attempt(int attemptNumber) {
|
||||
if (!runtime.acceptsNewAttempts() && attemptNumber > 1) {
|
||||
return Mono.error(
|
||||
new HttpRateLimitRejectedException(
|
||||
"runtime is draining and refuses new attempts", metadata));
|
||||
}
|
||||
return executor.execute(
|
||||
runtime,
|
||||
prepared,
|
||||
reactiveBody,
|
||||
responseType,
|
||||
statusHandlingPolicy,
|
||||
credentials,
|
||||
attemptNumber,
|
||||
startedAt,
|
||||
metadata.withAttempt(attemptNumber));
|
||||
}
|
||||
|
||||
@Override
|
||||
public RetryContext context(AttemptOutcome<T> outcome, int attemptNumber) {
|
||||
return new RetryContext(
|
||||
prepared.operation().idempotency(),
|
||||
prepared.operation().idempotencyKey(),
|
||||
prepared.idempotencyKeySent(),
|
||||
reactiveBody
|
||||
.map(ReactiveBodySource::replayability)
|
||||
.orElseGet(() -> prepared.operation().body().replayability()),
|
||||
outcome.evidence(),
|
||||
outcome.failureCategory(),
|
||||
outcome.status(),
|
||||
outcome.retryAfter(),
|
||||
attemptNumber,
|
||||
runtime.profile().retry().maxAttempts(),
|
||||
outcome.firstByteDelivered(),
|
||||
deadline.remaining(runtime.support().clock()),
|
||||
runtime.support().minimumAttemptBudget(),
|
||||
runtime.retryBudget().snapshot(),
|
||||
runtime.support().transientServerErrorStatuses(),
|
||||
false,
|
||||
!runtime.acceptsNewAttempts());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Deadline deadline() {
|
||||
return deadline;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<HttpCallResult<T>> finish(AttemptOutcome<T> outcome, int attemptNumber) {
|
||||
return outcome
|
||||
.result()
|
||||
.map(
|
||||
value ->
|
||||
Mono.just(
|
||||
new HttpCallResult<>(
|
||||
value.status(),
|
||||
value.headers(),
|
||||
value.body(),
|
||||
attemptNumber,
|
||||
Duration.between(startedAt, runtime.support().clock().instant()),
|
||||
value.evidence(),
|
||||
value.remoteProblem())))
|
||||
.orElseGet(() -> Mono.error(outcome.failure().orElseThrow()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpClientException ambiguous(AttemptOutcome<T> outcome, int attemptNumber) {
|
||||
return new HttpAmbiguousExecutionException(
|
||||
"request was sent but the remote outcome is unknown",
|
||||
metadata.withAttempt(attemptNumber).withEvidence(ExecutionEvidence.SENT_NO_RESPONSE));
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpClientException retryExhausted(int attemptNumber) {
|
||||
observation.recordRetryExhausted();
|
||||
return new HttpRateLimitRejectedException(
|
||||
"retry budget for this upstream is exhausted", metadata.withAttempt(attemptNumber));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRetryGranted(RetryAllowed allowed, int attemptNumber) {
|
||||
observation.recordRetry(allowed.reason());
|
||||
}
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.webclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.BodyReplayability;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.ClassResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.CredentialRequest;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.ServerSentEvent;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.retry.Retry;
|
||||
|
||||
/**
|
||||
* Bounded SSE client (design §23.4).
|
||||
*
|
||||
* <p>Three budgets are kept apart deliberately: a setup deadline for establishing the stream, an
|
||||
* idle timeout for silence once it is open, and an optional maximum lifetime. Reconnects consume
|
||||
* the retry budget like any other physical attempt, and cancelling the subscription stops both the
|
||||
* stream and any pending reconnect.
|
||||
*/
|
||||
public final class DefaultReactiveSseGateway implements ReactiveSseGateway {
|
||||
|
||||
private final ClientRuntimeRegistry runtimes;
|
||||
|
||||
public DefaultReactiveSseGateway(ClientRuntimeRegistry runtimes) {
|
||||
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> Flux<ServerSentEvent<T>> connect(
|
||||
ClientProfileName profileName, SseOperation operation, ResponseType<T> eventType) {
|
||||
Objects.requireNonNull(profileName, "profile name");
|
||||
Objects.requireNonNull(operation, "sse operation");
|
||||
Objects.requireNonNull(eventType, "event type");
|
||||
|
||||
AtomicReference<String> lastEventId = new AtomicReference<>();
|
||||
|
||||
return Flux.usingWhen(
|
||||
Mono.fromSupplier(() -> runtimes.acquire(profileName)),
|
||||
lease -> stream(lease, operation, eventType, lastEventId),
|
||||
lease -> Mono.fromRunnable(lease::close),
|
||||
(lease, failure) -> Mono.fromRunnable(lease::close),
|
||||
lease -> Mono.fromRunnable(lease::close));
|
||||
}
|
||||
|
||||
private <T> Flux<ServerSentEvent<T>> stream(
|
||||
ClientRuntimeLease lease,
|
||||
SseOperation operation,
|
||||
ResponseType<T> eventType,
|
||||
AtomicReference<String> lastEventId) {
|
||||
if (!(lease.runtime() instanceof ReactiveClientRuntime runtime)) {
|
||||
return Flux.error(
|
||||
new IllegalStateException(
|
||||
"profile "
|
||||
+ lease.runtime().name().value()
|
||||
+ " is not configured for the reactive api"));
|
||||
}
|
||||
HttpFailureMetadata metadata =
|
||||
HttpFailureMetadata.validation(
|
||||
runtime.name(),
|
||||
operation.operationName(),
|
||||
dev.caskeleton.adapter.outbound.httpclient.api.HttpMethod.GET,
|
||||
operation.uriTemplate(),
|
||||
BodyReplayability.REPLAYABLE);
|
||||
|
||||
Flux<ServerSentEvent<T>> events =
|
||||
open(runtime, operation, eventType, lastEventId)
|
||||
.timeout(
|
||||
operation.streamingIdleTimeout(),
|
||||
Flux.error(new SseIdleTimeoutException(operation.operationName(), metadata)))
|
||||
.doOnNext(event -> rememberEventId(event, lastEventId));
|
||||
|
||||
Flux<ServerSentEvent<T>> bounded =
|
||||
operation.maxStreamDuration().map(events::take).orElse(events);
|
||||
|
||||
return operation.reconnectPolicy().enabled()
|
||||
? bounded.retryWhen(reconnectSpec(runtime, operation))
|
||||
: bounded;
|
||||
}
|
||||
|
||||
private <T> Flux<ServerSentEvent<T>> open(
|
||||
ReactiveClientRuntime runtime,
|
||||
SseOperation operation,
|
||||
ResponseType<T> eventType,
|
||||
AtomicReference<String> lastEventId) {
|
||||
// Through the profile's target policy, not around it. This used to expand the template and go
|
||||
// straight to the WebClient, so an SSE subscription reached its destination with no
|
||||
// relative-only
|
||||
// check, no host or port allowlist, no header policy and no credential — the one long-lived
|
||||
// connection type in the platform was also the least governed. The policy rejects an absolute
|
||||
// template and an off-allowlist origin exactly as it does for an ordinary call.
|
||||
HttpOperation subscribeOperation =
|
||||
HttpOperation.get(
|
||||
operation.operationName(), operation.uriTemplate(), operation.uriVariables());
|
||||
PreparedOperation prepared = runtime.targetPolicy().prepare(subscribeOperation);
|
||||
java.net.URI uri = prepared.target().uri();
|
||||
|
||||
RequestCredentials credentials =
|
||||
runtime
|
||||
.credentialProvider()
|
||||
.resolve(
|
||||
new CredentialRequest(
|
||||
runtime.name(),
|
||||
operation.operationName(),
|
||||
runtime.profile().authentication(),
|
||||
uri,
|
||||
java.util.Optional.empty(),
|
||||
runtime.profile().tls().keyMaterialReference(),
|
||||
false))
|
||||
.block(operation.setupDeadline());
|
||||
|
||||
var request = runtime.webClient().get().uri(uri).accept(MediaType.TEXT_EVENT_STREAM);
|
||||
for (Map.Entry<String, List<String>> header : prepared.headers().entrySet()) {
|
||||
for (String value : header.getValue()) {
|
||||
request = request.header(header.getKey(), value);
|
||||
}
|
||||
}
|
||||
if (credentials != null) {
|
||||
for (Map.Entry<String, String> credential : credentials.headers().entrySet()) {
|
||||
request = request.header(credential.getKey(), credential.getValue());
|
||||
}
|
||||
}
|
||||
if (operation.reconnectPolicy().sendLastEventId() && lastEventId.get() != null) {
|
||||
request = request.header("Last-Event-ID", lastEventId.get());
|
||||
}
|
||||
return request
|
||||
.retrieve()
|
||||
.bodyToFlux(serverSentEventType(eventType))
|
||||
.timeout(
|
||||
operation.setupDeadline(),
|
||||
Flux.error(
|
||||
new SseIdleTimeoutException(
|
||||
operation.operationName(), HttpFailureMetadata.startup(runtime.name()))))
|
||||
.onErrorResume(
|
||||
java.util.concurrent.TimeoutException.class,
|
||||
failure ->
|
||||
Flux.error(
|
||||
new SseIdleTimeoutException(
|
||||
operation.operationName(), HttpFailureMetadata.startup(runtime.name()))));
|
||||
}
|
||||
|
||||
private <T> ParameterizedTypeReference<ServerSentEvent<T>> serverSentEventType(
|
||||
ResponseType<T> eventType) {
|
||||
java.lang.reflect.Type eventElementType =
|
||||
eventType instanceof ClassResponseType<T> classType
|
||||
? classType.rawType()
|
||||
: eventType.type();
|
||||
java.lang.reflect.Type sseType =
|
||||
org.springframework.core.ResolvableType.forClassWithGenerics(
|
||||
ServerSentEvent.class,
|
||||
org.springframework.core.ResolvableType.forType(eventElementType))
|
||||
.getType();
|
||||
return ParameterizedTypeReference.<ServerSentEvent<T>>forType(sseType);
|
||||
}
|
||||
|
||||
private <T> void rememberEventId(ServerSentEvent<T> event, AtomicReference<String> lastEventId) {
|
||||
if (event.id() != null) {
|
||||
lastEventId.set(event.id());
|
||||
}
|
||||
}
|
||||
|
||||
private Retry reconnectSpec(ReactiveClientRuntime runtime, SseOperation operation) {
|
||||
SseReconnectPolicy policy = operation.reconnectPolicy();
|
||||
Duration backoff =
|
||||
policy.reconnectBackoff().isZero() ? Duration.ofMillis(50) : policy.reconnectBackoff();
|
||||
return Retry.fixedDelay(policy.maxReconnects(), backoff)
|
||||
.filter(failure -> runtime.retryBudget().tryConsume())
|
||||
.transientErrors(true);
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.webclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpClientException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.FailureCategory;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.HttpCallResult;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.result.ResponseType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.RequestCredentials;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.AttemptOutcome;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingAttemptExecutor;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.RestClientResponseReader;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.StatusHandlingPolicy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Executes one physical reactive attempt (design §26.2).
|
||||
*
|
||||
* <p>Result and error mapping are shared with the blocking path so both produce identical stable
|
||||
* exceptions and metadata. Discarded buffers are released explicitly: a cancelled or errored
|
||||
* reactive pipeline drops elements silently, and a dropped {@code DataBuffer} is leaked memory.
|
||||
*/
|
||||
public final class ReactiveAttemptExecutor {
|
||||
|
||||
private final WebClientBodyWriter bodyWriter = new WebClientBodyWriter();
|
||||
private final WebClientResponseMapper responseMapper = new WebClientResponseMapper();
|
||||
|
||||
public <T> Mono<AttemptOutcome<T>> execute(
|
||||
ReactiveClientRuntime runtime,
|
||||
PreparedOperation prepared,
|
||||
Optional<ReactiveBodySource> reactiveBody,
|
||||
ResponseType<T> responseType,
|
||||
StatusHandlingPolicy statusHandlingPolicy,
|
||||
RequestCredentials credentials,
|
||||
int attemptNumber,
|
||||
Instant startedAt,
|
||||
HttpFailureMetadata baseMetadata) {
|
||||
Objects.requireNonNull(runtime, "runtime");
|
||||
|
||||
return Mono.defer(
|
||||
() -> {
|
||||
// Query-parameter credentials are applied here, as they are on the blocking path.
|
||||
// An API_KEY_QUERY profile resolved its credential, the reactive executor read only
|
||||
// the header map, and the request went out with the key missing — an authentication
|
||||
// mechanism that worked on one API and silently did not on the other.
|
||||
WebClient.RequestBodySpec spec =
|
||||
runtime
|
||||
.webClient()
|
||||
.method(
|
||||
org.springframework.http.HttpMethod.valueOf(
|
||||
prepared.operation().method().name()))
|
||||
.uri(withCredentialQuery(prepared.target().uri(), credentials));
|
||||
headers(prepared, credentials)
|
||||
.forEach((name, values) -> spec.header(name, values.toArray(String[]::new)));
|
||||
WebClient.RequestHeadersSpec<?> request =
|
||||
bodyWriter.write(
|
||||
spec,
|
||||
prepared.operation().body(),
|
||||
reactiveBody,
|
||||
runtime.bodyLimitPolicy(),
|
||||
baseMetadata);
|
||||
return request.exchangeToMono(
|
||||
response ->
|
||||
responseMapper.readBounded(
|
||||
response, Math.toIntExact(prepared.maxResponseWireBytes())));
|
||||
})
|
||||
.map(
|
||||
response ->
|
||||
mapResponse(
|
||||
runtime,
|
||||
responseType,
|
||||
statusHandlingPolicy,
|
||||
attemptNumber,
|
||||
startedAt,
|
||||
baseMetadata,
|
||||
response))
|
||||
.onErrorResume(failure -> Mono.just(mapFailure(runtime, baseMetadata, failure)))
|
||||
.doOnDiscard(DataBuffer.class, DataBufferUtils::release);
|
||||
}
|
||||
|
||||
private <T> AttemptOutcome<T> mapResponse(
|
||||
ReactiveClientRuntime runtime,
|
||||
ResponseType<T> responseType,
|
||||
StatusHandlingPolicy statusHandlingPolicy,
|
||||
int attemptNumber,
|
||||
Instant startedAt,
|
||||
HttpFailureMetadata baseMetadata,
|
||||
RestClientResponseReader.RawResponse response) {
|
||||
Duration elapsed = Duration.between(startedAt, runtime.support().clock().instant());
|
||||
try {
|
||||
HttpCallResult<T> result =
|
||||
runtime
|
||||
.support()
|
||||
.responseMapper()
|
||||
.map(
|
||||
response,
|
||||
responseType,
|
||||
runtime.profile().response(),
|
||||
statusHandlingPolicy,
|
||||
attemptNumber,
|
||||
elapsed,
|
||||
baseMetadata);
|
||||
return AttemptOutcome.succeeded(result);
|
||||
} catch (HttpRemoteErrorException remoteError) {
|
||||
return AttemptOutcome.failed(
|
||||
remoteError,
|
||||
FailureCategory.REMOTE_STATUS,
|
||||
BlockingAttemptExecutor.retryAfter(response),
|
||||
false);
|
||||
} catch (HttpClientException stable) {
|
||||
return AttemptOutcome.failed(stable, categoryOf(stable), Optional.empty(), false);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> AttemptOutcome<T> mapFailure(
|
||||
ReactiveClientRuntime runtime, HttpFailureMetadata baseMetadata, Throwable failure) {
|
||||
if (failure instanceof HttpClientException stable) {
|
||||
return AttemptOutcome.failed(stable, categoryOf(stable), Optional.empty(), false);
|
||||
}
|
||||
TransportFailure classified =
|
||||
runtime.failureClassifier().classify(failure, baseMetadata.stage());
|
||||
HttpFailureMetadata metadata =
|
||||
baseMetadata.withEvidence(classified.evidence()).withStage(classified.stage());
|
||||
HttpClientException mapped =
|
||||
runtime.support().exceptionMapper().map(classified, metadata, failure);
|
||||
return AttemptOutcome.failed(mapped, classified.category(), Optional.empty(), false);
|
||||
}
|
||||
|
||||
private Map<String, List<String>> headers(
|
||||
PreparedOperation prepared, RequestCredentials credentials) {
|
||||
Map<String, List<String>> merged = new LinkedHashMap<>(prepared.headers());
|
||||
credentials.headers().forEach((name, value) -> merged.put(name, List.of(value)));
|
||||
return merged;
|
||||
}
|
||||
|
||||
private FailureCategory categoryOf(HttpClientException failure) {
|
||||
return switch (failure.getClass().getSimpleName()) {
|
||||
case "HttpDnsException" -> FailureCategory.DNS;
|
||||
case "HttpPoolAcquireTimeoutException" -> FailureCategory.POOL_ACQUIRE_TIMEOUT;
|
||||
case "HttpConnectException" -> FailureCategory.CONNECT;
|
||||
case "HttpProxyException" -> FailureCategory.PROXY;
|
||||
case "HttpTlsException" -> FailureCategory.TLS_PERMANENT;
|
||||
case "HttpRequestWriteException" -> FailureCategory.REQUEST_WRITE;
|
||||
case "HttpResponseTimeoutException" -> FailureCategory.RESPONSE_TIMEOUT;
|
||||
case "HttpResponseTruncatedException" -> FailureCategory.RESPONSE_TRUNCATED;
|
||||
case "HttpResponseTooLargeException" -> FailureCategory.RESPONSE_TOO_LARGE;
|
||||
case "HttpSerializationException" -> FailureCategory.SERIALIZATION;
|
||||
case "HttpTargetRejectedException" -> FailureCategory.TARGET_REJECTED;
|
||||
case "HttpRedirectRejectedException" -> FailureCategory.REDIRECT_REJECTED;
|
||||
case "HttpAuthenticationException" -> FailureCategory.AUTHENTICATION;
|
||||
case "HttpDeadlineExceededException" -> FailureCategory.DEADLINE_EXCEEDED;
|
||||
case "HttpConfigurationException" -> FailureCategory.CONFIGURATION;
|
||||
default -> FailureCategory.UNKNOWN;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends query-parameter credentials to the target.
|
||||
*
|
||||
* <p>The blocking executor has always done this; the reactive one read only the header map, so an
|
||||
* {@code API_KEY_QUERY} profile authenticated on one API surface and not on the other.
|
||||
*/
|
||||
private java.net.URI withCredentialQuery(java.net.URI uri, RequestCredentials credentials) {
|
||||
if (credentials.queryParameters().isEmpty()) {
|
||||
return uri;
|
||||
}
|
||||
org.springframework.web.util.UriComponentsBuilder builder =
|
||||
org.springframework.web.util.UriComponentsBuilder.fromUri(uri);
|
||||
credentials.queryParameters().forEach(builder::queryParam);
|
||||
return builder.build(true).toUri();
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.webclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.ReactiveRequestCredentialProvider;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.LogicalAdmissionLimiter;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingExecutionSupport;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.BodyLimitPolicy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.TrustedTargetPolicy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailureClassifier;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportId;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
/**
|
||||
* One immutable reactive generation (design §7.2, §26.2).
|
||||
*
|
||||
* <p>Reactive runtimes deliberately do not expose an attempt bulkhead built on a thread pool:
|
||||
* design §18.2 requires semaphore-style concurrency here, because wrapping an event loop in a
|
||||
* thread pool destroys the property that makes it useful.
|
||||
*/
|
||||
public final class ReactiveClientRuntime extends ClientRuntime {
|
||||
|
||||
private final WebClient webClient;
|
||||
private final TransportId transportId;
|
||||
private final TransportFailureClassifier failureClassifier;
|
||||
private final TrustedTargetPolicy targetPolicy;
|
||||
private final BodyLimitPolicy bodyLimitPolicy;
|
||||
private final LogicalAdmissionLimiter admissionLimiter;
|
||||
private final RetryBudget retryBudget;
|
||||
private final Supplier<BackoffStrategy> backoffFactory;
|
||||
private final ReactiveRequestCredentialProvider credentialProvider;
|
||||
private final BlockingExecutionSupport support;
|
||||
|
||||
public ReactiveClientRuntime(
|
||||
ClientProfile profile,
|
||||
RuntimeGeneration generation,
|
||||
Runnable resourceCloser,
|
||||
WebClient webClient,
|
||||
TransportId transportId,
|
||||
TransportFailureClassifier failureClassifier,
|
||||
LogicalAdmissionLimiter admissionLimiter,
|
||||
RetryBudget retryBudget,
|
||||
Supplier<BackoffStrategy> backoffFactory,
|
||||
ReactiveRequestCredentialProvider credentialProvider,
|
||||
BlockingExecutionSupport support) {
|
||||
super(profile, generation, resourceCloser);
|
||||
this.webClient = Objects.requireNonNull(webClient, "web client");
|
||||
this.transportId = Objects.requireNonNull(transportId, "transport id");
|
||||
this.failureClassifier = Objects.requireNonNull(failureClassifier, "failure classifier");
|
||||
this.admissionLimiter = Objects.requireNonNull(admissionLimiter, "admission limiter");
|
||||
this.retryBudget = Objects.requireNonNull(retryBudget, "retry budget");
|
||||
this.backoffFactory = Objects.requireNonNull(backoffFactory, "backoff factory");
|
||||
this.credentialProvider = Objects.requireNonNull(credentialProvider, "credential provider");
|
||||
this.support = Objects.requireNonNull(support, "execution support");
|
||||
this.targetPolicy = new TrustedTargetPolicy(profile);
|
||||
this.bodyLimitPolicy = BodyLimitPolicy.maxRequestBytes(profile.request().maxBodyBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine client, visible only inside this package.
|
||||
*
|
||||
* <p>Public exposure let a caller bypass every platform guarantee, which is what the reactive
|
||||
* typed registry did. See {@code BlockingClientRuntime#restClient()} for the full reasoning.
|
||||
*
|
||||
* @return the profile's immutable {@code WebClient}
|
||||
*/
|
||||
WebClient webClient() {
|
||||
return webClient;
|
||||
}
|
||||
|
||||
public TransportId transportId() {
|
||||
return transportId;
|
||||
}
|
||||
|
||||
public TransportFailureClassifier failureClassifier() {
|
||||
return failureClassifier;
|
||||
}
|
||||
|
||||
public TrustedTargetPolicy targetPolicy() {
|
||||
return targetPolicy;
|
||||
}
|
||||
|
||||
public BodyLimitPolicy bodyLimitPolicy() {
|
||||
return bodyLimitPolicy;
|
||||
}
|
||||
|
||||
public LogicalAdmissionLimiter admissionLimiter() {
|
||||
return admissionLimiter;
|
||||
}
|
||||
|
||||
public RetryBudget retryBudget() {
|
||||
return retryBudget;
|
||||
}
|
||||
|
||||
public BackoffStrategy newBackoff() {
|
||||
return backoffFactory.get();
|
||||
}
|
||||
|
||||
public ReactiveRequestCredentialProvider credentialProvider() {
|
||||
return credentialProvider;
|
||||
}
|
||||
|
||||
public BlockingExecutionSupport support() {
|
||||
return support;
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.webclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.ClientProfileName;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.HttpStatus;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRemoteErrorException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.HttpOperation;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeLease;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeRegistry;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.ResponseSizeLimiter;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.PreparedOperation;
|
||||
import java.util.Objects;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Reactive streaming download (design §23.2, §23.3, D-12).
|
||||
*
|
||||
* <p>The status is checked before the body is exposed, the byte budget is enforced as buffers flow,
|
||||
* and the first delivered buffer permanently disables transparent retry for the call.
|
||||
*/
|
||||
public final class ReactiveStreamingGateway {
|
||||
|
||||
private final ClientRuntimeRegistry runtimes;
|
||||
|
||||
public ReactiveStreamingGateway(ClientRuntimeRegistry runtimes) {
|
||||
this.runtimes = Objects.requireNonNull(runtimes, "client runtime registry");
|
||||
}
|
||||
|
||||
public Flux<DataBuffer> download(ClientProfileName profileName, HttpOperation operation) {
|
||||
return download(profileName, operation, new FirstByteDeliveryGuard());
|
||||
}
|
||||
|
||||
public Flux<DataBuffer> download(
|
||||
ClientProfileName profileName, HttpOperation operation, FirstByteDeliveryGuard guard) {
|
||||
Objects.requireNonNull(profileName, "profile name");
|
||||
Objects.requireNonNull(operation, "operation");
|
||||
Objects.requireNonNull(guard, "first byte guard");
|
||||
|
||||
return Flux.usingWhen(
|
||||
Mono.fromSupplier(() -> runtimes.acquire(profileName)),
|
||||
lease -> stream(lease, operation, guard),
|
||||
lease -> Mono.fromRunnable(lease::close),
|
||||
(lease, failure) -> Mono.fromRunnable(lease::close),
|
||||
lease -> Mono.fromRunnable(lease::close));
|
||||
}
|
||||
|
||||
private Flux<DataBuffer> stream(
|
||||
ClientRuntimeLease lease, HttpOperation operation, FirstByteDeliveryGuard guard) {
|
||||
if (!(lease.runtime() instanceof ReactiveClientRuntime runtime)) {
|
||||
return Flux.error(
|
||||
new IllegalStateException(
|
||||
"profile "
|
||||
+ lease.runtime().name().value()
|
||||
+ " is not configured for the reactive api"));
|
||||
}
|
||||
HttpFailureMetadata metadata =
|
||||
HttpFailureMetadata.validation(
|
||||
runtime.name(),
|
||||
operation.operationName(),
|
||||
operation.method(),
|
||||
operation.uriTemplate(),
|
||||
operation.body().replayability());
|
||||
PreparedOperation prepared = runtime.targetPolicy().prepare(operation);
|
||||
ResponseSizeLimiter limiter =
|
||||
new ResponseSizeLimiter(
|
||||
prepared.maxResponseWireBytes(), prepared.maxResponseDecodedBytes(), metadata);
|
||||
|
||||
// The prepared headers are actually sent. They were computed and then dropped: the request was
|
||||
// built from the URI alone, so the header policy ran, produced an approved set, and the wire
|
||||
// saw
|
||||
// none of it — no content negotiation, no correlation header, and no credential.
|
||||
var request =
|
||||
runtime
|
||||
.webClient()
|
||||
.method(org.springframework.http.HttpMethod.valueOf(operation.method().name()))
|
||||
.uri(prepared.target().uri());
|
||||
for (var header : prepared.headers().entrySet()) {
|
||||
for (String value : header.getValue()) {
|
||||
request = request.header(header.getKey(), value);
|
||||
}
|
||||
}
|
||||
|
||||
return request
|
||||
.exchangeToFlux(
|
||||
response -> {
|
||||
int status = response.statusCode().value();
|
||||
if (status < 200 || status >= 300) {
|
||||
return response
|
||||
.releaseBody()
|
||||
.thenMany(
|
||||
Flux.error(
|
||||
new HttpRemoteErrorException(
|
||||
"streaming download returned an error status",
|
||||
metadata
|
||||
.withStatus(new HttpStatus(status))
|
||||
.withEvidence(ExecutionEvidence.RESPONSE_RECEIVED))));
|
||||
}
|
||||
return BoundedDataBufferFlux.bound(
|
||||
response.bodyToFlux(DataBuffer.class), limiter, guard);
|
||||
})
|
||||
.doOnDiscard(DataBuffer.class, DataBufferUtils::release);
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.webclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.BodySource;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.ByteArrayBody;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.EmptyBody;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.ObjectBody;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.OneShotStreamBody;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.body.ReopenableStreamBody;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpRequestWriteException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.security.BodyLimitPolicy;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
/**
|
||||
* Writes a body onto a WebClient request (design §10.2, §23.1).
|
||||
*
|
||||
* <p>A reopenable body is opened per attempt so a retry sends the same bytes rather than an
|
||||
* already-drained stream.
|
||||
*/
|
||||
public final class WebClientBodyWriter {
|
||||
|
||||
public WebClient.RequestHeadersSpec<?> write(
|
||||
WebClient.RequestBodySpec spec,
|
||||
BodySource body,
|
||||
Optional<ReactiveBodySource> reactiveBody,
|
||||
BodyLimitPolicy bodyLimitPolicy,
|
||||
HttpFailureMetadata metadata) {
|
||||
|
||||
if (reactiveBody.isPresent()) {
|
||||
ReactiveBodySource reactive = reactiveBody.get();
|
||||
// A reactive body used to return here before any limit was applied, so the whole
|
||||
// request-size policy was opt-out: publish the body as a Flux and the profile's
|
||||
// max-body-bytes stopped existing. The known length is checked up front when the source
|
||||
// declares one, and the emitted bytes are counted as they go when it does not — an
|
||||
// unbounded publisher is exactly the case a byte ceiling is for.
|
||||
reactive
|
||||
.knownLength()
|
||||
.ifPresent(
|
||||
declared -> {
|
||||
if (declared > bodyLimitPolicy.limit()) {
|
||||
throw new HttpRequestWriteException(
|
||||
"reactive request body declares "
|
||||
+ declared
|
||||
+ " bytes, over the profile limit of "
|
||||
+ bodyLimitPolicy.limit(),
|
||||
metadata);
|
||||
}
|
||||
});
|
||||
spec.contentType(reactive.mediaType());
|
||||
java.util.concurrent.atomic.AtomicLong written = new java.util.concurrent.atomic.AtomicLong();
|
||||
return spec.body(
|
||||
BodyInserters.fromDataBuffers(
|
||||
reactor.core.publisher.Flux.from(reactive.publisherFactory().get())
|
||||
.cast(DataBuffer.class)
|
||||
.doOnNext(
|
||||
buffer ->
|
||||
bodyLimitPolicy.recordWrittenBytes(
|
||||
written.addAndGet(buffer.readableByteCount()), metadata))));
|
||||
}
|
||||
|
||||
bodyLimitPolicy.validate(body, metadata);
|
||||
if (body instanceof EmptyBody) {
|
||||
return spec;
|
||||
}
|
||||
spec.contentType(mediaType(body));
|
||||
if (body instanceof ObjectBody objectBody) {
|
||||
return spec.bodyValue(objectBody.value());
|
||||
}
|
||||
if (body instanceof ByteArrayBody byteArrayBody) {
|
||||
return spec.bodyValue(byteArrayBody.bytes());
|
||||
}
|
||||
if (body instanceof ReopenableStreamBody reopenable) {
|
||||
try {
|
||||
return spec.body(
|
||||
BodyInserters.fromResource(new InputStreamResource(reopenable.opener().get())));
|
||||
} catch (IOException failure) {
|
||||
throw new HttpRequestWriteException("request body could not be opened", metadata, failure);
|
||||
}
|
||||
}
|
||||
if (body instanceof OneShotStreamBody oneShot) {
|
||||
return spec.body(BodyInserters.fromResource(new InputStreamResource(oneShot.stream())));
|
||||
}
|
||||
throw new IllegalStateException("unsupported body source: " + body.getClass().getName());
|
||||
}
|
||||
|
||||
private MediaType mediaType(BodySource body) {
|
||||
String declared = body.mediaType();
|
||||
return declared.isBlank()
|
||||
? MediaType.APPLICATION_OCTET_STREAM
|
||||
: MediaType.parseMediaType(declared);
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.webclient;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpConfigurationException;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.error.HttpFailureMetadata;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.auth.ReactiveRequestCredentialProvider;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntime;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientRuntimeFactory;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.TransportType;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.BackoffStrategy;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.ExponentialFullJitterBackoff;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.ResilienceRegistry;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.resilience.RetryBudget;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.restclient.BlockingExecutionSupport;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.ReactiveTransportProvider;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportCapabilityValidator;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportLifecycleListener;
|
||||
import java.time.Duration;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.random.RandomGenerator;
|
||||
import org.springframework.http.client.reactive.ClientHttpConnector;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
/**
|
||||
* Builds a reactive runtime generation for a profile (design §26.2).
|
||||
*
|
||||
* <p>The codec in-memory limit is derived from the profile's decoded-byte budget rather than left
|
||||
* at the framework default, so an oversized response is rejected by the same number the profile
|
||||
* declares.
|
||||
*/
|
||||
public final class WebClientRuntimeFactory implements ClientRuntimeFactory {
|
||||
|
||||
private final Map<TransportType, ReactiveTransportProvider> providers =
|
||||
new EnumMap<>(TransportType.class);
|
||||
private final TransportCapabilityValidator capabilityValidator =
|
||||
new TransportCapabilityValidator();
|
||||
private final ResilienceRegistry resilienceRegistry;
|
||||
private final ReactiveRequestCredentialProvider credentialProvider;
|
||||
private final BlockingExecutionSupport support;
|
||||
private final TransportLifecycleListener lifecycleListener;
|
||||
private final RandomGenerator random;
|
||||
|
||||
public WebClientRuntimeFactory(
|
||||
Map<TransportType, ReactiveTransportProvider> providers,
|
||||
ResilienceRegistry resilienceRegistry,
|
||||
ReactiveRequestCredentialProvider credentialProvider,
|
||||
BlockingExecutionSupport support,
|
||||
TransportLifecycleListener lifecycleListener,
|
||||
RandomGenerator random) {
|
||||
Objects.requireNonNull(providers, "reactive transport providers").forEach(this.providers::put);
|
||||
this.resilienceRegistry = Objects.requireNonNull(resilienceRegistry, "resilience registry");
|
||||
this.credentialProvider = Objects.requireNonNull(credentialProvider, "credential provider");
|
||||
this.support = Objects.requireNonNull(support, "execution support");
|
||||
this.lifecycleListener = Objects.requireNonNull(lifecycleListener, "lifecycle listener");
|
||||
this.random = Objects.requireNonNull(random, "random generator");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientRuntime create(ClientProfile profile, RuntimeGeneration generation) {
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
ReactiveTransportProvider provider = providers.get(profile.transport());
|
||||
if (provider == null) {
|
||||
throw new HttpConfigurationException(
|
||||
"no reactive transport provider is registered for " + profile.transport(),
|
||||
HttpFailureMetadata.startup(profile.name()));
|
||||
}
|
||||
capabilityValidator.validate(profile, provider.capabilities());
|
||||
|
||||
ClientHttpConnector connector = provider.create(profile, generation, lifecycleListener);
|
||||
WebClient webClient =
|
||||
WebClient.builder()
|
||||
.clientConnector(connector)
|
||||
.baseUrl(profile.baseUrl().toString())
|
||||
.codecs(
|
||||
configurer ->
|
||||
configurer
|
||||
.defaultCodecs()
|
||||
.maxInMemorySize(
|
||||
Math.toIntExact(
|
||||
Math.min(profile.response().maxDecodedBytes(), Integer.MAX_VALUE))))
|
||||
.build();
|
||||
|
||||
RetryBudget retryBudget =
|
||||
profile
|
||||
.retry()
|
||||
.budget()
|
||||
.map(
|
||||
name ->
|
||||
resilienceRegistry.retryBudget(
|
||||
name,
|
||||
Math.max(1L, profile.pool().maxTotalConnections() / 10L),
|
||||
Duration.ofMinutes(1)))
|
||||
.orElseGet(RetryBudget::unlimited);
|
||||
|
||||
Supplier<BackoffStrategy> backoffFactory =
|
||||
() ->
|
||||
new ExponentialFullJitterBackoff(
|
||||
profile.retry().baseBackoff(),
|
||||
profile.retry().maxBackoff(),
|
||||
profile.retry().jitter(),
|
||||
profile.retry().retryAfter(),
|
||||
random);
|
||||
|
||||
return new ReactiveClientRuntime(
|
||||
profile,
|
||||
generation,
|
||||
() -> provider.close(profile, generation),
|
||||
webClient,
|
||||
provider.id(),
|
||||
provider.failureClassifier(),
|
||||
resilienceRegistry.admission(
|
||||
profile.name(),
|
||||
profile.pool().maxPendingAcquires() + profile.pool().maxTotalConnections()),
|
||||
retryBudget,
|
||||
backoffFactory,
|
||||
credentialProvider,
|
||||
support);
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.apache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.testkit.NoopLifecycleListener;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
class ApacheBlockingTransportProviderTest {
|
||||
|
||||
@Test
|
||||
void sendsRequestThroughConfiguredFactory() throws Exception {
|
||||
try (MockHttpServer server = MockHttpServer.start()) {
|
||||
server.enqueueJson(200, "{\"value\":1}");
|
||||
ClientProfile profile = ClientProfiles.apache(server.uri("/"));
|
||||
ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider();
|
||||
try {
|
||||
ClientHttpRequestFactory factory =
|
||||
provider.create(
|
||||
profile,
|
||||
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
|
||||
NoopLifecycleListener.INSTANCE);
|
||||
RestClient client = RestClient.builder().requestFactory(factory).build();
|
||||
String body = client.get().uri(server.uri("/value")).retrieve().body(String.class);
|
||||
|
||||
assertThat(body).contains("value");
|
||||
assertThat(server.takeRequest(Duration.ofSeconds(2)).path()).isEqualTo("/value");
|
||||
assertThat(provider.leasedConnections(profile.name())).isZero();
|
||||
} finally {
|
||||
provider.close(
|
||||
profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void neverFollowsRedirectsAtTheEngineLevel() throws Exception {
|
||||
try (MockHttpServer server = MockHttpServer.start()) {
|
||||
server.enqueueRedirect(302, "/moved");
|
||||
ClientProfile profile = ClientProfiles.apache(server.uri("/"));
|
||||
ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider();
|
||||
try {
|
||||
RestClient client =
|
||||
RestClient.builder()
|
||||
.requestFactory(
|
||||
provider.create(
|
||||
profile,
|
||||
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
|
||||
NoopLifecycleListener.INSTANCE))
|
||||
.build();
|
||||
int status =
|
||||
client
|
||||
.get()
|
||||
.uri(server.uri("/start"))
|
||||
.exchange((request, response) -> response.getStatusCode().value());
|
||||
assertThat(status).isEqualTo(302);
|
||||
assertThat(server.requestCount()).isEqualTo(1);
|
||||
} finally {
|
||||
provider.close(
|
||||
profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void declaresRouteScopedPoolAndDynamicTargetCapability() {
|
||||
assertThat(new ApacheBlockingTransportProvider().capabilities().routeScopedPool()).isTrue();
|
||||
assertThat(new ApacheBlockingTransportProvider().capabilities().dynamicTargetStable()).isTrue();
|
||||
assertThat(new ApacheBlockingTransportProvider().id().value()).isEqualTo("apache");
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package dev.caskeleton.adapter.outbound.httpclient.apache;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.AttemptStage;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.api.operation.ExecutionEvidence;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.ClientProfile;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.profile.PoolSettings;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.testkit.ClientProfiles;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.testkit.MockHttpServer;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.testkit.NoopLifecycleListener;
|
||||
import dev.caskeleton.adapter.outbound.httpclient.transport.TransportFailure;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
class ApachePoolSaturationTest {
|
||||
|
||||
@Test
|
||||
void poolAcquireTimeoutIsClassifiedAsNotSent() throws Exception {
|
||||
ApacheBlockingTransportProvider provider = new ApacheBlockingTransportProvider();
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try (MockHttpServer server = MockHttpServer.start()) {
|
||||
server.enqueueDelayedBody(200, "{\"slow\":true}", Duration.ofSeconds(2));
|
||||
server.enqueueJson(200, "{\"fast\":true}");
|
||||
|
||||
ClientProfile profile = singleConnectionProfile(server.uri("/"));
|
||||
RestClient client =
|
||||
RestClient.builder()
|
||||
.requestFactory(
|
||||
provider.create(
|
||||
profile,
|
||||
new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1),
|
||||
NoopLifecycleListener.INSTANCE))
|
||||
.build();
|
||||
|
||||
CountDownLatch firstStarted = new CountDownLatch(1);
|
||||
executor.execute(
|
||||
() -> {
|
||||
firstStarted.countDown();
|
||||
try {
|
||||
client.get().uri(server.uri("/slow")).retrieve().body(String.class);
|
||||
} catch (RuntimeException ignored) {
|
||||
// The holding request is only needed to occupy the single pooled connection.
|
||||
}
|
||||
});
|
||||
assertThat(firstStarted.await(2, TimeUnit.SECONDS)).isTrue();
|
||||
Thread.sleep(200);
|
||||
|
||||
Throwable captured = null;
|
||||
try {
|
||||
client.get().uri(server.uri("/fast")).retrieve().body(String.class);
|
||||
} catch (RuntimeException saturated) {
|
||||
captured = saturated;
|
||||
}
|
||||
|
||||
assertThat(captured).isNotNull();
|
||||
TransportFailure failure =
|
||||
new ApacheFailureClassifier().classify(captured, AttemptStage.POOL_ACQUIRE);
|
||||
assertThat(failure.stage()).isEqualTo(AttemptStage.POOL_ACQUIRE);
|
||||
assertThat(failure.evidence()).isEqualTo(ExecutionEvidence.NOT_SENT);
|
||||
|
||||
provider.close(
|
||||
profile, new dev.caskeleton.adapter.outbound.httpclient.profile.RuntimeGeneration(1));
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
private static ClientProfile singleConnectionProfile(java.net.URI baseUrl) {
|
||||
return ClientProfiles.builder("saturated")
|
||||
.baseUrl(baseUrl)
|
||||
.pool(
|
||||
new PoolSettings(
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
Duration.ofMillis(100),
|
||||
Duration.ofSeconds(30),
|
||||
Duration.ofMinutes(5),
|
||||
Duration.ofSeconds(5),
|
||||
Duration.ofSeconds(15),
|
||||
Duration.ofSeconds(5),
|
||||
false,
|
||||
false))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
+157
@@ -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);
|
||||
}
|
||||
}
|
||||
+217
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -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://");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
@@ -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);
|
||||
}
|
||||
}
|
||||
+139
@@ -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");
|
||||
}
|
||||
}
|
||||
+51
@@ -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");
|
||||
}
|
||||
}
|
||||
+131
@@ -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"));
|
||||
}
|
||||
}
|
||||
+205
@@ -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();
|
||||
}
|
||||
}
|
||||
+175
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
@@ -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();
|
||||
}
|
||||
}
|
||||
+114
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
@@ -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());
|
||||
}
|
||||
}
|
||||
+76
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -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
Reference in New Issue
Block a user