Skip to content

Rate limit AOP

View Markdown

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

vertique-rate-limit-aop adapts one @RateLimited method declaration to the provider-neutral programmatic rate-limiting API. It owns the annotation vocabulary, delegation to the shared runtime selector-path resolver, and the Dagger binding for the rate-limit aspect. The generic Vertique AOP processor generates the application proxy and reflection-free method metadata; this module does not own proxy generation, quota math, or backend behavior.

@RateLimited is transport-neutral: it is usable on any Dagger-provided component — services, REST resources, and future MCP tools alike — because the generic AOP proxy intercepts Dagger Provider.get() resolution, not a transport-specific hook.

Install RateLimitAopModule alongside RateLimitCoreModule (and vertique-rate-limit-redis for CLUSTERED policies) when methods should declare per-operation admission through the annotation rather than manual RateLimiters calls. Add vertique-codegen-rate-limit to the compiler’s annotation-processor path so declarations are validated at compile time.

@RateLimited(policy = "search-quota", key = {"tenantId", "userId"})
public Future<SearchResult> search(String tenantId, String userId, Query query) { ... }

@RateLimited names exactly one policy and is non-repeatable in v1: numeric limits, algorithm, mode, revision, and failure behavior never appear in the annotation, only in the named policy’s configuration. key() is an ordered array of selector paths — never a template or format string — following the shared selector-path grammar documented in the Selector-path grammar section of the dev.vertique:vertique-aop module reference. subject (default EFFECTIVE_PRINCIPAL) and anonymous (default SHARED_BUCKET) control identity- or origin-scoped keying (see vertique-rate-limit-core’s RateLimitSubjectResolver/RateLimitAdapterSupport); the origin-aware values are IP, ACTOR_OR_IP, and CLIENT_OR_IP. They use the trusted RequestOrigin.clientIp() captured in the security context and fail closed when it is absent. CLIENT_OR_IP falls back to that IP only when an authenticated identity has no client facet; strict CLIENT remains fail-closed. cost (default 1) is the per-invocation cost. An application that uses any subject() other than NONE must co-install the existing AuthModule/SecurityModule (or JwtAuthModule) so a typed, possibly anonymous, SecurityContext exists before the aspect runs — otherwise every caller resolves as anonymous.

@Aspect(ordering = 300) places @RateLimited outside @Cacheable(200)/ @CacheEvict(100) and inside @Timed(1000) in the generated interceptor chain.

When composed with @Cacheable, this ordering makes quota admission the outer boundary: every invocation consumes one quota unit before the cache lookup, including a cache hit. A cache miss enters the nested cache and resilience pipeline only after admission; retries remain inside that single admission and do not consume additional quota units.

Handle resolution happens once, at generated-proxy-constructor time: an unknown policy name fails application startup, before any request is served, exactly as @RateLimited’s handle-creation contract in vertique-rate-limit-core describes. At invocation, resolved selector components are framed into a RateLimitKey through the identity-framing seam; an empty framing result (an anonymous caller under AnonymousRateLimitPolicy.BYPASS) bypasses admission entirely and the method proceeds unlimited, otherwise the resolved handle’s guarded execute(key, cost, invocation::proceed) runs.

Self-invocation bypass. Calling an @RateLimited method on this from inside the same bean, or constructing the bean directly instead of resolving it through Dagger, bypasses the generated proxy and therefore bypasses admission entirely — identical to @Cacheable’s documented behavior. Only calls that arrive through the Dagger-resolved proxy are intercepted.

policy() (required), key() (default {}), subject() (default RateLimitSubject.EFFECTIVE_PRINCIPAL), anonymous() (default AnonymousRateLimitPolicy.SHARED_BUCKET), cost() (default 1).

The annotation is @Target(ElementType.METHOD) only and carries @Aspect(ordering = 300). Place it on methods of Dagger-provided classes, or on an interface method (including an inherited default) that a concrete implementing class inherits or overrides in the same compilation — vertique-codegen-aop weaves the trigger onto that implementor’s $AopProxy. An interface alone is not a proxy bean; without a concrete implementor in the compilation, no proxy is generated and no admission occurs.

Public abstract Dagger @Module. Its single @Binds method binds AspectProvider<RateLimited> to the package-private RateLimitedAspect, which the generated proxy resolves to build the interceptor for each @RateLimited method. RateLimitedAspect itself is not application API. Install the module in the application component alongside RateLimitCoreModule, which supplies the RateLimiters runtime the aspect depends on.

Binding Kind Description
AspectProvider<RateLimited> @Binds The generated-proxy interceptor for @RateLimited methods
./mvnw -ntp -pl vertique-rate-limit/vertique-rate-limit-aop -am verify
Artifact Purpose
vertique-rate-limit-core Programmatic rate-limiting API the aspect delegates to
vertique-aop Generic proxy generation and AspectProvider/@Aspect machinery
vertique-core Framework foundations