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
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package fr.enimaloc.catapult.getter;

import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;

/**
* The calls sent with each shared API key, for the admin Keys tab: those in the last {@code window} and those in the
* current period (a day, a month: what {@code periodOf} names an instant's). Counted on this instance only.
*/
public final class KeyCallCounter {

private final Duration window;
private final Function<Instant, Object> periodOf;
private final Map<String, Counts> byKey = new ConcurrentHashMap<>();

private static final class Counts {
final Deque<Instant> recent = new ArrayDeque<>();
Object period;
long inPeriod;
}

public KeyCallCounter(Duration window, Function<Instant, Object> periodOf) {
this.window = window;
this.periodOf = periodOf;
}

public void record(String key, Instant now) {
Counts c = byKey.computeIfAbsent(key, k -> new Counts());
synchronized (c) {
c.recent.addLast(now);
prune(c, now);
Object period = periodOf.apply(now);
if (!period.equals(c.period)) {
c.period = period;
c.inPeriod = 0;
}
c.inPeriod++;
}
}

/** The calls sent with {@code key} in the last window. */
public int inWindow(String key, Instant now) {
Counts c = byKey.get(key);
if (c == null) return 0;
synchronized (c) {
prune(c, now);
return c.recent.size();
}
}

/** The calls sent with {@code key} in the current period. */
public long inPeriod(String key, Instant now) {
Counts c = byKey.get(key);
if (c == null) return 0;
synchronized (c) {
return periodOf.apply(now).equals(c.period) ? c.inPeriod : 0;
}
}

private void prune(Counts c, Instant now) {
Instant from = now.minus(window);
while (!c.recent.isEmpty() && !c.recent.peekFirst().isAfter(from)) c.recent.removeFirst();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@

import fr.enimaloc.catapult.domain.dtdd.DtddApiKeyEntry;
import fr.enimaloc.catapult.repository.dtdd.DtddApiKeyRepository;
import fr.enimaloc.catapult.common.dto.admin.KeyUsageDto;
import fr.enimaloc.catapult.getter.KeyCallCounter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.Instant;
import java.time.YearMonth;
import java.time.ZoneOffset;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
Expand All @@ -26,6 +34,11 @@ public class DtddApiKeyRotator {
private final Map<String, Quota> quotas = new ConcurrentHashMap<>();
private final AtomicInteger counter = new AtomicInteger(0);
private volatile List<String> keys = List.of();
private final KeyCallCounter calls =
new KeyCallCounter(Duration.ofMinutes(1), at -> YearMonth.from(at.atZone(ZoneOffset.UTC)));
/** A key's limits when DTDD doesn't tell them ({@code X-RateLimit-Limit-*}): its free tier's. */
@Setter @Value("${dtdd.rate-limit.per-minute:30}") private int defaultPerMinute = 30;
@Setter @Value("${dtdd.rate-limit.per-month:5000}") private long defaultPerMonth = 5000;

public DtddApiKeyRotator(DtddApiKeyRepository repository, MeterRegistry meterRegistry) {
this.repository = repository;
Expand Down Expand Up @@ -90,24 +103,56 @@ public boolean isAllKeysBlocked() {
}

/**
* What DTDD said was left of a key's quota with its last answer ({@code X-RateLimit-Remaining-*}), null when it
* didn't say; {@code at} when it said it (epoch ms).
* What DTDD said was left of a key's quota with its last answer ({@code X-RateLimit-Remaining-*}), and its limits
* ({@code X-RateLimit-Limit-*}), null when it didn't say; {@code at} when it said it (epoch ms).
*/
public record Quota(Integer remainingMinute, Integer remainingMonth, long at) {}
public record Quota(Integer remainingMinute, Integer remainingMonth, long at, Integer limitMinute,
Integer limitMonth) {

/** What was left, without the limits. */
public Quota(Integer remainingMinute, Integer remainingMonth, long at) {
this(remainingMinute, remainingMonth, at, null, null);
}
}

/**
* Remembers what DTDD said was left of {@code key}'s quota. Nothing left this minute: the key rests until the
* next one instead of asking for a 429.
*/
public void onQuota(String key, Integer remainingMinute, Integer remainingMonth) {
onQuota(key, remainingMinute, remainingMonth, null, null);
}

/** As {@link #onQuota(String, Integer, Integer)}, with the limits DTDD told (null: not told). */
public void onQuota(String key, Integer remainingMinute, Integer remainingMonth, Integer limitMinute,
Integer limitMonth) {
if (remainingMinute == null && remainingMonth == null) return;
long now = System.currentTimeMillis();
quotas.put(key, new Quota(remainingMinute, remainingMonth, now));
quotas.put(key, new Quota(remainingMinute, remainingMonth, now, limitMinute, limitMonth));
if (remainingMinute != null && remainingMinute <= 0) {
keyBlockedUntil.merge(key, now + 60_000L, Math::max);
}
}

/** A call about to be sent with {@code key}: counted for the admin Keys tab. */
public void onCall(String key) {
calls.record(key, Instant.now());
}

/**
* What {@code key} was used for on this instance: its calls of the last minute and of the month (UTC), each out
* of the limit DTDD told (else the free tier's), with what DTDD said was left (the minute's only while fresh).
*/
public KeyUsageDto usage(String key) {
Instant now = Instant.now();
Quota q = quotas.get(key);
boolean fresh = q != null && now.toEpochMilli() - q.at() < 60_000L;
Integer limitMinute = q != null && q.limitMinute() != null ? q.limitMinute() : defaultPerMinute;
Long limitMonth = q != null && q.limitMonth() != null ? (long) q.limitMonth() : defaultPerMonth;
return new KeyUsageDto(calls.inWindow(key, now), limitMinute, 60, fresh ? q.remainingMinute() : null,
"MONTH", calls.inPeriod(key, now), limitMonth, q == null ? null : q.remainingMonth());
}

/** Each key's last known quota (keys DTDD never answered for are absent). */
public Map<String, Quota> getQuotas() {
return Map.copyOf(quotas);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,12 @@ private <T> Optional<T> callWithRetry(Function<String, ResponseEntity<String>> c
if (keyOpt.isEmpty()) return Optional.empty();
String key = keyOpt.get();
try {
rotator.onCall(key);
ResponseEntity<String> response = call.apply(key);
rotator.onQuota(key, header(response.getHeaders(), "X-RateLimit-Remaining-Minute"),
header(response.getHeaders(), "X-RateLimit-Remaining-Month"));
header(response.getHeaders(), "X-RateLimit-Remaining-Month"),
header(response.getHeaders(), "X-RateLimit-Limit-Minute"),
header(response.getHeaders(), "X-RateLimit-Limit-Month"));
return Optional.ofNullable(parser.apply(response.getBody()));
} catch (RestClientResponseException e) {
HttpStatusCode status = e.getStatusCode();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
package fr.enimaloc.catapult.getter.steam;

import fr.enimaloc.catapult.common.dto.admin.KeyUsageDto;
import fr.enimaloc.catapult.getter.KeyCallCounter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
Expand All @@ -28,6 +35,10 @@ public class SteamRateLimiter {
private final ConcurrentHashMap<String, Semaphore> keyPermits = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Long> keyBlockedUntil = new ConcurrentHashMap<>();
private volatile long globalBlockedUntil = 0;
/** Steam's documented daily limit per key (100,000 calls), shown against the calls of the day. */
@Setter @Value("${steam.rate-limit.daily-limit:100000}") private long dailyLimit = 100_000;
private final KeyCallCounter calls =
new KeyCallCounter(Duration.ofSeconds(1), at -> LocalDate.ofInstant(at, ZoneOffset.UTC));

public SteamRateLimiter(
@Value("${steam.rate-limit.permits-per-window:3}") int permitsPerWindow,
Expand Down Expand Up @@ -67,7 +78,7 @@ public boolean acquire(String key) {
long now = System.currentTimeMillis();
if (now < globalBlockedUntil || now < keyBlockedUntil.getOrDefault(key, 0L)) return false;
try {
return semaphoreFor(key).tryAcquire(maxWaitMs, TimeUnit.MILLISECONDS);
return counted(key, semaphoreFor(key).tryAcquire(maxWaitMs, TimeUnit.MILLISECONDS));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
Expand All @@ -80,7 +91,7 @@ public boolean acquire(String key) {
public boolean acquireBlocking(String key) {
try {
semaphoreFor(key).acquire();
return true;
return counted(key, true);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
Expand Down Expand Up @@ -130,6 +141,22 @@ void replenish() {
});
}

/**
* What {@code key} was used for on this instance: the permits taken in the current window out of
* {@code permitsPerWindow}, and the calls of the day (UTC) out of Steam's daily limit.
*/
public KeyUsageDto usage(String key) {
Semaphore permits = keyPermits.get(key);
int used = permits == null ? 0 : Math.max(0, permitsPerWindow - permits.availablePermits());
return new KeyUsageDto(used, permitsPerWindow, (int) Math.max(1, windowMs / 1000), null,
"DAY", calls.inPeriod(key, Instant.now()), dailyLimit, null);
}

private boolean counted(String key, boolean acquired) {
if (acquired) calls.record(key, Instant.now());
return acquired;
}

private Semaphore semaphoreFor(String key) {
return keyPermits.computeIfAbsent(key, k -> new Semaphore(permitsPerWindow, true));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ public DtddKeysPageData page() {
long until = blockedUntil.getOrDefault(key, 0L);
boolean blocked = until > now;
long remainingSec = blocked ? TimeUnit.MILLISECONDS.toSeconds(until - now) : 0L;
keys.add(new KeyStatus(ApiKeyHasher.id(key), masked, owner, blocked, remainingSec));
keys.add(new KeyStatus(ApiKeyHasher.id(key), masked, owner, blocked, remainingSec,
rotator.map(r -> r.usage(key)).orElse(null)));
}
return new DtddKeysPageData(keys, rotator.isPresent());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import fr.enimaloc.catapult.common.dto.admin.SteamKeysPageData;
import fr.enimaloc.catapult.domain.steam.SteamApiKeyEntry;
import fr.enimaloc.catapult.getter.steam.SteamApiKeyRotator;
import fr.enimaloc.catapult.getter.steam.SteamRateLimiter;
import fr.enimaloc.catapult.repository.steam.SteamApiKeyRepository;
import fr.enimaloc.catapult.service.ApiKeyHasher;
import fr.enimaloc.catapult.service.notification.AdminEventPublisher;
Expand All @@ -23,24 +24,28 @@ public class SteamKeyPoolService {
private final SteamApiKeyRepository repository;
private final Optional<SteamApiKeyRotator> rotator;
private final AdminEventPublisher events;
private final Optional<SteamRateLimiter> rateLimiter;

/** The rotator only exists when Steam is enabled. */
/** The rotator and the rate limiter (each key's usage) only exist when Steam is enabled. */
public SteamKeyPoolService(SteamApiKeyRepository repository,
Optional<SteamApiKeyRotator> rotator,
AdminEventPublisher events) {
AdminEventPublisher events,
Optional<SteamRateLimiter> rateLimiter) {
this.repository = repository;
this.rotator = rotator;
this.events = events;
this.rateLimiter = rateLimiter;
}

/** A shared key's id, masked value, owner and remaining rate-limit block. */
private static KeyStatus status(SteamApiKeyEntry entry, Map<String, Long> blockedUntil, long now) {
private KeyStatus status(SteamApiKeyEntry entry, Map<String, Long> blockedUntil, long now) {
String key = entry.getApiKey();
String owner = entry.getOwner() != null ? entry.getOwner().getTwitchUsername() : null;
long until = blockedUntil.getOrDefault(key, 0L);
boolean blocked = until > now;
long remainingSec = blocked ? TimeUnit.MILLISECONDS.toSeconds(until - now) : 0L;
return new KeyStatus(ApiKeyHasher.id(key), mask(key), owner, blocked, remainingSec);
return new KeyStatus(ApiKeyHasher.id(key), mask(key), owner, blocked, remainingSec,
rateLimiter.map(l -> l.usage(key)).orElse(null));
}

private void refreshRotator() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ class ApiAdminSteamKeysControllerTest {
private final SteamApiKeyRepository repository = mock(SteamApiKeyRepository.class);
private final SteamApiKeyRotator rotator = mock(SteamApiKeyRotator.class);
private final AdminEventPublisher events = mock(AdminEventPublisher.class);
private final ApiAdminSteamKeysController controller = new ApiAdminSteamKeysController(new SteamKeyPoolService(repository, Optional.of(rotator), events));
private final fr.enimaloc.catapult.getter.steam.SteamRateLimiter limiter =
new fr.enimaloc.catapult.getter.steam.SteamRateLimiter(3, 2000, 5000,
new io.micrometer.core.instrument.simple.SimpleMeterRegistry());
private final ApiAdminSteamKeysController controller = new ApiAdminSteamKeysController(
new SteamKeyPoolService(repository, Optional.of(rotator), events, Optional.of(limiter)));

@BeforeEach
void setUp() {
Expand Down Expand Up @@ -79,7 +83,7 @@ void page_masksKeys_andShowsOwnersAndBlocks() {

@Test
void withoutRotator_steamIsReportedDisabled_andEditsStillWork() {
ApiAdminSteamKeysController bare = new ApiAdminSteamKeysController(new SteamKeyPoolService(repository, Optional.empty(), events));
ApiAdminSteamKeysController bare = new ApiAdminSteamKeysController(new SteamKeyPoolService(repository, Optional.empty(), events, Optional.empty()));
when(repository.findByExclusiveFalseWithOwner()).thenReturn(List.of(entry(KEY, null)));
when(repository.findByExclusiveFalse()).thenReturn(List.of(entry(KEY, null)));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package fr.enimaloc.catapult.getter;

import org.junit.jupiter.api.Test;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;

import static org.assertj.core.api.Assertions.assertThat;

class KeyCallCounterTest {

private static final Instant T0 = Instant.parse("2026-11-12T23:59:50Z");

@Test
void callsAreCountedInTheWindow_andInTheirDay() {
KeyCallCounter counter = new KeyCallCounter(Duration.ofSeconds(5), at -> LocalDate.ofInstant(at, ZoneOffset.UTC));
counter.record("k", T0);
counter.record("k", T0.plusSeconds(3));

assertThat(counter.inWindow("k", T0.plusSeconds(4))).isEqualTo(2);
assertThat(counter.inWindow("k", T0.plusSeconds(6))).isEqualTo(1);
assertThat(counter.inPeriod("k", T0.plusSeconds(6))).isEqualTo(2);
assertThat(counter.inWindow("other", T0)).isZero();
assertThat(counter.inPeriod("other", T0)).isZero();

// A new day counts afresh
assertThat(counter.inPeriod("k", T0.plusSeconds(20))).isZero();
counter.record("k", T0.plusSeconds(20));
assertThat(counter.inPeriod("k", T0.plusSeconds(21))).isEqualTo(1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,22 @@ void aQuotaWithoutNumbers_isNotRemembered() {
rotator.onQuota("KEY_A", null, null);
assertThat(rotator.getQuotas()).isEmpty();
}

@Test
void aKeysUsage_countsItsCalls_againstWhatDtddTold_elseTheFreeTier() {
assertThat(rotator.usage("KEY_A")).isEqualTo(new fr.enimaloc.catapult.common.dto.admin.KeyUsageDto(
0, 30, 60, null, "MONTH", 0, 5000L, null));

rotator.onCall("KEY_A");
rotator.onCall("KEY_A");
rotator.onQuota("KEY_A", 28, 4321, 60, 10000);

var usage = rotator.usage("KEY_A");
assertThat(usage.windowCalls()).isEqualTo(2);
assertThat(usage.windowLimit()).isEqualTo(60);
assertThat(usage.windowRemaining()).isEqualTo(28);
assertThat(usage.periodCalls()).isEqualTo(2);
assertThat(usage.periodLimit()).isEqualTo(10000L);
assertThat(usage.periodRemaining()).isEqualTo(4321);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,13 @@ void everyAnswer_tellsTheRotatorWhatIsLeftOfTheKey() {
.setHeader("Content-Type", "application/json")
.setHeader("X-RateLimit-Remaining-Minute", "28")
.setHeader("X-RateLimit-Remaining-Month", "4321")
.setHeader("X-RateLimit-Limit-Minute", "30")
.setBody("[]"));

client.search("x", null);

verify(rotator).onQuota("KEY_A", 28, 4321);
verify(rotator).onCall("KEY_A");
verify(rotator).onQuota("KEY_A", 28, 4321, 30, null);
}

@Test
Expand Down
Loading
Loading