Skip to content

Remember unknown SDLC projects briefly; rate limit API requests per client - #17

Merged
andreasarf merged 4 commits into
claude/redis-rate-limiterfrom
claude/redis-more-use-cases
Nov 1, 2026
Merged

andreasarf merged 4 commits into
claude/redis-rate-limiterfrom
claude/redis-more-use-cases

Conversation

@andreasarf

@andreasarf andreasarf commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fourth Redis PR, stacked on #16 (rate limits). Merge in order #14 → #15 → #16 → this one. It adds the two use cases from the plan that #14–#16 didn't cover, plus hardening that the per-client limit made necessary.

Briefly cache "unknown project" answers

  • Same cache, shorter TTL. When the SDLC system says it doesn't know a project, the answer is stored in the external-projects cache as Spring's cached null. A per-entry TTL keeps it for 30 s (resourceapi.cache.missing-external-projects-ttl); found projects are kept for 5 min. Repeated GET /external calls for an unknown project no longer each call the SDLC system.
  • Found quickly. A project created in the SDLC system is found within 30 s. A sync (@CachePut) replaces the cached answer right away.
  • Plain annotations. @Cacheable and @CachePut, no hand-written cache code, so the shared fail-soft cache error handler covers Redis outages.
  • Depends on a Boot default. Relies on spring.cache.redis.cache-null-values=true (the default).

Per-client API rate limit

  • What it does. ClientRateLimitFilter limits each client IP on /api/*, across all instances: 100 per second (resourceapi.rate-limit.client.*), then 429 + Retry-After.
  • Error response. A refusal goes through the shared exception handler (RateLimitExceededException), like the other 429s, and is logged at DEBUG, so a flood of refusals doesn't flood the logs.
  • Registration. A FilterRegistrationBean maps it to /api/* only, so the OpenAPI document and Swagger UI aren't limited. It runs after the trace filter, so a refused request still carries X-Trace-Id.
  • Behind a proxy. server.forward-headers-strategy=native makes Tomcat take the client IP from X-Forwarded-For, but only from internal proxy addresses. The README explains when server.tomcat.remoteip.internal-proxies must be set.

Rate limiter hardening

These change RateLimiters from #16.

  • Redis outage. After a failure, Redis isn't asked for 10 s and requests are allowed, so an outage doesn't add a timeout to every request. One WARN per pause.
  • Key expiry. Limiters expire 10 periods after their last use (at least 1 min), instead of after 1 h, so per-IP limiters don't pile up in Redis.

Native image fixes

Made in the PRs that introduced the code, then merged up:

Tests

  • CacheIntegrationTest:
    • an unknown answer is cached as null with the short TTL, then the project is found once it expires;
    • the entry then gets the long TTL;
    • a sync replaces the cached null immediately;
    • SDLC call counts are taken after the first lookup (the HTTP client may retry on a stale pooled connection), and the timing-sensitive intermediate assertion is gone.
  • ClientRateLimitIntegrationTest:
    • the 4th request from one client gets 429 + Retry-After + X-Trace-Id;
    • another client is unaffected;
    • /v3/api-docs isn't limited.
  • RateLimitersTest (new): when Redis fails, requests are allowed and Redis is asked once, not on every request.
  • RateLimitIntegrationTest: a limiter's Redis TTL is the 1-min minimum.
  • Mutation checks: removing the pause, using one TTL for both answers, or restoring the 1 h expiry each fails a test.

Full suite: 112 tests pass, run twice.

Native image of this head, run against Redis 8 with a WireMock stand-in for the SDLC system:

  • starts;
  • an unknown project is cached as null (TTL 30 s, 1 SDLC call for 5 lookups);
  • a sync replaces it with the project (TTL 5 min);
  • the 6th sync within a minute gets 429;
  • keys are prefixed;
  • no errors in the log.

🤖 Generated with Claude Code

https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2

…lient

- external-projects-missing cache (30s): a project the SDLC system doesn't
  know is answered from the cache instead of asking again each time, and
  found soon after it is created there; a sync replaces it with the
  project. Used directly through the CacheManager, so failures are
  caught and logged like the cache error handler does
- ClientRateLimitFilter on /api/* (FilterRegistrationBean, after the trace
  filter): requests per client IP across all instances (100/s, 429 +
  Retry-After); server.forward-headers-strategy=native takes the client IP
  from X-Forwarded-For only from internal proxy addresses
- Tests: unknown answer remembered then found after expiry, sync clears it;
  per-client 429 with Retry-After and X-Trace-Id, separate per client,
  non-API paths unlimited; the repeated-lookup test counts calls after the
  first lookup, as the HTTP client may retry a GET on a stale connection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
…ases

# Conflicts:
#	src/main/java/com/company/resourceapi/config/RateLimitProperties.java
…ed 429

- "Unknown project" is cached as Spring's null value in the external-projects
  cache with a per-entry TTL (30s unknown, 5m found), replacing the separate
  cache and the hand-written lookups; a sync simply @CachePut-replaces it.
- RateLimiters stops asking Redis for 10s after a failure, so an outage
  doesn't add a timeout to every request.
- Limiters expire 10 periods after last use (at least a minute) instead of
  an hour, so per-IP limiters don't pile up.
- The per-client filter answers through the shared exception handler;
  refused callers are logged at DEBUG.
- README: server.tomcat.remoteip.internal-proxies for the per-IP limit.
- Tests: longer negative-cache TTL, no timing-sensitive 404 assertion,
  delta-based call counts, unit test for the outage pause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
@andreasarf
andreasarf added this pull request to stack #18 October 31, 2026 14:21
@andreasarf
andreasarf merged commit 39f3d56 into main Nov 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants