Skip to content

Rate limit core

View Markdown

Status: Stable Package: dev.vertique.ratelimit Artifact: vertique-rate-limit-core Depends on: vertique-core, vertique-context, vertique-security-core API reference: javadoc.io

vertique-rate-limit-core is the provider-neutral foundation for programmatic quota admission. Injected RateLimiters resolves named policies into RateLimiter/ KeyedRateLimiter<K> handles; the in-process LOCAL engine is always available from this artifact alone. Annotation support is layered in vertique-rate-limit-aop, a shared-state CLUSTERED engine in vertique-rate-limit-redis, and telemetry in the Micrometer/OpenTelemetry adapters — none of that is exposed here.

Install RateLimitCoreModule whenever an application needs quota admission for at least one policy. LOCAL mode needs no further module. Add vertique-rate-limit-redis for any policy that must share state across instances, vertique-rate-limit-aop (plus vertique-codegen-rate-limit on the processor path) for the @RateLimited annotation, and vertique-micrometer-rate-limit/ vertique-opentelemetry-rate-limit for telemetry.

RateLimiters is the single injected entry point (one per application graph, no static registry). It resolves a named policy into a cached handle immediately: limiter(String) returns an untyped RateLimiter; limiter(String, Function<? super K, RateLimitKey>) returns a KeyedRateLimiter<K> bound to one caller-supplied key-selector function. An unknown policy name fails handle creation with IllegalArgumentException — never at first acquire.

Both handle types are decision-first: acquire(...) never fails the returned future for quota reasons, it always yields a RateLimitDecision. execute(...) is the guarded wrapper — QUOTA_EXCEEDED fails with RateLimitExceededException, BACKEND_FAILURE_CLOSED fails with RateLimitUnavailableException, and PERMITTED/BACKEND_FAILURE_OPEN/DISABLED run the supplied action exactly once, with no retry and no refund. A cost above the policy’s capacity fails the future with RateLimitRequestException(COST_EXCEEDS_CAPACITY) and is never subject to fail-open behavior.

RateLimiters is force-constructed at bootstrap through an ApplicationShutdownStep @IntoSet contribution, so its startup validation runs even for a policy nothing has referenced yet: duplicate policy names, any enabled policy whose mode has no bound backend, and a missing or under-length keyDerivation.secret when any enabled policy is CLUSTERED all fail startup.

RateLimitKey follows the CacheKey idiom: RateLimitKey.of(Object first, Object... rest) frames an ordered scalar-component tuple (String, Character, Boolean, integral types, BigInteger/BigDecimal, finite Float/Double, Enum, UUID, java.time scalars); RateLimitKey.global() is the explicit zero-component bucket. Each framed component is bounded at 256 bytes UTF-8 post-encoding; an oversized component is deterministically replaced by h: followed by the lowercase-hex SHA-256 digest of its pre-encoding value — a fixed-width, distinctness-preserving substitution, never a rejection path.

A policy is one RateLimitPolicy: name, enabled, mode (LOCAL/CLUSTERED), failureMode (OPEN/CLOSED, no default for any policy), revision, defaultCost, and one RateLimitAlgorithm — v1 ships TokenBucketRateLimit(capacity, refill) with GreedyRateLimitRefill/IntervalRateLimitRefill. Configuration-declared policies replace a same-named Dagger-contributed policy wholesale (fields never merge); a semantic change needs a new revision. Choose failureMode deliberately for key-space saturation, not only for a genuine backend outage: under OPEN, a LOCAL registry at its configured maxTrackedKeys budget silently admits every request for a not-yet-tracked key once saturated — an attacker who can mint high-cardinality keys (e.g. an unauthenticated per-IP or per-header dimension) can exploit this to disable the limiter’s protection entirely; under CLOSED it denies instead. Either way, monitor vertique.ratelimit.failures{code="capacity-exhausted"} and the WARN log line below — this failure mode is silent otherwise.

LOCAL mode stores Bucket state in a bounded, per-policy concurrent registry: each enabled policy owns its own registry sized by rateLimit.local.maxTrackedKeys (or its own rateLimit.policies.<name>.local.maxTrackedKeys override), never one shared pool across policies — CAPACITY_EXHAUSTED pressure on one policy’s key space can never surface as an outcome, latency change, or eviction on any other policy. Inactive state is reclaimable only after the worst-case time to full plus a configured retention slack; active state is never evicted merely to make room. The registry’s internal storage key is policyName:policyRevision:canonicalKeyEncoding (the same canonical input RateLimitKey framing produces); LOCAL retains this raw key verbatim, in-memory, for the process’s lifetime — by design: LOCAL state never leaves the process and needs no cross-instance unforgeability, so it carries none of the HMAC/hashing cost CLUSTERED’s physical key does. If a key component can itself be sensitive (rarely — most policies key on tenant/user id or IP, not raw secrets), account for that retention in your threat model; CLUSTERED never retains the raw key, only its HMAC digest. The first CAPACITY_EXHAUSTED admission of a saturation episode also logs one WARN — the policy name and configured budget only, never key material — so an OPEN-mode silent-admit degradation and a CLOSED-mode denial spike both leave a signal an operator can alert on; see the failureMode caution above.

A subject-resolution SPI (RateLimitSubjectResolver) and its shared framing helper (RateLimitAdapterSupport) let the annotation, REST, and future adapters resolve an identity- or origin-scoped RateLimitKey without hand-rolling identity encoding — see Extension Points. RateLimitSubject supports the strict NONE, ACTOR, EFFECTIVE_PRINCIPAL, and CLIENT dimensions plus the origin-aware IP, ACTOR_OR_IP, and CLIENT_OR_IP dimensions. Origin-aware dimensions use only the trusted RequestOrigin.clientIp() value; they fail closed when no captured origin is available or its client IP is unresolved (RequestOrigin.clientIpKnown() is false), and never inspect raw headers or socket addresses.

Every consumed decision reports one redacted, synchronous RateLimitDecisionCompleted event to the optional RateLimitObserver set: policy name/revision/mode/algorithm, outcome, cost, capacity, remaining, retry/reset durations, failure code, and backend latency only — never a key, identity, IP, credential, header, or exception. Dispatch is synchronous and per-observer exception-isolated; an implementor’s only obligation is to stay bounded and non-blocking, since observers run on the admission hot path.

limiter(String policyName) and <K> limiter(String policyName, Function<? super K, RateLimitKey> keySelector) resolve named policies into handles; close() is idempotent and force-fails in-flight operations on a second call.

RateLimiter search = rateLimiters.limiter("search-quota");
return search.acquire(RateLimitKey.of(tenantId, userId)).compose(decision -> {
if (decision.permitted()) {
return searchService.run(query);
}
if (decision.outcome() == RateLimitOutcome.QUOTA_EXCEEDED) {
return Future.failedFuture(new SearchQuotaExceeded(decision));
}
return Future.failedFuture(new AdmissionUnavailable(decision));
});

policyName(), failureMode(), and (on RateLimiter only) capacity() expose the resolved policy’s identity and its token-bucket capacity. Both expose acquire(key[, cost]) and execute(key[, cost], Supplier<Future<T>> action); the keyed handle applies its bound selector function before delegating:

KeyedRateLimiter<ReportRequest> reports =
rateLimiters.limiter("report-quota", r -> RateLimitKey.of(r.tenantId()));
return reports.acquire(request).compose(decision ->
decision.permitted() ? generate(request) : Future.succeededFuture(tooBusy(decision)));

record RateLimitDecision(String policyName, RateLimitOutcome outcome, RateLimitMode mode, RateLimitAlgorithmType algorithm, long capacity, OptionalLong remaining, Optional<Duration> retryAfter, Optional<Duration> resetAfter, Optional<RateLimitFailureCode> failureCode). permitted() is true for PERMITTED, BACKEND_FAILURE_OPEN, and DISABLED. retryAfter/resetAfter are relative durations (never an absolute instant, to avoid unreliable client-clock comparison) and retryAfter is present and positive only when the request was rejected.

RateLimitKey.of(Object first, Object... rest) and RateLimitKey.global(); see Core Concepts for the framing discipline. Distinct component tuples always produce distinct canonical keys.

RateLimitSubjectResolver is the one genuine application extension point:

public interface RateLimitSubjectResolver {
Optional<SecurityIdentity> current();
default Optional<RequestOrigin> currentOrigin() {
return Optional.empty();
}
}

It is declared @BindsOptionalOf. The framework default reads the current SecurityContext off the framework ContextHolder, including SecurityContext.origin(). Supplying one custom RateLimitSubjectResolver binding replaces it application-wide; existing implementations remain source-compatible because currentOrigin() defaults to empty. An origin-aware subject therefore fails closed unless the custom resolver supplies a trusted RequestOrigin.

RateLimitAdapterSupport normalizes an empty resolver result and the canonical SecurityIdentity.anonymous() identity to the same unauthenticated state before applying AnonymousRateLimitPolicy. Authenticated identities resolved from JWT or another configured authentication mechanism remain subject-keyed. ACTOR_OR_IP falls back to the trusted client IP only for that unauthenticated state, while CLIENT_OR_IP also uses the trusted client IP when an authenticated identity has no client facet. Strict CLIENT behavior is unchanged and still fails closed when its client facet is absent. The helper is integration surface for adapters, not application API to call directly in ordinary business code.

Behavior change for existing @RateLimited callers. Before this normalization, a caller carrying the canonical anonymous identity was keyed as a literal anonymous principal. It now follows AnonymousRateLimitPolicy:

  • With anonymous = BYPASS, those callers are no longer limited.
  • With strict CLIENT, they no longer fail with SUBJECT_UNRESOLVABLE; the anonymous policy applies.
  • SHARED_BUCKET key bytes changed, so clustered counters start fresh once after upgrade.

RateLimitSubject may gain constants in a minor release; switch over it with a default branch.

All keys live under the root rateLimit object. Every key is optional unless stated; an omitted rateLimit section yields the defaults below with no policies declared. Durations carry an explicit Ms suffix.

{
"rateLimit": {
"enabled": true,
"defaultMode": "LOCAL",
"local": {
"maxTrackedKeys": 100000,
"cleanupIntervalMs": 60000
},
"keyDerivation": {
"secret": "${RATE_LIMIT_KEY_SECRET}"
},
"policies": {
"search-quota": {
"enabled": true,
"failureMode": "CLOSED",
"revision": "r1",
"defaultCost": 1,
"algorithm": {
"type": "TOKEN_BUCKET",
"capacity": 100,
"refill": { "type": "GREEDY", "tokens": 100, "periodMs": 60000 }
},
"local": { "maxTrackedKeys": 500000 }
}
}
}
}
Key Type Default Description
rateLimit.enabled boolean true Root kill switch. When false, every acquire(...) on every handle yields a DISABLED decision without engaging any backend; execute(...) runs the action. Startup validation still runs.
rateLimit.defaultMode LOCAL | CLUSTERED LOCAL Mode applied to any policy that omits mode. An unrecognized value fails startup with ConfigurationException naming rateLimit.defaultMode.
rateLimit.local.maxTrackedKeys long 100000 Per-policy bound on distinct keys a LOCAL registry tracks. Must be at least 1. Applies to each LOCAL policy separately, never as one pool shared across policies.
rateLimit.local.cleanupIntervalMs long (ms) 60000 Interval at which inactive LOCAL state is reclaimed. Must be at least 1.
rateLimit.keyDerivation.secret string none HMAC secret used to derive CLUSTERED physical keys. Required when any enabled policy is CLUSTERED, and must be at least 32 bytes in UTF-8; a missing, shorter, or unresolved ${...} placeholder value fails startup. Not validated for LOCAL-only applications. Supply it through a secret-bearing property source or environment placeholder, never as a literal in a committed file.
rateLimit.policies.<name> object none One policy per key; the key is the policy name ([A-Za-z0-9._~-]{1,128}). A configuration policy replaces a same-named Dagger-contributed RateLimitPolicy wholesale.

rateLimit.policies.<name> accepts these fields. All are required with no default except mode (falls back to rateLimit.defaultMode) and local.

Field Type Description
enabled boolean Whether the policy is active. A disabled policy resolves a handle that always yields DISABLED and needs no bound backend.
mode LOCAL | CLUSTERED Where admission state lives. Defaults to rateLimit.defaultMode.
failureMode OPEN | CLOSED Backend-failure and LOCAL key-space-saturation behavior. Required even on a disabled policy.
revision string Bumped on any semantic algorithm or key change; [A-Za-z0-9._-]{1,32}.
defaultCost long Tokens consumed when a caller passes no explicit cost; 1..1000000000000.
algorithm object type must be TOKEN_BUCKET; capacity is 1..1000000000000; refill is { "type": "GREEDY" | "INTERVAL", "tokens": 1..1000000000000, "periodMs": 1..31536000000 }.
local.maxTrackedKeys long Optional per-policy override of rateLimit.local.maxTrackedKeys; at least 1. Absent means the global value applies. Only meaningful for LOCAL policies.

At most 10,000 policies may be declared. Invalid or out-of-range values fail startup with a ConfigurationException (or IllegalStateException for runtime-level validation such as a duplicate policy name, an enabled policy whose mode has no bound backend, or a missing or short keyDerivation.secret) before any handle is requested.

Binding Kind Description
Map<RateLimitMode, RateLimitBackend> @Multibinds Backend provider map; core contributes the LOCAL entry, vertique-rate-limit-redis contributes CLUSTERED
Set<RateLimitPolicy> @Multibinds Programmatic policy contributions merged under configuration-declared policies
Set<RateLimitObserver> @Multibinds Observer contributions from telemetry adapters or application code
RateLimitSubjectResolver @BindsOptionalOf See Extension Points
RateLimiters @Provides @Singleton The application entry point
ApplicationShutdownStep @Provides @IntoSet Forces eager RateLimiters construction so startup validation runs unconditionally

The LOCAL backend entry itself is a framework-private Bucket4j implementation reached only through this map; it is Dagger composition surface, not something an application implements or replaces.

Run the rate-limit family proof with:

./mvnw -ntp -pl vertique-rate-limit/vertique-rate-limit-core,vertique-rate-limit/vertique-rate-limit-aop,vertique-rate-limit/vertique-rate-limit-redis,vertique-codegen/vertique-codegen-rate-limit,vertique-rest/vertique-rest-rate-limit,vertique-micrometer/vertique-micrometer-rate-limit,vertique-opentelemetry/vertique-opentelemetry-rate-limit -am verify

The clean reactor verification additionally checks dependency and BOM parity and packaged module-documentation parity:

./mvnw -ntp clean verify
Artifact Purpose
vertique-core Framework foundations and shared configuration/runtime contracts
vertique-context Vert.x context propagation for observer/decision dispatch
vertique-security-core Provider-neutral SecurityContext/SecurityIdentity contracts for subject resolution