Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,9 @@ The application uses Redis (`spring.data.redis.host`/`port`, default `localhost:
| Cache | What | TTL |
|---|---|---|
| `sdlc-systems` | SDLC systems by id, read on every create, update and SDLC call | `resourceapi.cache.sdlc-systems-ttl` (10m) |
| `external-projects` | `GET /api/v2/projects/{id}/external` answers per SDLC system and external id; "unknown" answers are not cached | `resourceapi.cache.external-projects-ttl` (5m) |
| `external-projects` | `GET /api/v2/projects/{id}/external` answers per SDLC system and external id | `resourceapi.cache.external-projects-ttl` (5m); "the SDLC system doesn't know this project" (stored as a cached null) only `resourceapi.cache.missing-external-projects-ttl` (30s), so a project created there is found soon |

A background sync always asks the SDLC system and refreshes the cached answer (or removes it when the SDLC
system no longer knows the project). SDLC systems are not changed
A background sync always asks the SDLC system and replaces the cached answer with its own. SDLC systems are not changed
through this API; one changed directly in the database is picked up when its entry expires. The cache is an
optimization: if Redis is unreachable, cache reads and writes are logged and skipped (each waits at most the
500 ms Redis timeouts) and requests are served without it.
Expand All @@ -97,6 +96,28 @@ standalone Redis is supported for the lock: with Sentinel or Cluster the applica
run unlocked; TLS uses the JVM's trust store (Boot SSL bundles aren't passed to Redisson). (Redisson's Spring Boot starter isn't used: it targets
Spring Boot 3. Redisson brings a JCache provider, so `spring.cache.type=redis` is set explicitly.)

**Rate limits** (Redisson `RRateLimiter`, sliding windows shared by all instances: a used permit becomes
available again one period later, which is also what `Retry-After` says):

| Limit | Default | When exceeded |
|---|---|---|
| Calls to one SDLC system (`resourceapi.rate-limit.sdlc.*`), protecting its API quota; cache hits don't count | 10 per 1s, waiting up to 1s for a permit | `503` + `Retry-After` (our own limit, not the SDLC system's failure); a background sync logs it |
| `POST /api/v2/projects/{id}/sync` per project (`resourceapi.rate-limit.sync.*`); requests refused anyway (unknown project, sync already running) don't count, one refused because no background slot is free (`503`) does | 6 per minute | `429` + `Retry-After` |
| All `/api/**` requests per client IP (`resourceapi.rate-limit.client.*`); behind a reverse proxy the IP comes from `X-Forwarded-For`, trusted only from internal proxy addresses (`server.forward-headers-strategy=native`, see below) | 100 per second | `429` + `Retry-After` |

A changed rate is applied to the existing limiter when an instance first uses it; unused limiters expire from
Redis after 10 periods (at least a minute), so per-IP limiters don't pile up. Invalid limits (e.g.
`permits=0`) stop the application at startup. If Redis can't be reached, requests are allowed (limiting
protects, losing it shouldn't stop the application), and Redis is not asked again for 10 seconds, so an
outage doesn't add a timeout to every request.

The per-IP limit only means something if the application sees the client's address. Tomcat takes it from
`X-Forwarded-For` when the request comes from an address matching `server.tomcat.remoteip.internal-proxies`
(by default 10/8, 192.168/16, 172.16/12, 169.254/16, 127/8 and IPv6 local addresses). If the reverse proxy or
load balancer has another address, set that property to match it; otherwise every request appears to come from
the proxy and all clients share one limit. Never match addresses clients can connect from directly, or they
can choose their own key by sending `X-Forwarded-For`.

**Local development**: `./mvnw spring-boot:run` starts Redis from `compose.yaml` automatically
(`spring-boot-docker-compose`); with `java -jar`, start it yourself with `docker compose up -d` (it listens on
`localhost:6379`). Tests start their own Redis with Testcontainers, so they need Docker.
Expand Down
15 changes: 12 additions & 3 deletions src/main/java/com/company/resourceapi/config/CacheConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.cache.interceptor.LoggingCacheErrorHandler;
import org.springframework.cache.support.NullValue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Role;
Expand All @@ -26,31 +27,39 @@
@EnableCaching
@EnableConfigurationProperties(CacheTtlProperties.class)
// cached values are rebuilt from JSON by type name: reflection a native image must know about
@RegisterReflectionForBinding({SdlcSystem.class, ExternalProject.class})
@RegisterReflectionForBinding({SdlcSystem.class, ExternalProject.class, NullValue.class})
public class CacheConfig {

public static final String SDLC_SYSTEMS = "sdlc-systems";
public static final String EXTERNAL_PROJECTS = "external-projects";

/**
* Starts from Boot's defaults (spring.cache.redis.*: key prefix, null values, ...) and only
* replaces the value serializer and sets each cache's time to live.
* replaces the value serializer and sets each cache's time to live. External projects rely on
* null values being cached (spring.cache.redis.cache-null-values, true by default).
*/
@Bean
RedisCacheManagerBuilderCustomizer redisCacheCustomizer(CacheTtlProperties ttl) {
var typeValidator = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.company.resourceapi.")
.allowIfSubType("java.lang.")
.allowIfSubType("java.time.")
// Spring's marker for a cached null ("the SDLC system doesn't know this project")
.allowIfSubType(NullValue.class)
.build();
var serializer = GenericJacksonJsonRedisSerializer.builder()
.enableDefaultTyping(typeValidator)
.enableSpringCacheNullValueSupport()
.build();
return builder -> {
var defaults = builder.cacheDefaults().serializeValuesWith(SerializationPair.fromSerializer(serializer));
builder.cacheDefaults(defaults)
.withCacheConfiguration(SDLC_SYSTEMS, defaults.entryTtl(ttl.sdlcSystemsTtl()))
.withCacheConfiguration(EXTERNAL_PROJECTS, defaults.entryTtl(ttl.externalProjectsTtl()));
// a found project is kept longer than "unknown", so a project created there is found soon
.withCacheConfiguration(EXTERNAL_PROJECTS, defaults.entryTtl((key, value) ->
value == null || value instanceof NullValue
? ttl.missingExternalProjectsTtl()
: ttl.externalProjectsTtl()));
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@
*
* @param sdlcSystemsTtl SDLC systems by id; they change rarely and only outside this API
* @param externalProjectsTtl projects as described by their SDLC system ({@code GET .../external})
* @param missingExternalProjectsTtl "the SDLC system doesn't know this project" answers; short, so a
* project created there is found soon
*/
@ConfigurationProperties("resourceapi.cache")
public record CacheTtlProperties(
@DefaultValue("10m") Duration sdlcSystemsTtl,
@DefaultValue("5m") Duration externalProjectsTtl) {
@DefaultValue("5m") Duration externalProjectsTtl,
@DefaultValue("30s") Duration missingExternalProjectsTtl) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.company.resourceapi.config;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.time.Duration;
import org.springframework.aot.hint.MemberCategory;
import org.springframework.aot.hint.annotation.RegisterReflection;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

/**
* Distributed rate limits ({@code resourceapi.rate-limit.*}), shared by all instances through Redis.
* Validated at startup: a broken limit would otherwise fail, or silently stop limiting, at runtime.
*
* @param sdlc calls to one SDLC system (protects its API quota)
* @param sync sync requests for one project
* @param client API requests from one client (IP address)
*/
@Validated
@ConfigurationProperties("resourceapi.rate-limit")
// validation reads the record fields reflectively, which a native image must know about
@RegisterReflection(classes = {RateLimitProperties.class, RateLimitProperties.Limit.class},
memberCategories = {MemberCategory.ACCESS_DECLARED_FIELDS, MemberCategory.INVOKE_DECLARED_METHODS})
public record RateLimitProperties(@NotNull @Valid Limit sdlc, @NotNull @Valid Limit sync, @NotNull @Valid Limit client) {

/**
* At most {@code permits} per sliding window of {@code period}: each permit becomes available again
* one period after it was used.
*
* @param permits how many per period
* @param period the sliding window
* @param maxWait how long a caller may wait for a permit before being refused
*/
public record Limit(@Positive long permits, @NotNull Duration period, @NotNull Duration maxWait) {

/** When a permit is surely available again: a used permit only comes back after a full period. */
public long retryAfterSeconds() {
return Math.max(1, (long) Math.ceil(period.toMillis() / 1000.0));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.data.redis.autoconfigure.DataRedisConnectionDetails;
import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties;
import org.springframework.context.annotation.Bean;
Expand All @@ -25,6 +26,7 @@
* back to running without it. TLS uses the JVM's trust store (Boot SSL bundles aren't passed on).
*/
@Configuration
@EnableConfigurationProperties(RateLimitProperties.class)
public class RedissonConfig {

static final String REDISSON_CLIENT = "redissonClient";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.company.resourceapi.exceptions;

import lombok.Getter;

/** One of our own rate limits refused the request; it can be retried after {@link #getRetryAfterSeconds()}. */
@Getter
public class RateLimitExceededException extends RuntimeException {

/** Whose limit: the caller's own requests (429) or the application's calls to a system (503). */
public enum Scope { CALLER, UPSTREAM }

private final Scope scope;
private final long retryAfterSeconds;

public RateLimitExceededException(Scope scope, String message, long retryAfterSeconds) {
super(message);
this.scope = scope;
this.retryAfterSeconds = retryAfterSeconds;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,50 @@

import com.company.resourceapi.clients.ExternalProject;
import com.company.resourceapi.clients.SdlcProjectClient;
import com.company.resourceapi.config.RateLimitProperties;
import com.company.resourceapi.entities.SdlcSystem;
import com.company.resourceapi.exceptions.RateLimitExceededException;
import com.company.resourceapi.exceptions.RateLimitExceededException.Scope;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service;

/** Projects as described by their SDLC system, cached per SDLC system and external id. */
/**
* Projects as described by their SDLC system, cached per SDLC system and external id, and rate limited
* per SDLC system across all instances.
*/
@Service
@RequiredArgsConstructor
public class ExternalProjectService {

private final SdlcProjectClient sdlcProjectClient;
private final RateLimiters rateLimiters;
private final RateLimitProperties rateLimits;

/** Cached; a project unknown to the SDLC system isn't cached, so it is found once it exists. */
@Cacheable(cacheNames = EXTERNAL_PROJECTS, key = "#sdlcSystem.id + ':' + #externalId", unless = "#result == null")
/**
* Cached, including the answer "the SDLC system doesn't know this project" (cached as null), which
* is kept only briefly so a project created there is found soon (see CacheConfig).
*/
@Cacheable(cacheNames = EXTERNAL_PROJECTS, key = "#sdlcSystem.id + ':' + #externalId")
public Optional<ExternalProject> findProject(SdlcSystem sdlcSystem, String externalId) {
return sdlcProjectClient.fetchProject(sdlcSystem, externalId);
return fetch(sdlcSystem, externalId);
}

/**
* Always asks the SDLC system and updates the cache with the answer: a found project replaces the
* cached one, a project the SDLC system no longer knows removes it.
*/
@Caching(
put = @CachePut(cacheNames = EXTERNAL_PROJECTS, key = "#sdlcSystem.id + ':' + #externalId",
unless = "#result == null"),
// unlike for @CachePut, #result is the Optional itself here (late evictions get the raw return value)
evict = @CacheEvict(cacheNames = EXTERNAL_PROJECTS, key = "#sdlcSystem.id + ':' + #externalId",
condition = "#result == null or #result.isEmpty()"))
/** Always asks the SDLC system and caches its answer, found or unknown, replacing the cached one. */
@CachePut(cacheNames = EXTERNAL_PROJECTS, key = "#sdlcSystem.id + ':' + #externalId")
public Optional<ExternalProject> refreshProject(SdlcSystem sdlcSystem, String externalId) {
return fetch(sdlcSystem, externalId);
}

/** Only real calls count against the SDLC system's rate limit, not cache hits. */
private Optional<ExternalProject> fetch(SdlcSystem sdlcSystem, String externalId) {
var limit = rateLimits.sdlc();
if (!rateLimiters.tryAcquire("sdlc-rate:" + sdlcSystem.getId(), limit)) {
throw new RateLimitExceededException(Scope.UPSTREAM,
"Rate limit for SdlcSystem %d reached.".formatted(sdlcSystem.getId()), limit.retryAfterSeconds());
}
return sdlcProjectClient.fetchProject(sdlcSystem, externalId);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.company.resourceapi.services;

import com.company.resourceapi.config.RateLimitProperties;
import com.company.resourceapi.exceptions.NotFoundException;
import com.company.resourceapi.exceptions.RateLimitExceededException;
import com.company.resourceapi.exceptions.RateLimitExceededException.Scope;
import com.company.resourceapi.exceptions.SyncInProgressException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
Expand All @@ -13,11 +16,14 @@ public class ProjectSyncService {
private final ProjectService projectService;
private final ProjectSyncLock projectSyncLock;
private final ProjectSyncWorker projectSyncWorker;
private final RateLimiters rateLimiters;
private final RateLimitProperties rateLimits;

/**
* Starts refreshing the project from its SDLC system in the background.
*
* @throws NotFoundException if the project doesn't exist
* @throws RateLimitExceededException if the project was synced too often recently
* @throws SyncInProgressException if a sync of the project is still running
* @throws org.springframework.core.task.TaskRejectedException if no background slot is free
*/
Expand All @@ -27,6 +33,15 @@ public void requestSync(long projectId) throws NotFoundException {

var lease = projectSyncLock.tryAcquire(projectId)
.orElseThrow(() -> new SyncInProgressException(projectId));

// counted only for syncs that could run: an unknown project or a running sync doesn't use a permit
var limit = rateLimits.sync();
if (!rateLimiters.tryAcquire("sync-rate:" + projectId, limit)) {
lease.release();
throw new RateLimitExceededException(Scope.CALLER,
"Too many sync requests for project %d.".formatted(projectId), limit.retryAfterSeconds());
}

try {
// the background task releases the lock when it ends, however it ends
projectSyncWorker.syncName(projectId).whenComplete((result, failure) -> lease.release());
Expand Down
Loading
Loading