From caac4c03f5b917a8baf181797388000410df6733 Mon Sep 17 00:00:00 2001 From: "catapult-counterweight[bot]" <349249181+catapult-counterweight[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:17:03 +0000 Subject: [PATCH 01/53] chore(release): v1.1.0-BETA.1 changelog --- CHANGELOG.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec2075bfc..f1cca031b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,60 @@ Toutes les versions publiées de Catapult, la plus récente en premier. Ce fichier est régénéré par le bot à chaque release à partir des notes des [GitHub Releases](https://github.com/enimaloc/catapult/releases) : pour corriger une entrée, modifie les notes de la release (ou le changelog de la PR de release avant sa publication), pas ce fichier. +## [v1.1.0-BETA.1](https://github.com/enimaloc/catapult/releases/tag/v1.1.0-BETA.1) - 2026-11-10 + +### Features +- **api:** send the stream title along with the category (7fee7ca) +- **api:** let channels edit their stream title settings (0cdb5b4) +- **web:** let streamers set their stream title per game or for every game (df86277) +- **api:** write stream title variables as {{GAME}} and add {{TITLE}} (8bbfcd0) +- **api:** add {{ORIGINAL_TITLE}} and accept only exactly written title variables (653a1aa) +- **api:** keep title variables not written exactly as typed instead of refusing them (1a5fb6c) +- give custom bindings a stream title of their own (c9f1fe7) +- **web:** color the variables Catapult replaces in the stream title fields (62eb121) +- add {{CATEGORY}} and titles for the "no game" and incomplete game categories (2cfb76a) +- merge stream title into dev (7ed07b3) +- **api:** gate drops, title conditions and title preview behind their experiments (7d16eaf) +- **api:** render conditions in stream titles (5acd43c) +- **api:** keep the running Twitch drops campaigns from ttvdrops (8621d5a) +- **api:** evaluate drops and binding conditions when sending the stream title (1f3dd9f) +- show running Twitch drops on the channel bindings (22ea797) +- **api:** expose running drops at /api/me/drops (b49d8a4) +- **web:** relay /api/me/drops like the other public routes (1958811) +- preview the stream title as it would be sent (48e49d4) +- **api:** let an experiment spec target the members another experiment gave a variant (1b5bbfa) +- **api:** offer the title conditions and preview only to the channels stream.title enabled (e1aacd8) +- merge stream title conditions and Twitch drops into dev (5464a77) + +### Fixes +- **web:** give the custom binding title field the form's full width and its own height (88c4a0b) +- **ci:** refresh the beta -> main promotion in the promote-beta concurrency group (44d3ae8) +- **ci:** merge the promote-beta concurrency fix into dev (60d1654) +- **api:** show how to send the personal token in the API docs (1511746) +- **api:** merge the API docs token guide into dev (c9989db) +- **api:** keep only http links, ttvdrops' own images and plain ids from the drops campaigns (8074cf5) +- **api:** refuse a title preview longer than a title before rendering it (58e56ef) +- **api:** render brace-adjacent title variables as before and keep only a failed ternary raw (ef47535) +- **api:** preview an empty own title as the channel-wide one that would be sent (8d1a5f7) +- **api:** keep the drops when one campaign's channels can't be read and cap the listing (4f74b60) +- **api:** enrol the channels in stream.title.conditions from their settings (e08d937) +- **web:** highlight the title ternaries and the drop variables (76fdaed) + +### Performance +- **web:** ask a binding row's title preview only once its field is used (22d1e45) + +### Documentation +- **api:** explain the stream title conditions and drops variables in the card's help (8bc67fe) + +### Other +- **api:** roll the stream title out to one channel in ten (a3bc4b6) +- **api:** mock the stream title service in the controller and Twitchat revert slices (6cbe9c7) +- **api:** budget the settings route's stream title gate lookup (6acae3c) +- **common:** cover the stream title DTO constructors and older settings payloads (748c327) +- **release:** merge the v1.0.1 backport into dev (5bbb792) +- **api:** declare /api/me/drops among the public routes (fb8c440) +- merge dev into feat/stream-title-conditions (5ce9b3f) + ## [v1.0.1](https://github.com/enimaloc/catapult/releases/tag/v1.0.1) - 2026-11-10 ### Fixes From d2a3cd23b1e1279543aefca8bb524848546579c5 Mon Sep 17 00:00:00 2001 From: "catapult-counterweight[bot]" <349249181+catapult-counterweight[bot]@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:41:02 +0000 Subject: [PATCH 02/53] chore(release): v1.1.0 changelog --- CHANGELOG.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1cca031b..dc84cfcea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,60 @@ Toutes les versions publiées de Catapult, la plus récente en premier. Ce fichier est régénéré par le bot à chaque release à partir des notes des [GitHub Releases](https://github.com/enimaloc/catapult/releases) : pour corriger une entrée, modifie les notes de la release (ou le changelog de la PR de release avant sa publication), pas ce fichier. +## [v1.1.0](https://github.com/enimaloc/catapult/releases/tag/v1.1.0) - 2026-11-10 + +### Features +- **api:** send the stream title along with the category (7fee7ca) +- **api:** let channels edit their stream title settings (0cdb5b4) +- **web:** let streamers set their stream title per game or for every game (df86277) +- **api:** write stream title variables as {{GAME}} and add {{TITLE}} (8bbfcd0) +- **api:** add {{ORIGINAL_TITLE}} and accept only exactly written title variables (653a1aa) +- **api:** keep title variables not written exactly as typed instead of refusing them (1a5fb6c) +- give custom bindings a stream title of their own (c9f1fe7) +- **web:** color the variables Catapult replaces in the stream title fields (62eb121) +- add {{CATEGORY}} and titles for the "no game" and incomplete game categories (2cfb76a) +- merge stream title into dev (7ed07b3) +- **api:** gate drops, title conditions and title preview behind their experiments (7d16eaf) +- **api:** render conditions in stream titles (5acd43c) +- **api:** keep the running Twitch drops campaigns from ttvdrops (8621d5a) +- **api:** evaluate drops and binding conditions when sending the stream title (1f3dd9f) +- show running Twitch drops on the channel bindings (22ea797) +- **api:** expose running drops at /api/me/drops (b49d8a4) +- **web:** relay /api/me/drops like the other public routes (1958811) +- preview the stream title as it would be sent (48e49d4) +- **api:** let an experiment spec target the members another experiment gave a variant (1b5bbfa) +- **api:** offer the title conditions and preview only to the channels stream.title enabled (e1aacd8) +- merge stream title conditions and Twitch drops into dev (5464a77) + +### Fixes +- **web:** give the custom binding title field the form's full width and its own height (88c4a0b) +- **ci:** refresh the beta -> main promotion in the promote-beta concurrency group (44d3ae8) +- **ci:** merge the promote-beta concurrency fix into dev (60d1654) +- **api:** keep only http links, ttvdrops' own images and plain ids from the drops campaigns (8074cf5) +- **api:** refuse a title preview longer than a title before rendering it (58e56ef) +- **api:** render brace-adjacent title variables as before and keep only a failed ternary raw (ef47535) +- **api:** preview an empty own title as the channel-wide one that would be sent (8d1a5f7) +- **api:** keep the drops when one campaign's channels can't be read and cap the listing (4f74b60) +- **api:** show how to send the personal token in the API docs (1511746) +- **api:** merge the API docs token guide into dev (c9989db) +- **api:** enrol the channels in stream.title.conditions from their settings (e08d937) +- **web:** highlight the title ternaries and the drop variables (76fdaed) + +### Performance +- **web:** ask a binding row's title preview only once its field is used (22d1e45) + +### Documentation +- **api:** explain the stream title conditions and drops variables in the card's help (8bc67fe) + +### Other +- **api:** roll the stream title out to one channel in ten (a3bc4b6) +- **api:** mock the stream title service in the controller and Twitchat revert slices (6cbe9c7) +- **api:** budget the settings route's stream title gate lookup (6acae3c) +- **common:** cover the stream title DTO constructors and older settings payloads (748c327) +- **release:** merge the v1.0.1 backport into dev (5bbb792) +- **api:** declare /api/me/drops among the public routes (fb8c440) +- merge dev into feat/stream-title-conditions (5ce9b3f) + ## [v1.1.0-BETA.1](https://github.com/enimaloc/catapult/releases/tag/v1.1.0-BETA.1) - 2026-11-10 ### Features From b1f56be5f515b67ace844ec0e01596b3da4fa07c Mon Sep 17 00:00:00 2001 From: enimaloc Date: Wed, 11 Nov 2026 05:10:56 +0100 Subject: [PATCH 03/53] feat(api): tell the settings whether the title's conditions and drops are offered --- .../channel/ChannelDashboardAssembler.java | 12 ++++---- .../ChannelDashboardAssemblerTest.java | 17 +++++++++++ .../common/dto/channel/UserSettingsDto.java | 30 +++++++++++++++++-- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssembler.java b/catapult-api/src/main/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssembler.java index 4d5db3ff7..dc026dd32 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssembler.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssembler.java @@ -265,10 +265,10 @@ public UserSettingsDto settings(UserAccount channel) { return defaults; }); boolean titleAvailable = streamTitle.isAvailableFor(channel); - if (titleAvailable) { - // The title's fields are where the conditions are written: offered them, the channel may be enrolled - streamTitle.conditionsAvailableFor(channel); - } + // The title's fields are where the conditions are written: offered them, the channel may be enrolled + boolean titleConditions = titleAvailable && streamTitle.conditionsAvailableFor(channel); + // What the title reads of the drops (StreamTitleService#context): the conditions and the drops both on + boolean titleDrops = titleConditions && dropsGate.isAvailableFor(channel); return new UserSettingsDto( settings.isCclFeatureEnabled(), settings.getBlockedCcls(), @@ -295,7 +295,9 @@ public UserSettingsDto settings(UserAccount channel) { titleAvailable, settings.getNoGameTitle(), settings.getIncompleteFallbackTitle(), - streamTitle.previewAvailableFor(channel)); + streamTitle.previewAvailableFor(channel), + titleConditions, + titleDrops); } // --- DTDD mapping ---------------------------------------------------------------------------- diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssemblerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssemblerTest.java index 03b0e56df..4a3cfc844 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssemblerTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/channel/ChannelDashboardAssemblerTest.java @@ -643,6 +643,23 @@ void streamTitle_enrolsTheChannelInTheConditions_onlyWhileTheTitleIsOffered() { verify(streamTitle, never()).conditionsAvailableFor(owner); } + /** The title's drops are read only with its conditions (StreamTitleService#context): the help says the same. */ + @Test + void streamTitle_offersTheDropsOnlyWithTheConditions() { + when(streamTitle.isAvailableFor(owner)).thenReturn(true); + when(dropsGate.isAvailableFor(owner)).thenReturn(true); + + when(streamTitle.conditionsAvailableFor(owner)).thenReturn(true); + UserSettingsDto both = assembler().settings(owner); + when(streamTitle.conditionsAvailableFor(owner)).thenReturn(false); + UserSettingsDto noConditions = assembler().settings(owner); + + assertThat(both.titleConditionsAvailable()).isTrue(); + assertThat(both.titleDropsAvailable()).isTrue(); + assertThat(noConditions.titleConditionsAvailable()).isFalse(); + assertThat(noConditions.titleDropsAvailable()).isFalse(); + } + @Test void raidRestore_isOfferedOnlyWhileTheExperimentIsOnForTheChannel() { UserSettings saved = new UserSettings(); diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/channel/UserSettingsDto.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/channel/UserSettingsDto.java index c7266cf73..939751e8d 100644 --- a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/channel/UserSettingsDto.java +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/channel/UserSettingsDto.java @@ -37,8 +37,32 @@ public record UserSettingsDto( String noGameTitle, String incompleteFallbackTitle, // Whether the title fields get their preview (experiment stream.title.preview); null: not offered - Boolean titlePreviewAvailable + Boolean titlePreviewAvailable, + // Whether the titles read their conditions (experiment stream.title.conditions); null: not offered + Boolean titleConditionsAvailable, + // Whether the titles get the drops ({{DROP_NAME}}, {{DROP_END}}, DROP_ENABLED): the conditions and the + // experiment drops; null: not offered + Boolean titleDropsAvailable ) { + /** Settings without the title's conditions nor drops. */ + public UserSettingsDto(boolean cclFeatureEnabled, Set blockedCcls, String noGameTwitchGameId, + String noGameTwitchGameName, Set noGameCcls, boolean applyDefaultOnStreamStart, + boolean applyDefaultOnNoGame, boolean applyDefaultOnStreamEnd, + String incompleteFallbackTwitchGameId, String incompleteFallbackTwitchGameName, + Set incompleteFallbackCcls, List availableCcls, boolean twFeatureEnabled, + Set blockedTws, List availableTws, Integer raidRestoreGameMinutes, + boolean raidRestoreGameAvailable, List noGameExtensions, + List incompleteFallbackExtensions, Boolean titleFeatureEnabled, + String titleTemplate, Boolean titleAvailable, String noGameTitle, + String incompleteFallbackTitle, Boolean titlePreviewAvailable) { + this(cclFeatureEnabled, blockedCcls, noGameTwitchGameId, noGameTwitchGameName, noGameCcls, + applyDefaultOnStreamStart, applyDefaultOnNoGame, applyDefaultOnStreamEnd, + incompleteFallbackTwitchGameId, incompleteFallbackTwitchGameName, incompleteFallbackCcls, + availableCcls, twFeatureEnabled, blockedTws, availableTws, raidRestoreGameMinutes, + raidRestoreGameAvailable, noGameExtensions, incompleteFallbackExtensions, titleFeatureEnabled, + titleTemplate, titleAvailable, noGameTitle, incompleteFallbackTitle, titlePreviewAvailable, null, null); + } + /** Settings without the title's preview. */ public UserSettingsDto(boolean cclFeatureEnabled, Set blockedCcls, String noGameTwitchGameId, String noGameTwitchGameName, Set noGameCcls, boolean applyDefaultOnStreamStart, @@ -55,7 +79,7 @@ public UserSettingsDto(boolean cclFeatureEnabled, Set blockedCcls, Strin incompleteFallbackTwitchGameId, incompleteFallbackTwitchGameName, incompleteFallbackCcls, availableCcls, twFeatureEnabled, blockedTws, availableTws, raidRestoreGameMinutes, raidRestoreGameAvailable, noGameExtensions, incompleteFallbackExtensions, titleFeatureEnabled, - titleTemplate, titleAvailable, noGameTitle, incompleteFallbackTitle, null); + titleTemplate, titleAvailable, noGameTitle, incompleteFallbackTitle, null, null, null); } /** Settings without the fallbacks' own titles. */ @@ -73,7 +97,7 @@ public UserSettingsDto(boolean cclFeatureEnabled, Set blockedCcls, Strin incompleteFallbackTwitchGameId, incompleteFallbackTwitchGameName, incompleteFallbackCcls, availableCcls, twFeatureEnabled, blockedTws, availableTws, raidRestoreGameMinutes, raidRestoreGameAvailable, noGameExtensions, incompleteFallbackExtensions, titleFeatureEnabled, - titleTemplate, titleAvailable, null, null, null); + titleTemplate, titleAvailable, null, null, null, null, null); } /** Settings without the stream title (off, and not offered). */ From d85167e1e43e8aa3894380f9d81712970c38cf92 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Wed, 11 Nov 2026 05:10:56 +0100 Subject: [PATCH 04/53] fix(api): explain each title condition and the drops in the help, only when offered --- .../catapult/api/ApiHelpController.java | 27 +++++++++ .../main/resources/lang/messages.properties | 4 +- .../resources/lang/messages_fr.properties | 4 +- .../catapult/api/ApiHelpControllerTest.java | 57 +++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/api/ApiHelpController.java b/catapult-api/src/main/java/fr/enimaloc/catapult/api/ApiHelpController.java index b254d5986..05174aa84 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/api/ApiHelpController.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/api/ApiHelpController.java @@ -1,7 +1,11 @@ package fr.enimaloc.catapult.api; import fr.enimaloc.catapult.common.dto.HelpContent; +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.repository.account.UserAccountRepository; +import fr.enimaloc.catapult.service.drops.DropsGate; import fr.enimaloc.catapult.service.minecraft.MinecraftGateService; +import fr.enimaloc.catapult.service.title.StreamTitleService; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.MessageSource; @@ -13,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.Locale; +import java.util.Optional; import java.util.UUID; @RestController @@ -22,6 +27,9 @@ public class ApiHelpController { private final MessageSource messageSource; private final MinecraftGateService gateService; + private final StreamTitleService streamTitleService; + private final DropsGate dropsGate; + private final UserAccountRepository userAccountRepository; @Value("${app.account.deletion-delay-days:7}") private int accountDeletionDelayDays; @@ -49,9 +57,28 @@ public HelpContent help(@PathVariable String cardId, Locale locale, body += messageSource.getMessage("help.connections.body.minecraft", args, "", locale); } } + if ("title-settings".equals(cardId)) { + // Read-only, as the title fields offered them (the settings page enrols): the drops need the conditions + Optional account = account(jwt); + if (account.map(streamTitleService::conditionsAvailableReadOnly).orElse(false)) { + body += messageSource.getMessage("help.title-settings.body.conditions", args, "", locale); + if (dropsGate.isAvailableReadOnly(account.get())) { + body += messageSource.getMessage("help.title-settings.body.drops", args, "", locale); + } + } + } return new HelpContent(title, body); } + private Optional account(Jwt jwt) { + if (jwt == null) return Optional.empty(); + try { + return userAccountRepository.findById(UUID.fromString(jwt.getSubject())); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + private boolean isMinecraftVisible(Jwt jwt) { if (jwt == null) return false; try { diff --git a/catapult-api/src/main/resources/lang/messages.properties b/catapult-api/src/main/resources/lang/messages.properties index e98d37d96..ec35909a5 100644 --- a/catapult-api/src/main/resources/lang/messages.properties +++ b/catapult-api/src/main/resources/lang/messages.properties @@ -33,7 +33,9 @@ help.ccl-settings.body=

This section controls the content labels (CCLs) that { help.tw-settings.title=Trigger Warnings help.tw-settings.body=Use {tw#active} for the aggregated list, or {tw#<slug>} for a specific category. Auto-suggestion uses IGDB age ratings, Steam content descriptors, Steam mature-content notes, and DoesTheDogDie.com topics. help.title-settings.title=Stream title -help.title-settings.body=When it is on, Catapult changes your stream title each time it changes your category. What has a title of its own uses it: a game (in its row, under Overview), a custom binding, and the "no game" and incomplete game categories (in their cards); the rest uses the title for every game. In a title, {{GAME}} becomes the game detected (a custom binding''s name, nothing without a game), {{CATEGORY}} its Twitch category, {{TITLE}} the title the stream had just before, and {{ORIGINAL_TITLE}} your own title, the one you set before Catapult changed it (change it by hand and that one becomes your own). Write them exactly so, in capitals: anything else between double braces stays as typed. With no title to use, yours is left as it is; when the title before can''t be read from Twitch, it is left as it is too. Twitch cuts a title at 140 characters.

When conditions are offered to your channel, {{DROP_ENABLED ? [[[Drops] ]] : [[]]}} keeps the first part when the condition holds and the second otherwise. The conditions are DROP_ENABLED (a Twitch drops campaign runs for your channel on the category), HAS_GAME, IS_CUSTOM, IS_INCOMPLETE, IS_NO_GAME, HAS_TW and IS_MATURE; combine them with !, && and ||, put variables or other conditions in the parts. {{DROP_NAME}} and {{DROP_END}} name the drops campaign and its end. A condition that doesn''t read through stays as typed. +help.title-settings.body=When it is on, Catapult changes your stream title each time it changes your category. What has a title of its own uses it: a game (in its row, under Overview), a custom binding, and the "no game" and incomplete game categories (in their cards); the rest uses the title for every game. In a title, {{GAME}} becomes the game detected (a custom binding''s name, nothing without a game), {{CATEGORY}} its Twitch category, {{TITLE}} the title the stream had just before (even when Catapult set it), and {{ORIGINAL_TITLE}} your own title, the one you set before Catapult changed it (change it by hand and that one becomes your own). Write them exactly so, in capitals: anything else between double braces stays as typed. With no title to use, yours is left as it is; when the title before can''t be read from Twitch, it is left as it is too. Twitch cuts a title at 140 characters. +help.title-settings.body.conditions=

{{HAS_TW ? [[[TW] ]] : [[]]}} keeps the first part when the condition holds and the second otherwise. The conditions are IS_NO_GAME (no game detected), HAS_GAME (a game is detected), IS_CUSTOM (it is a custom binding), IS_INCOMPLETE (the game is detected but no Twitch category was found), HAS_TW (Catapult found warnings, the TWs, on the game) and IS_MATURE (Catapult found the Mature CCL on the game); combine them with !, && and ||, put variables or other conditions in the parts. A condition that doesn''t read through stays as typed. +help.title-settings.body.drops=

For Twitch drops, the DROP_ENABLED condition holds when a drops campaign runs for your channel on the category; {{DROP_NAME}} then names the campaign and {{DROP_END}} gives its end date (dd/MM, Paris time). Without a campaign, both are empty. help.dtdd.title=DTDD association help.dtdd.body=

{2} links the current game to its page on DoesTheDogDie.com: the sensitive topics it lists are used to suggest the game''s trigger warnings.

  • The percentage is the confidence of the association found automatically.
  • Validate confirms it is right; this is possible as long as it isn''t verified and no correction is pending.
  • Correct lets you search for the right page and propose it: the correction applies once an administrator validates it.
diff --git a/catapult-api/src/main/resources/lang/messages_fr.properties b/catapult-api/src/main/resources/lang/messages_fr.properties index fbdec01c8..bd3056757 100644 --- a/catapult-api/src/main/resources/lang/messages_fr.properties +++ b/catapult-api/src/main/resources/lang/messages_fr.properties @@ -32,7 +32,9 @@ help.ccl-settings.body=

Cette section contrôle les labels de contenu (CCL) qu help.tw-settings.title=Trigger Warnings help.tw-settings.body=Utilise {tw#active} pour la liste agrégée, ou {tw#<slug>} pour une catégorie spécifique. L''auto-suggestion s''appuie sur les classifications IGDB, les content_descriptors Steam, les notes mature Steam, et les topics DoesTheDogDie.com. help.title-settings.title=Titre du stream -help.title-settings.body=Activé, Catapult change le titre de ton stream à chaque fois qu''il change ta catégorie. Ce qui a son propre titre l''utilise : un jeu (dans sa ligne, sous Vue d''ensemble), un binding personnalisé, et les catégories « aucun jeu » et « jeu incomplet » (dans leurs cartes) ; le reste prend le titre pour tous les jeux. Dans un titre, {{GAME}} devient le jeu détecté (le nom d''un binding personnalisé, rien sans jeu), {{CATEGORY}} sa catégorie Twitch, {{TITLE}} le titre qu''avait le stream juste avant et {{ORIGINAL_TITLE}} ton propre titre, celui que tu avais mis avant que Catapult le change (change-le à la main et c''est lui qui devient le tien). Écris-les exactement ainsi, en majuscules : tout le reste entre doubles accolades reste tel quel. Sans titre à utiliser, le tien n''est pas touché ; si le titre d''avant ne peut pas être lu sur Twitch, il n''est pas touché non plus. Twitch coupe un titre à 140 caractères.

Quand les conditions sont proposées à ta chaîne, {{DROP_ENABLED ? [[[Drops] ]] : [[]]}} garde la première partie si la condition est vraie, la seconde sinon. Les conditions sont DROP_ENABLED (une campagne de drops Twitch est en cours pour ta chaîne sur la catégorie), HAS_GAME, IS_CUSTOM, IS_INCOMPLETE, IS_NO_GAME, HAS_TW et IS_MATURE ; combine-les avec !, && et ||, et mets des variables ou d''autres conditions dans les parties. {{DROP_NAME}} et {{DROP_END}} donnent le nom de la campagne de drops et sa date de fin. Une condition mal écrite reste telle quelle. +help.title-settings.body=Activé, Catapult change le titre de ton stream à chaque fois qu''il change ta catégorie. Ce qui a son propre titre l''utilise : un jeu (dans sa ligne, sous Vue d''ensemble), un binding personnalisé, et les catégories « aucun jeu » et « jeu incomplet » (dans leurs cartes) ; le reste prend le titre pour tous les jeux. Dans un titre, {{GAME}} devient le jeu détecté (le nom d''un binding personnalisé, rien sans jeu), {{CATEGORY}} sa catégorie Twitch, {{TITLE}} le titre qu''avait le stream juste avant (même s''il avait été mis par Catapult) et {{ORIGINAL_TITLE}} ton propre titre, celui que tu avais mis avant que Catapult le change (change-le à la main et c''est lui qui devient le tien). Écris-les exactement ainsi, en majuscules : tout le reste entre doubles accolades reste tel quel. Sans titre à utiliser, le tien n''est pas touché ; si le titre d''avant ne peut pas être lu sur Twitch, il n''est pas touché non plus. Twitch coupe un titre à 140 caractères. +help.title-settings.body.conditions=

{{HAS_TW ? [[[TW] ]] : [[]]}} garde la première partie si la condition est vraie, la seconde sinon. Les conditions sont IS_NO_GAME (aucun jeu détecté), HAS_GAME (un jeu est détecté), IS_CUSTOM (c''est un binding personnalisé), IS_INCOMPLETE (le jeu est détecté mais aucune catégorie Twitch n''a été trouvée), HAS_TW (Catapult a trouvé des avertissements, les TW, sur le jeu) et IS_MATURE (Catapult a trouvé le CCL Mature sur le jeu) ; combine-les avec !, && et ||, mets des variables ou d''autres conditions dans les parties. Une condition mal écrite reste telle quelle. +help.title-settings.body.drops=

Pour les drops Twitch, la condition DROP_ENABLED est vraie quand une campagne de drops est en cours pour ta chaîne sur la catégorie ; {{DROP_NAME}} donne alors le nom de la campagne et {{DROP_END}} sa date de fin (jj/MM, heure de Paris). Sans campagne, ces deux variables sont vides. help.dtdd.title=Association DTDD help.dtdd.body=

{2} relie le jeu en cours à sa fiche sur DoesTheDogDie.com : les sujets sensibles qu''elle recense servent à suggérer les trigger warnings du jeu.

  • Le pourcentage indique la confiance de l''association trouvée automatiquement.
  • Valider confirme qu''elle est juste ; c''est possible tant qu''elle n''est pas vérifiée et qu''aucune correction n''est en attente.
  • Corriger permet de chercher la bonne fiche et de la proposer : la correction est appliquée une fois validée par un administrateur.
diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/ApiHelpControllerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/ApiHelpControllerTest.java index 25e91525d..da31dd5ad 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/ApiHelpControllerTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/ApiHelpControllerTest.java @@ -1,6 +1,10 @@ package fr.enimaloc.catapult.api; +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.repository.account.UserAccountRepository; +import fr.enimaloc.catapult.service.drops.DropsGate; import fr.enimaloc.catapult.service.minecraft.MinecraftGateService; +import fr.enimaloc.catapult.service.title.StreamTitleService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -13,6 +17,7 @@ import org.springframework.security.oauth2.jwt.Jwt; import java.util.Locale; +import java.util.Optional; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -26,6 +31,9 @@ class ApiHelpControllerTest { @Mock private MessageSource messageSource; @Mock private MinecraftGateService gateService; + @Mock private StreamTitleService streamTitleService; + @Mock private DropsGate dropsGate; + @Mock private UserAccountRepository userAccountRepository; @Mock private Jwt jwt; @InjectMocks private ApiHelpController controller; @@ -45,6 +53,12 @@ void setup() { .thenReturn("

minecraft

"); when(messageSource.getMessage(eq("help.connections.body.xbox"), any(), any(), eq(locale))) .thenReturn("

xbox

"); + when(messageSource.getMessage(eq("help.title-settings.body"), any(), any(), eq(locale))) + .thenReturn("

titre

"); + when(messageSource.getMessage(eq("help.title-settings.body.conditions"), any(), any(), eq(locale))) + .thenReturn("

conditions

"); + when(messageSource.getMessage(eq("help.title-settings.body.drops"), any(), any(), eq(locale))) + .thenReturn("

drops

"); } @Test @@ -120,4 +134,47 @@ void connections_xboxDisabled_omitsXboxSection() { assertThat(content.body()).isEqualTo("

corps

"); } + + @Test + void titleSettings_conditionsAndDrops_appendsBothSections() { + UserAccount user = new UserAccount(); + when(userAccountRepository.findById(userId)).thenReturn(Optional.of(user)); + when(streamTitleService.conditionsAvailableReadOnly(user)).thenReturn(true); + when(dropsGate.isAvailableReadOnly(user)).thenReturn(true); + + var content = controller.help("title-settings", locale, jwt); + + assertThat(content.body()).isEqualTo("

titre

conditions

drops

"); + } + + @Test + void titleSettings_conditionsWithoutDrops_appendsConditionsOnly() { + UserAccount user = new UserAccount(); + when(userAccountRepository.findById(userId)).thenReturn(Optional.of(user)); + when(streamTitleService.conditionsAvailableReadOnly(user)).thenReturn(true); + when(dropsGate.isAvailableReadOnly(user)).thenReturn(false); + + var content = controller.help("title-settings", locale, jwt); + + assertThat(content.body()).isEqualTo("

titre

conditions

"); + } + + @Test + void titleSettings_dropsWithoutConditions_omitsBoth() { + UserAccount user = new UserAccount(); + when(userAccountRepository.findById(userId)).thenReturn(Optional.of(user)); + when(streamTitleService.conditionsAvailableReadOnly(user)).thenReturn(false); + when(dropsGate.isAvailableReadOnly(user)).thenReturn(true); + + var content = controller.help("title-settings", locale, jwt); + + assertThat(content.body()).isEqualTo("

titre

"); + } + + @Test + void titleSettings_anonymous_omitsBoth() { + var content = controller.help("title-settings", locale, null); + + assertThat(content.body()).isEqualTo("

titre

"); + } } From b5ad1b1cc7d59ebc3a1ecf60807e0038abf4f97c Mon Sep 17 00:00:00 2001 From: enimaloc Date: Wed, 11 Nov 2026 05:10:57 +0100 Subject: [PATCH 05/53] fix(web): name the title's conditions and drop variables under its fields when offered --- .../catapult/service/mock/MockData.java | 4 +- .../catapult/service/mock/MockPresets.java | 2 +- .../main/resources/lang/messages.properties | 2 + .../resources/lang/messages_en.properties | 2 + .../resources/lang/messages_fr.properties | 2 + .../templates/pages/channel-tab/overview.html | 6 +-- .../templates/pages/channel-tab/settings.html | 8 ++-- .../ChannelVisibilityRenderingTest.java | 39 +++++++++++++++++++ .../catapult/service/mock/MockDataTest.java | 2 +- 9 files changed, 56 insertions(+), 11 deletions(-) diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockData.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockData.java index d748aed54..5a5ea2f8a 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockData.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockData.java @@ -217,7 +217,7 @@ private static MockData fromConfig(MockConfig config) { null, null, Set.of(), AVAILABLE_CCLS, config.settings.twFeatureEnabled, Set.copyOf(config.settings.blockedTws), AVAILABLE_TWS, // raid.restore-game and stream.title: the mock has no experiments, the settings are offered - null, true, List.of(), List.of(), false, null, true, null, null, true); + null, true, List.of(), List.of(), false, null, true, null, null, true, true, true); MockConfig.Steam steam = config.steam; MockData data = new MockData(channel, List.of(binding), settings, List.of(), @@ -892,7 +892,7 @@ UserSettingsDto build() { from.availableCcls(), twEnabled, blockedTws, from.availableTws(), raidMinutes, from.raidRestoreGameAvailable(), noGameExtensions, fallbackExtensions, titleEnabled, titleTemplate, from.titleAvailable(), noGameTitle, incompleteFallbackTitle, - from.titlePreviewAvailable()); + from.titlePreviewAvailable(), from.titleConditionsAvailable(), from.titleDropsAvailable()); } } } diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockPresets.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockPresets.java index 8f701a6cb..fefef7b61 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockPresets.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockPresets.java @@ -116,7 +116,7 @@ public static String getDefaultAvatar(Random random, int size) { null, null, Set.of(), AVAILABLE_CCLS, false, Set.of(), AVAILABLE_TWS, // raid.restore-game and stream.title: the mock has no experiments, the settings are offered - null, true, List.of(), List.of(), false, null, true, null, null, true); + null, true, List.of(), List.of(), false, null, true, null, null, true, true, true); private MockPresets() { } diff --git a/catapult-web/src/main/resources/lang/messages.properties b/catapult-web/src/main/resources/lang/messages.properties index d036293cc..4c81c322d 100644 --- a/catapult-web/src/main/resources/lang/messages.properties +++ b/catapult-web/src/main/resources/lang/messages.properties @@ -278,6 +278,8 @@ channel.settings.stream_title.preview=Preview: channel.settings.stream_title.preview_none=(title left as it is) channel.settings.fallback_title=Stream title with this category channel.settings.fallback_title_helper={{GAME}}: the game detected, {{CATEGORY}}: its Twitch category, {{TITLE}}: the title just before, {{ORIGINAL_TITLE}}: your own title, before Catapult changed it. Empty: the title for every game +channel.title.helper_conditions=Conditions: {{HAS_TW ? [[if it holds]] : [[otherwise]]}} with IS_NO_GAME, HAS_GAME, IS_CUSTOM, IS_INCOMPLETE, HAS_TW or IS_MATURE, detailed in the help. +channel.title.helper_drops=Drops: {{DROP_NAME}}: the running campaign's name, {{DROP_END}}: its end date (dd/MM), DROP_ENABLED: holds during a campaign. channel.settings.danger.title=Danger zone channel.settings.danger.description=Deleting your account is irreversible after {0} days. channel.settings.danger.delete_button=Delete my account diff --git a/catapult-web/src/main/resources/lang/messages_en.properties b/catapult-web/src/main/resources/lang/messages_en.properties index 497f01e99..9e18a9918 100644 --- a/catapult-web/src/main/resources/lang/messages_en.properties +++ b/catapult-web/src/main/resources/lang/messages_en.properties @@ -278,6 +278,8 @@ channel.settings.stream_title.preview=Preview: channel.settings.stream_title.preview_none=(title left as it is) channel.settings.fallback_title=Stream title with this category channel.settings.fallback_title_helper={{GAME}}: the game detected, {{CATEGORY}}: its Twitch category, {{TITLE}}: the title just before, {{ORIGINAL_TITLE}}: your own title, before Catapult changed it. Empty: the title for every game +channel.title.helper_conditions=Conditions: {{HAS_TW ? [[if it holds]] : [[otherwise]]}} with IS_NO_GAME, HAS_GAME, IS_CUSTOM, IS_INCOMPLETE, HAS_TW or IS_MATURE, detailed in the help. +channel.title.helper_drops=Drops: {{DROP_NAME}}: the running campaign's name, {{DROP_END}}: its end date (dd/MM), DROP_ENABLED: holds during a campaign. channel.settings.danger.title=Danger zone channel.settings.danger.description=Deleting your account is irreversible after {0} days. channel.settings.danger.delete_button=Delete my account diff --git a/catapult-web/src/main/resources/lang/messages_fr.properties b/catapult-web/src/main/resources/lang/messages_fr.properties index 3318e9bb9..786527f70 100644 --- a/catapult-web/src/main/resources/lang/messages_fr.properties +++ b/catapult-web/src/main/resources/lang/messages_fr.properties @@ -278,6 +278,8 @@ channel.settings.stream_title.preview=Aperçu : channel.settings.stream_title.preview_none=(titre laissé tel quel) channel.settings.fallback_title=Titre du stream avec cette catégorie channel.settings.fallback_title_helper={{GAME}} : le jeu détecté, {{CATEGORY}} : sa catégorie Twitch, {{TITLE}} : le titre juste avant, {{ORIGINAL_TITLE}} : ton propre titre, avant que Catapult le change. Vide : le titre pour tous les jeux +channel.title.helper_conditions=Conditions : {{HAS_TW ? [[si vraie]] : [[sinon]]}} avec IS_NO_GAME, HAS_GAME, IS_CUSTOM, IS_INCOMPLETE, HAS_TW ou IS_MATURE, détaillées dans l'aide. +channel.title.helper_drops=Drops : {{DROP_NAME}} : le nom de la campagne en cours, {{DROP_END}} : sa date de fin (jj/MM), DROP_ENABLED : vraie pendant une campagne. channel.settings.danger.title=Zone danger channel.settings.danger.description=La suppression de compte est irréversible après {0} jours. channel.settings.danger.delete_button=Supprimer mon compte diff --git a/catapult-web/src/main/resources/templates/pages/channel-tab/overview.html b/catapult-web/src/main/resources/templates/pages/channel-tab/overview.html index 645428836..f6045cfff 100644 --- a/catapult-web/src/main/resources/templates/pages/channel-tab/overview.html +++ b/catapult-web/src/main/resources/templates/pages/channel-tab/overview.html @@ -1,6 +1,6 @@ - +

Statut

@@ -198,7 +198,7 @@

Associations

+ th:attr="label=#{channel.bindings.title.label},helper=|#{channel.bindings.title.helper}${titleHelperExtra}|">

Aperçu :

@@ -396,7 +396,7 @@

Bindings personnalisés

+ th:attr="label=#{channel.custom_bindings.settings.title},helper=|#{channel.bindings.title.helper}${titleHelperExtra}|">

Aperçu :

diff --git a/catapult-web/src/main/resources/templates/pages/channel-tab/settings.html b/catapult-web/src/main/resources/templates/pages/channel-tab/settings.html index 276ddbec3..b269b740c 100644 --- a/catapult-web/src/main/resources/templates/pages/channel-tab/settings.html +++ b/catapult-web/src/main/resources/templates/pages/channel-tab/settings.html @@ -1,7 +1,7 @@ - + @@ -261,6 +264,12 @@

Associations

+ +
+ Fermer +
+

Sa catégorie, ses CCL et ses TW reviennent à la détection automatique.

diff --git a/catapult-web/src/main/resources/templates/pages/channel-tab/tw-details.html b/catapult-web/src/main/resources/templates/pages/channel-tab/tw-details.html new file mode 100644 index 000000000..df8848ca1 --- /dev/null +++ b/catapult-web/src/main/resources/templates/pages/channel-tab/tw-details.html @@ -0,0 +1,63 @@ + + + + +
  • + + IGDB + Steam + Steam + DTDD + source + + + unknown + unknown level + too few + verified + votes + below + below + +
  • + +
    +

    unavailable

    + +

    hint

    +

    yours

    +

    none

    + +
    +

    + TW + turned off +

    +

    yours

    +

    admin

    +

    no source

    +
      + +
    • source
    • +
    +
    + +
    +

    Kept off by their severity

    +
      + +
    • + TW +
      • source
      +
    • +
      +
    +
    +
    +
    diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/TwDetailsRenderingTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/TwDetailsRenderingTest.java new file mode 100644 index 000000000..ac11928fd --- /dev/null +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/TwDetailsRenderingTest.java @@ -0,0 +1,89 @@ +package fr.enimaloc.catapult.controller; + +import fr.enimaloc.catapult.common.dto.channel.TwDetailsDto; +import fr.enimaloc.catapult.common.dto.channel.TwDetailsDto.Severity; +import fr.enimaloc.catapult.common.dto.channel.TwDetailsDto.Source; +import fr.enimaloc.catapult.common.dto.channel.TwDetailsDto.Tw; +import fr.enimaloc.catapult.config.I18nConfiguration; +import fr.enimaloc.catapult.config.TwitchatCatalogConfig; +import fr.enimaloc.catapult.security.WebSecurityConfig; +import fr.enimaloc.catapult.service.ApiService; +import fr.enimaloc.catapult.service.http.ApiClient; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Locale; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** Why a binding has its TWs, as the TW panel's details dialog renders it. */ +@WebMvcTest(controllers = SpaFragmentController.class) +@Import({ModelFiller.class, WebSecurityConfig.class, TwitchatCatalogConfig.class, I18nConfiguration.class}) +class TwDetailsRenderingTest { + + @Autowired MockMvc mvc; + @MockitoBean ApiService apiService; + @MockitoBean NavEventStreamController navEvents; + + private String render(Locale locale) throws Exception { + return mvc.perform(get("/spa/channel/bindings/b1/tw-details").locale(locale) + .sessionAttr(ApiClient.SESSION_JWT_KEY, "jwt")) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + } + + @Test + void eachTw_isToldWithItsSources_inPlainWords() throws Exception { + when(apiService.twDetails("b1")).thenReturn(new TwDetailsDto(false, List.of( + new Tw("gore", "Gore", true, "DETECTED", List.of( + new Source("gore", "Gore", "STEAM_CONTENT", "2", null), + new Source("gore", "Gore", "STEAM_KEYWORD", "blood", null), + new Source("gore", "Gore", "IGDB_DESCRIPTOR", "17", null), + new Source("gore", "Gore", "DTDD_TOPIC", "there's blood/gore", + new Severity("VOTES", "Depicted", "Excessive", 12, 83)), + new Source("gore", "Gore", "DTDD_TOPIC", "a corpse is shown", + new Severity("TOO_FEW_VOTES", "Bloody", "Shown", 1, 0)))), + new Tw("spiders", "Spiders", false, "DETECTED", List.of( + new Source("spiders", "Spiders", "DTDD_TOPIC", "there are spiders", + new Severity("NO_THRESHOLD", null, null, 0, 0))))), + List.of(new Source("jumpscares", "Jump scares", "DTDD_TOPIC", "there are jump scares", + new Severity("BELOW_VOTES", "Startling", "Mild", 9, 22))))); + + String html = render(Locale.FRENCH); + + assertThat(html).contains(">Gore<", "Steam : violence ou gore fréquents", + "Steam : la note de contenu mentionne « blood »", "IGDB : descripteur de contenu n°17", + "DoesTheDogDie : « there's blood/gore »", "83 % des votes au niveau Depicted ou plus (12 votes).", + "Pas assez de votes pour juger la gravité (1 vote) : activé par défaut.", + "désactivé par toi", "Écartés par leur gravité", ">Jump scares<", + "Seulement 22 % des votes au niveau Startling ou plus (9 votes, surtout Mild)."); + // Without a threshold, nothing about the severity + String spiders = html.substring(html.indexOf("data-tw=\"spiders\""), html.indexOf("Écartés")); + assertThat(spiders).doesNotContain("tw-details-severity\">\n <"); + assertThat(spiders).doesNotContain("% des votes"); + } + + @Test + void theStreamersAndAnAdminsChoices_andATwWithoutSource_areToldAsSuch() throws Exception { + when(apiService.twDetails("b1")).thenReturn(new TwDetailsDto(true, List.of( + new Tw("spiders", "Spiders", true, "STREAMER", List.of()), + new Tw("gore", "Gore", true, "ADMIN", List.of()), + new Tw("old", "Old", true, "NO_SOURCE", List.of())), List.of())); + + assertThat(render(Locale.ENGLISH)).contains("You changed this game's TWs", "Added by you.", + "Set by a Catapult admin for this game.", "Nothing suggests it any more") + .doesNotContain("Kept off by their severity"); + } + + @Test + void detailsUnavailable_saySo() throws Exception { + assertThat(render(Locale.ENGLISH)).contains("The details aren't available right now."); + } +} diff --git a/catapult-web/src/test/js/channel-actions.test.js b/catapult-web/src/test/js/channel-actions.test.js index c0a5774f0..4690e7260 100644 --- a/catapult-web/src/test/js/channel-actions.test.js +++ b/catapult-web/src/test/js/channel-actions.test.js @@ -99,11 +99,16 @@ describe("channel-bindings", () => { +
    - `); + + +
    + +
    `); render(); }); @@ -231,6 +236,28 @@ describe("channel-bindings", () => { ]); }); + it("opens why the binding has its TWs in the dialog, and closes it", async () => { + fetch.mockResolvedValueOnce(response(200, "
    Gore
    ")); + await click("[data-binding-id=b1] .binding-tw-details-btn"); + + expect(fetch).toHaveBeenCalledWith("/spa/channel/bindings/b1/tw-details"); + const dialog = document.getElementById("binding-tw-details-dialog"); + expect(dialog.querySelector(".tw-details-tw").textContent).toBe("Gore"); + expect(dialog.open).toBe(true); + expect(posts()).toEqual([]); + + await click("#binding-tw-details-dialog .binding-tw-details-close"); + expect(dialog.open).toBe(false); + }); + + it("tells when the TWs' details can't be had", async () => { + fetch.mockResolvedValueOnce(response(502, "")); + await click("[data-binding-id=b1] .binding-tw-details-btn"); + + expect(document.querySelector("mdui-snackbar")?.textContent).toBe("No details"); + expect(document.getElementById("binding-tw-details-dialog").open).not.toBe(true); + }); + it("mirrors the accordion's open state as .is-open", () => { const item = document.querySelector("mdui-collapse-item"); From 4bbbc962b5c5fb8548a0f64acaa6af86d6d069ea Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 05:19:01 +0100 Subject: [PATCH 22/53] fix(api): leave the Steam store alone after a 429, for its Retry-After or a doubling wait --- .../service/steam/SteamStoreService.java | 8 ++ .../service/steam/SteamStoreServiceImpl.java | 67 ++++++++++++++++ .../service/tw/TwResolverService.java | 20 ++++- .../service/steam/SteamStoreServiceTest.java | 79 +++++++++++++++++++ .../service/tw/TwResolverServiceTest.java | 21 +++++ 5 files changed, 192 insertions(+), 3 deletions(-) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreService.java index d1832dd86..2aa107faf 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreService.java @@ -27,6 +27,14 @@ public static SteamTwSignals empty() { Map fetchTwSignals(Collection appIds); + /** + * Until when the Steam store asked to be left alone (a 429): it isn't called meanwhile, an app it would have + * answered for answering nothing. Empty while it may be called. + */ + default Optional rateLimitedUntil() { + return Optional.empty(); + } + /** * Store-page short description for the given app, in the given locale (mapped to Steam's * own language names internally — Steam does not accept ISO codes). Empty when Steam has diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceImpl.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceImpl.java index 83171f1b0..f3874c9e2 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceImpl.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceImpl.java @@ -4,13 +4,22 @@ import fr.enimaloc.catapult.getter.steam.SteamPlaytestRedirectResolver; import fr.enimaloc.catapult.repository.steam.SteamAppParentRepository; import fr.enimaloc.catapult.service.metrics.ExternalApiObservations; +import lombok.AccessLevel; import lombok.RequiredArgsConstructor; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClient; +import java.time.Clock; +import java.time.Duration; import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; import java.util.*; @Slf4j @@ -35,6 +44,52 @@ public class SteamStoreServiceImpl implements SteamStoreService { private static final java.time.Duration PARENT_CACHE_TTL = java.time.Duration.ofDays(30); + /** Without a Retry-After, the first wait after a 429; it doubles while they follow each other, up to the max. */ + private static final Duration RATE_LIMIT_FIRST = Duration.ofSeconds(60); + private static final Duration RATE_LIMIT_MAX = Duration.ofMinutes(10); + + @Setter(AccessLevel.PACKAGE) + private Clock clock = Clock.systemUTC(); + /** Until when the store asked to be left alone; null: it may be called. */ + private volatile Instant rateLimitedUntil; + /** The wait the next 429 without a Retry-After gets: doubled by each, reset by an answer. */ + private volatile Duration nextWait = RATE_LIMIT_FIRST; + + @Override + public Optional rateLimitedUntil() { + Instant until = rateLimitedUntil; + return until != null && until.isAfter(clock.instant()) ? Optional.of(until) : Optional.empty(); + } + + /** The store answered 429: left alone for its Retry-After (seconds or an HTTP date), else the next wait. */ + private void onRateLimited(HttpHeaders headers) { + Instant now = clock.instant(); + Duration wait = retryAfter(headers == null ? null : headers.getFirst("Retry-After"), now) + .orElse(nextWait); + if (wait.compareTo(RATE_LIMIT_MAX) > 0) wait = RATE_LIMIT_MAX; + Instant until = now.plus(wait); + if (rateLimitedUntil == null || until.isAfter(rateLimitedUntil)) rateLimitedUntil = until; + Duration doubled = nextWait.multipliedBy(2); + nextWait = doubled.compareTo(RATE_LIMIT_MAX) > 0 ? RATE_LIMIT_MAX : doubled; + log.warn("Steam store rate limited (429): left alone until {}", rateLimitedUntil); + } + + static Optional retryAfter(String header, Instant now) { + if (header == null || header.isBlank()) return Optional.empty(); + String value = header.trim(); + try { + return Optional.of(Duration.ofSeconds(Math.max(1, Long.parseLong(value)))); + } catch (NumberFormatException notSeconds) { + try { + Instant at = ZonedDateTime.parse(value, + DateTimeFormatter.RFC_1123_DATE_TIME).toInstant(); + return Optional.of(at.isAfter(now) ? Duration.between(now, at) : Duration.ofSeconds(1)); + } catch (DateTimeParseException notADate) { + return Optional.empty(); + } + } + } + /** One app's entry in an {@code appdetails} response, keyed by appId. */ record AppDetailsEntry(boolean success, SteamStorePage data) {} @@ -207,14 +262,22 @@ public Optional fetchData(String appId, Locale locale, boolean r .map(ResolvedParentApp::appId) .orElse(appId) : appId; SteamLanguage lang = SteamLanguage.fromLocale(locale); + if (rateLimitedUntil().isPresent()) { + log.debug("Steam store left alone (rate limited): no appdetails for appId={}", appId); + return Optional.empty(); + } try { Map response = restClient.get() .uri(APP_DETAILS_URL + "?appids=" + effectiveAppId + "&l=" + lang) .retrieve() .body(new ParameterizedTypeReference<>() {}); + nextWait = RATE_LIMIT_FIRST; AppDetailsEntry entry = response == null ? null : response.get(effectiveAppId); if (entry == null || !entry.success()) return Optional.empty(); return Optional.ofNullable(entry.data()); + } catch (HttpClientErrorException.TooManyRequests e) { + onRateLimited(e.getResponseHeaders()); + return Optional.empty(); } catch (Exception e) { log.warn("Steam fetchData failed for appId={}: {}", appId, e.getMessage(), e); return Optional.empty(); @@ -229,6 +292,7 @@ public Optional fetchIADisclosure(String appId, Locale locale) { .map(ResolvedParentApp::appId) .orElse(appId); SteamLanguage lang = SteamLanguage.fromLocale(locale); + if (rateLimitedUntil().isPresent()) return Optional.empty(); try { String response = restClient.get() .uri("https://store.steampowered.com/app/" + effectiveAppId + "?l=" + lang) @@ -236,6 +300,9 @@ public Optional fetchIADisclosure(String appId, Locale locale) { .body(String.class); if (response == null || response.isBlank()) return Optional.empty(); return Optional.of(response); + } catch (HttpClientErrorException.TooManyRequests e) { + onRateLimited(e.getResponseHeaders()); + return Optional.empty(); } catch (Exception e) { log.warn("Steam fetchIADisclosure failed for appId={}: {}", appId, e.getMessage(), e); return Optional.empty(); diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwResolverService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwResolverService.java index 78e2480f9..3a24ae90b 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwResolverService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwResolverService.java @@ -13,6 +13,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.stereotype.Service; +import java.time.Instant; import java.util.*; import java.util.stream.Collectors; @@ -36,17 +37,30 @@ public record SuggestInput( String sourceName ) {} + /** + * The TWs suggested, and until when the Steam store was rate limited when a Steam game's signals were asked for + * (empty: they came, or weren't needed): without them, the TWs miss what Steam would have added. + */ + public record Suggestion(Set tws, Optional steamLimitedUntil) {} + /** Computes the set of TW slugs that should be pre-activated for this binding input. */ public Set suggest(SuggestInput in) { + return suggestReporting(in).tws(); + } + + /** As {@link #suggest}, telling when the Steam signals were missed because the store was rate limited. */ + public Suggestion suggestReporting(SuggestInput in) { Set out = new HashSet<>(); + Optional steamLimitedUntil = Optional.empty(); if (in.igdbDescriptorIds() != null && !in.igdbDescriptorIds().isEmpty()) { out.addAll(igdbRepo.findTwIdsByDescriptorIds(in.igdbDescriptorIds())); } if (in.steamAppId() != null) { - SteamTwSignals s = steamStoreService.fetchTwSignals(List.of(in.steamAppId())) - .getOrDefault(in.steamAppId(), SteamTwSignals.empty()); + Map signals = steamStoreService.fetchTwSignals(List.of(in.steamAppId())); + if (!signals.containsKey(in.steamAppId())) steamLimitedUntil = steamStoreService.rateLimitedUntil(); + SteamTwSignals s = signals.getOrDefault(in.steamAppId(), SteamTwSignals.empty()); if (!s.contentDescriptorIds().isEmpty()) { out.addAll(steamIdRepo.findTwIdsBySteamContentIdIn(s.contentDescriptorIds())); } @@ -68,6 +82,6 @@ public Set suggest(SuggestInput in) { } }); - return out; + return new Suggestion(out, steamLimitedUntil); } } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java index 739e57cec..dcb454095 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java @@ -445,4 +445,83 @@ void fetchIADisclosure_storeError_returnsEmpty() { assertThat(service.fetchIADisclosure("100", Locale.ENGLISH)).isEmpty(); } + + // --- rate limit (429) --------------------------------------------------------------------- + + /** A clock the test moves on. */ + private static final class MovingClock extends java.time.Clock { + Instant now = Instant.parse("2026-11-12T10:00:00Z"); + + @Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; } + @Override public java.time.Clock withZone(java.time.ZoneId zone) { return this; } + @Override public Instant instant() { return now; } + } + + private void given429(String appId, String retryAfter) { + var response = withStatus(HttpStatus.TOO_MANY_REQUESTS); + if (retryAfter != null) response = response.header("Retry-After", retryAfter); + steam.expect(org.springframework.test.web.client.ExpectedCount.once(), + requestTo(APP_DETAILS + "?appids=" + appId + "&l=english")).andRespond(response); + } + + @Test + void a429_leavesTheStoreAloneForItsRetryAfter_thenAsksAgain() { + MovingClock clock = new MovingClock(); + service.setClock(clock); + given429("100", "120"); + + assertThat(service.fetchTwSignals(List.of("100"))).isEmpty(); + assertThat(service.rateLimitedUntil()).contains(clock.now.plusSeconds(120)); + // Not asked meanwhile (a second request would fail the mock) + assertThat(service.fetchTwSignals(List.of("100"))).isEmpty(); + steam.verify(); + + clock.now = clock.now.plusSeconds(121); + assertThat(service.rateLimitedUntil()).isEmpty(); + steam.reset(); + givenAppDetails("100", success("100", "{\"type\": \"game\", \"name\": \"Some Game\"}")); + assertThat(service.fetchTwSignals(List.of("100"))).containsKey("100"); + } + + @Test + void a429_withoutRetryAfter_waits60s_doublingWhileTheyFollow_upTo10Minutes() { + MovingClock clock = new MovingClock(); + service.setClock(clock); + long[] expected = {60, 120, 240, 480, 600, 600}; + for (long seconds : expected) { + steam.reset(); + given429("100", null); + service.fetchTwSignals(List.of("100")); + assertThat(service.rateLimitedUntil()).contains(clock.now.plusSeconds(seconds)); + clock.now = clock.now.plusSeconds(seconds + 1); + } + } + + @Test + void anAnswer_resetsTheWait() { + MovingClock clock = new MovingClock(); + service.setClock(clock); + given429("100", null); + service.fetchTwSignals(List.of("100")); + clock.now = clock.now.plusSeconds(61); + + steam.reset(); + givenAppDetails("100", success("100", "{\"type\": \"game\", \"name\": \"Some Game\"}")); + service.fetchTwSignals(List.of("100")); + steam.reset(); + given429("100", null); + service.fetchTwSignals(List.of("100")); + + assertThat(service.rateLimitedUntil()).contains(clock.now.plusSeconds(60)); + } + + @Test + void retryAfter_readsSecondsOrAnHttpDate() { + Instant now = Instant.parse("2026-11-12T10:00:00Z"); + assertThat(SteamStoreServiceImpl.retryAfter("30", now)).contains(java.time.Duration.ofSeconds(30)); + assertThat(SteamStoreServiceImpl.retryAfter("Thu, 12 Nov 2026 10:05:00 GMT", now)) + .contains(java.time.Duration.ofMinutes(5)); + assertThat(SteamStoreServiceImpl.retryAfter("soon", now)).isEmpty(); + assertThat(SteamStoreServiceImpl.retryAfter(null, now)).isEmpty(); + } } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/tw/TwResolverServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/tw/TwResolverServiceTest.java index 346c944e1..4982bfab1 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/tw/TwResolverServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/tw/TwResolverServiceTest.java @@ -61,4 +61,25 @@ void dtddAbsent_skipsSilently() { assertThat(svc.suggest(new TwResolverService.SuggestInput( "x", Set.of(), null, "x"))).isEmpty(); } + + @Test + void steamSignalsMissedWhileRateLimited_areReported_andOtherwiseNot() { + SteamStoreService steam = mock(SteamStoreService.class); + TwResolverService svc = new TwResolverService( + mock(TwIgdbDescriptorMappingRepository.class), mock(TwSteamContentIdMappingRepository.class), + mock(TwSteamKeywordRepository.class), mock(TwDtddTopicMappingRepository.class), Optional.empty(), steam); + java.time.Instant until = java.time.Instant.parse("2026-11-12T10:05:00Z"); + when(steam.fetchTwSignals(List.of("app42"))).thenReturn(Map.of()); + when(steam.rateLimitedUntil()).thenReturn(Optional.of(until)); + + assertThat(svc.suggestReporting(new TwResolverService.SuggestInput("x", Set.of(), "app42", "x")) + .steamLimitedUntil()).contains(until); + // Not a Steam game: nothing asked of Steam, nothing missed + assertThat(svc.suggestReporting(new TwResolverService.SuggestInput("x", Set.of(), null, "x")) + .steamLimitedUntil()).isEmpty(); + // Steam answered + when(steam.fetchTwSignals(List.of("app42"))).thenReturn(Map.of("app42", SteamTwSignals.empty())); + assertThat(svc.suggestReporting(new TwResolverService.SuggestInput("x", Set.of(), "app42", "x")) + .steamLimitedUntil()).isEmpty(); + } } From d7170cb37e17149c1e0ac6e40ef6187268e34529 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 05:21:59 +0100 Subject: [PATCH 23/53] fix(api): resolve a Steam game's TWs again once the store's rate limit ends --- .../catapult/event/TwsMissedSteamEvent.java | 10 + .../service/binding/BindingService.java | 65 ++++++- .../service/binding/TwSteamRetry.java | 51 +++++ .../service/tw/TwResolverService.java | 5 + .../binding/BindingServiceSteamLimitTest.java | 178 ++++++++++++++++++ .../service/binding/TwSteamRetryTest.java | 63 +++++++ 6 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/event/TwsMissedSteamEvent.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/TwSteamRetry.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceSteamLimitTest.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/TwSteamRetryTest.java diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/event/TwsMissedSteamEvent.java b/catapult-api/src/main/java/fr/enimaloc/catapult/event/TwsMissedSteamEvent.java new file mode 100644 index 000000000..2c70d6618 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/event/TwsMissedSteamEvent.java @@ -0,0 +1,10 @@ +package fr.enimaloc.catapult.event; + +import java.time.Instant; +import java.util.UUID; + +/** + * A Steam game's binding had its TWs resolved while the Steam store was rate limited: they miss what Steam would + * have added, and are to be resolved again once it may be called ({@code until}). + */ +public record TwsMissedSteamEvent(UUID bindingId, Instant until) {} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java index 671d2bf6a..8fafd4d1a 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java @@ -6,6 +6,7 @@ import fr.enimaloc.catapult.domain.binding.GameBinding; import fr.enimaloc.catapult.domain.binding.GameMapping; import fr.enimaloc.catapult.domain.igdb.IgdbGameDetails; +import fr.enimaloc.catapult.event.TwsMissedSteamEvent; import fr.enimaloc.catapult.getter.DetectedGame; import fr.enimaloc.catapult.repository.binding.GameBindingRepository; import fr.enimaloc.catapult.repository.binding.GameMappingRepository; @@ -17,7 +18,10 @@ import fr.enimaloc.catapult.service.tw.TwResolverService; import fr.enimaloc.catapult.service.twitch.TwitchService; import lombok.RequiredArgsConstructor; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -44,6 +48,9 @@ public class BindingService { private final GameBindingLookup gameBindingLookup; private final CategoryChain categoryChain; private final ExtensionLayoutApplier extensionLayoutApplier; + /** Tells a binding whose Steam signals were missed (the store rate limited); null outside Spring. */ + @Setter(onMethod_ = @Autowired) + private ApplicationEventPublisher events; /** * Applies {@code change} to the user's binding (if it exists), saves it, and applies it when it is the game @@ -62,10 +69,13 @@ private void editBinding(UserAccount user, UUID bindingId, Consumer /** The TWs suggested for a game from its IGDB descriptors, Steam signals (Steam games only) and name. */ private Set suggestTws(String igdbId, DetectedGame game) { + return twResolverService.suggest(twInput(igdbId, game)); + } + + private TwResolverService.SuggestInput twInput(String igdbId, DetectedGame game) { Set descriptorIds = igdbService.fetchDescriptorIds(igdbId); String steamAppId = game.getSourceType() == GameBinding.SourceType.STEAM ? game.getSourceId() : null; - return twResolverService.suggest(new TwResolverService.SuggestInput( - igdbId, descriptorIds, steamAppId, game.getSourceName())); + return new TwResolverService.SuggestInput(igdbId, descriptorIds, steamAppId, game.getSourceName()); } private static DetectedGame detectedOf(GameBinding binding) { @@ -142,9 +152,56 @@ private GameBinding updateWithIgdbResolution(UserAccount user, DetectedGame dete applyMapping(binding, mapping.get()); log.info("Binding updated as MAPPED for user {} — game '{}' mapped by an admin to '{}' (twitchId={})", user.getId(), detectedGame.getSourceName(), binding.getTwitchGameName(), binding.getTwitchGameId()); - return gameBindingRepository.save(binding); + return missedSteam(gameBindingRepository.save(binding), mapping.get().isTwsOverridden()); } - return updateWithoutMapping(user, detectedGame, binding); + return missedSteam(updateWithoutMapping(user, detectedGame, binding), false); + } + + /** + * A Steam game's binding just resolved while the Steam store is rate limited: its TWs (when the detection chose + * them: not the streamer's, nor an admin's) miss Steam's signals, and are resolved again once it may be called. + */ + private GameBinding missedSteam(GameBinding saved, boolean adminTws) { + if (events == null || adminTws || saved.isTwOverride() + || saved.getSourceType() != GameBinding.SourceType.STEAM || saved.getId() == null) { + return saved; + } + twResolverService.steamRateLimitedUntil() + .ifPresent(until -> events.publishEvent(new TwsMissedSteamEvent(saved.getId(), until))); + return saved; + } + + /** + * Resolves again the TWs of a binding whose Steam signals were missed (the store rate limited), when the detection + * still chooses them; saved when they changed. Still limited, it is told again for later. + * + * @return the binding when its TWs changed, to push to its dashboard + */ + @Transactional + public Optional retryTwsAfterSteamLimit(UUID bindingId) { + Optional found = gameBindingRepository.findById(bindingId); + if (found.isEmpty()) return Optional.empty(); + GameBinding binding = found.get(); + boolean adminTws = binding.getStatus() == GameBinding.Status.MAPPED && binding.getSourceId() != null + && gameMappingRepository.findBySourceTypeAndSourceId(binding.getSourceType(), binding.getSourceId()) + .map(GameMapping::isTwsOverridden).orElse(false); + if (binding.isTwOverride() || adminTws || binding.getStatus() == GameBinding.Status.MANUAL) { + return Optional.empty(); + } + Optional igdbId = igdbService.resolveIgdbIdForBinding(binding); + if (igdbId.isEmpty()) return Optional.empty(); + TwResolverService.Suggestion suggestion = twResolverService.suggestReporting(twInput(igdbId.get(), detectedOf(binding))); + if (suggestion.steamLimitedUntil().isPresent()) { + if (events != null) { + events.publishEvent(new TwsMissedSteamEvent(bindingId, suggestion.steamLimitedUntil().get())); + } + return Optional.empty(); + } + if (binding.getTws().equals(suggestion.tws())) return Optional.empty(); + binding.getTws().clear(); + binding.getTws().addAll(suggestion.tws()); + log.info("[TW] binding {} resolved again after the Steam store's rate limit: {}", bindingId, suggestion.tws()); + return Optional.of(gameBindingRepository.save(binding)); } /** diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/TwSteamRetry.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/TwSteamRetry.java new file mode 100644 index 000000000..155ca2eb3 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/TwSteamRetry.java @@ -0,0 +1,51 @@ +package fr.enimaloc.catapult.service.binding; + +import fr.enimaloc.catapult.event.TwsMissedSteamEvent; +import fr.enimaloc.catapult.service.notification.ChannelEventPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.Duration; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A binding whose TWs missed the Steam store's signals (rate limited) has them resolved again once the store may be + * called — a moment after its Retry-After, never holding up the detection — then pushed to its dashboard. Once per + * binding at a time; still limited by then, it is planned again. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TwSteamRetry { + + /** After the limit, so the store is surely callable again. */ + static final Duration MARGIN = Duration.ofSeconds(2); + + private final BindingService bindingService; + private final ChannelEventPublisher channelEvents; + private final TaskScheduler scheduler; + private final Set planned = ConcurrentHashMap.newKeySet(); + + /** Once the binding is saved (its transaction committed, or none). */ + @TransactionalEventListener(fallbackExecution = true) + public void onMissedSteam(TwsMissedSteamEvent event) { + if (!planned.add(event.bindingId())) return; + scheduler.schedule(() -> retry(event.bindingId()), event.until().plus(MARGIN)); + log.debug("[TW] binding {} resolved again after the Steam store's limit ({})", event.bindingId(), event.until()); + } + + void retry(UUID bindingId) { + planned.remove(bindingId); + try { + bindingService.retryTwsAfterSteamLimit(bindingId) + .ifPresent(binding -> channelEvents.bindingUpserted(binding.getUser().getId(), binding)); + } catch (RuntimeException e) { + log.warn("[TW] binding {} not resolved again after the Steam store's limit: {}", bindingId, e.getMessage()); + } + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwResolverService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwResolverService.java index 3a24ae90b..234423049 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwResolverService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwResolverService.java @@ -43,6 +43,11 @@ public record SuggestInput( */ public record Suggestion(Set tws, Optional steamLimitedUntil) {} + /** Until when the Steam store is rate limited (a Steam game's TWs resolved meanwhile miss its signals). */ + public Optional steamRateLimitedUntil() { + return steamStoreService.rateLimitedUntil(); + } + /** Computes the set of TW slugs that should be pre-activated for this binding input. */ public Set suggest(SuggestInput in) { return suggestReporting(in).tws(); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceSteamLimitTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceSteamLimitTest.java new file mode 100644 index 000000000..d52f3057a --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceSteamLimitTest.java @@ -0,0 +1,178 @@ +package fr.enimaloc.catapult.service.binding; + +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.domain.binding.GameBinding; +import fr.enimaloc.catapult.domain.binding.GameMapping; +import fr.enimaloc.catapult.event.TwsMissedSteamEvent; +import fr.enimaloc.catapult.getter.DetectedGame; +import fr.enimaloc.catapult.repository.account.UserAccountRepository; +import fr.enimaloc.catapult.repository.account.UserSettingsRepository; +import fr.enimaloc.catapult.repository.binding.GameBindingRepository; +import fr.enimaloc.catapult.repository.binding.GameMappingRepository; +import fr.enimaloc.catapult.service.category.CategoryApplier; +import fr.enimaloc.catapult.service.category.CategoryChain; +import fr.enimaloc.catapult.service.category.ExtensionLayoutApplier; +import fr.enimaloc.catapult.service.igdb.IgdbGameDetailsService; +import fr.enimaloc.catapult.service.igdb.IgdbService; +import fr.enimaloc.catapult.service.tw.TwResolverService; +import fr.enimaloc.catapult.service.twitch.TwitchService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** A Steam game's TWs resolved while the Steam store is rate limited: told, then resolved again once it may be called. */ +class BindingServiceSteamLimitTest { + + private static final Instant UNTIL = Instant.parse("2026-11-12T10:05:00Z"); + + private final GameBindingRepository bindings = mock(GameBindingRepository.class); + private final GameMappingRepository mappings = mock(GameMappingRepository.class); + private final IgdbService igdb = mock(IgdbService.class); + private final IgdbGameDetailsService igdbDetails = mock(IgdbGameDetailsService.class); + private final TwitchService twitch = mock(TwitchService.class); + private final TwResolverService twResolver = mock(TwResolverService.class); + private final ApplicationEventPublisher events = mock(ApplicationEventPublisher.class); + private final BindingService service = new BindingService(bindings, igdb, igdbDetails, twitch, twResolver, mappings, + new GameBindingLookup(bindings), new CategoryChain(mock(CustomBindingActivations.class), + mock(StreamStateService.class), mock(UserSettingsRepository.class), new RaidRestoreHold(), + mock(GameStateService.class), mock(UserAccountRepository.class), + new CategoryApplier(twitch, mock(StreamStateService.class)), mock(RaidRestoreGateService.class), + mock(GameBindingLookup.class), mock(ExtensionLayoutApplier.class)), mock(ExtensionLayoutApplier.class)); + + private final UserAccount user = new UserAccount(); + private final DetectedGame steamGame = new DetectedGame("730", GameBinding.SourceType.STEAM, "Counter-Strike 2"); + + @BeforeEach + void setUp() { + service.setEvents(events); + user.setId(UUID.randomUUID()); + when(bindings.save(any())).thenAnswer(call -> { + GameBinding b = call.getArgument(0); + if (b.getId() == null) b.setId(UUID.randomUUID()); + return b; + }); + when(mappings.findBySourceTypeAndSourceId(any(), any())).thenReturn(Optional.empty()); + when(igdb.findByExternalAppId(GameBinding.SourceType.STEAM, "730")) + .thenReturn(Optional.of(new IgdbService.IgdbGame("igdb-1", "Counter-Strike 2"))); + when(igdbDetails.getDetails(anyString())).thenReturn(Optional.empty()); + when(igdb.findTwitchGameId("igdb-1")).thenReturn(Optional.of("32399")); + when(igdb.resolveIgdbIdForBinding(any())).thenReturn(Optional.of("igdb-1")); + when(twResolver.suggest(any())).thenReturn(Set.of("violence")); + when(twResolver.steamRateLimitedUntil()).thenReturn(Optional.of(UNTIL)); + } + + private GameBinding binding(GameBinding.Status status) { + GameBinding b = new GameBinding(); + b.setId(UUID.randomUUID()); + b.setUser(user); + b.setSourceType(GameBinding.SourceType.STEAM); + b.setSourceId("730"); + b.setSourceName("Counter-Strike 2"); + b.setStatus(status); + b.getTws().add("old"); + when(bindings.findById(b.getId())).thenReturn(Optional.of(b)); + return b; + } + + @Test + void aSteamGameResolvedWhileLimited_isTold() { + when(bindings.findByUserAndSourceIdAndSourceType(user, "730", GameBinding.SourceType.STEAM)) + .thenReturn(Optional.empty()); + + GameBinding created = service.resolveOrCreate(user, steamGame); + + verify(events).publishEvent(new TwsMissedSteamEvent(created.getId(), UNTIL)); + } + + @Test + void notWhenTheStoreMayBeCalled_norForAnotherPlatform() { + when(twResolver.steamRateLimitedUntil()).thenReturn(Optional.empty()); + when(bindings.findByUserAndSourceIdAndSourceType(any(), any(), any())).thenReturn(Optional.empty()); + service.resolveOrCreate(user, steamGame); + + when(twResolver.steamRateLimitedUntil()).thenReturn(Optional.of(UNTIL)); + when(igdb.findByExternalAppId(GameBinding.SourceType.XBOX, "x1")) + .thenReturn(Optional.of(new IgdbService.IgdbGame("igdb-2", "Halo"))); + service.resolveOrCreate(user, new DetectedGame("x1", GameBinding.SourceType.XBOX, "Halo")); + + verify(events, never()).publishEvent(any(Object.class)); + } + + @Test + void notWhenAnAdminMappingSetsTheTws() { + GameMapping mapping = new GameMapping(); + mapping.setTwitchGameId("32399"); + mapping.setTwitchGameName("CS"); + mapping.setTwsOverridden(true); + when(mappings.findBySourceTypeAndSourceId(GameBinding.SourceType.STEAM, "730")).thenReturn(Optional.of(mapping)); + when(bindings.findByUserAndSourceIdAndSourceType(any(), any(), any())).thenReturn(Optional.empty()); + + service.resolveOrCreate(user, steamGame); + + verify(events, never()).publishEvent(any(Object.class)); + } + + @Test + void theRetry_savesTheTwsOnceSteamAnswers() { + GameBinding binding = binding(GameBinding.Status.AUTO); + when(twResolver.suggestReporting(any())).thenReturn( + new TwResolverService.Suggestion(Set.of("violence", "gore"), Optional.empty())); + + Optional retried = service.retryTwsAfterSteamLimit(binding.getId()); + + assertThat(retried).contains(binding); + assertThat(binding.getTws()).containsExactlyInAnyOrder("violence", "gore"); + verify(bindings).save(binding); + } + + @Test + void theRetry_stillLimited_isToldAgain_andChangesNothing() { + GameBinding binding = binding(GameBinding.Status.AUTO); + Instant later = UNTIL.plusSeconds(120); + when(twResolver.suggestReporting(any())).thenReturn( + new TwResolverService.Suggestion(Set.of("violence"), Optional.of(later))); + + assertThat(service.retryTwsAfterSteamLimit(binding.getId())).isEmpty(); + assertThat(binding.getTws()).containsExactly("old"); + verify(events).publishEvent(new TwsMissedSteamEvent(binding.getId(), later)); + verify(bindings, never()).save(any()); + } + + @Test + void theRetry_leavesTheStreamersAndAnAdminsChoiceAlone() { + GameBinding pinned = binding(GameBinding.Status.AUTO); + pinned.setTwOverride(true); + GameBinding manual = binding(GameBinding.Status.MANUAL); + GameBinding mapped = binding(GameBinding.Status.MAPPED); + GameMapping mapping = new GameMapping(); + mapping.setTwsOverridden(true); + when(mappings.findBySourceTypeAndSourceId(GameBinding.SourceType.STEAM, "730")).thenReturn(Optional.of(mapping)); + + assertThat(service.retryTwsAfterSteamLimit(pinned.getId())).isEmpty(); + assertThat(service.retryTwsAfterSteamLimit(manual.getId())).isEmpty(); + assertThat(service.retryTwsAfterSteamLimit(mapped.getId())).isEmpty(); + verify(twResolver, never()).suggestReporting(any()); + } + + @Test + void theRetry_ofUnchangedTws_pushesNothing() { + GameBinding binding = binding(GameBinding.Status.AUTO); + when(twResolver.suggestReporting(any())).thenReturn(new TwResolverService.Suggestion(Set.of("old"), Optional.empty())); + + assertThat(service.retryTwsAfterSteamLimit(binding.getId())).isEmpty(); + verify(bindings, never()).save(any()); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/TwSteamRetryTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/TwSteamRetryTest.java new file mode 100644 index 000000000..05fa1d928 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/TwSteamRetryTest.java @@ -0,0 +1,63 @@ +package fr.enimaloc.catapult.service.binding; + +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.domain.binding.GameBinding; +import fr.enimaloc.catapult.event.TwsMissedSteamEvent; +import fr.enimaloc.catapult.service.notification.ChannelEventPublisher; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.scheduling.TaskScheduler; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** The deferred TW resolution: planned after the limit, once per binding, its result pushed. */ +class TwSteamRetryTest { + + private final BindingService bindings = mock(BindingService.class); + private final ChannelEventPublisher channelEvents = mock(ChannelEventPublisher.class); + private final TaskScheduler scheduler = mock(TaskScheduler.class); + private final TwSteamRetry retry = new TwSteamRetry(bindings, channelEvents, scheduler); + private final UUID bindingId = UUID.randomUUID(); + private final Instant until = Instant.parse("2026-11-12T10:05:00Z"); + + @Test + void itIsPlannedJustAfterTheLimit_oncePerBindingAtATime() { + retry.onMissedSteam(new TwsMissedSteamEvent(bindingId, until)); + retry.onMissedSteam(new TwsMissedSteamEvent(bindingId, until.plusSeconds(30))); + + verify(scheduler, times(1)).schedule(any(Runnable.class), eq(until.plus(TwSteamRetry.MARGIN))); + } + + @Test + void itsResult_isPushedToTheDashboard_andAnotherMayBePlannedThen() { + UserAccount owner = new UserAccount(); + owner.setId(UUID.randomUUID()); + GameBinding binding = new GameBinding(); + binding.setUser(owner); + when(bindings.retryTwsAfterSteamLimit(bindingId)).thenReturn(Optional.of(binding)); + retry.onMissedSteam(new TwsMissedSteamEvent(bindingId, until)); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).schedule(task.capture(), any(Instant.class)); + + task.getValue().run(); + + verify(channelEvents).bindingUpserted(owner.getId(), binding); + retry.onMissedSteam(new TwsMissedSteamEvent(bindingId, until.plusSeconds(60))); + verify(scheduler, times(2)).schedule(any(Runnable.class), any(Instant.class)); + } + + @Test + void aFailure_isLogged_notThrown() { + when(bindings.retryTwsAfterSteamLimit(bindingId)).thenThrow(new IllegalStateException("boom")); + + retry.retry(bindingId); + + verifyNoInteractions(channelEvents); + } +} From 1ae844a1a38dc33a4d8bba1d7c4766cb4250dbb9 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 05:25:17 +0100 Subject: [PATCH 24/53] feat(api): run one TW recalculation at a time, follow and cancel it, waiting out Steam's rate limit --- .../api/admin/ApiAdminTwController.java | 50 ++-- .../service/tw/TwBackfillService.java | 245 +++++++++++++----- .../api/admin/ApiAdminTwControllerTest.java | 18 +- .../service/tw/TwBackfillServiceTest.java | 137 +++++++++- .../src/test/resources/access-matrix.csv | 2 + .../common/dto/admin/TwRebuildStatusDto.java | 20 ++ 6 files changed, 383 insertions(+), 89 deletions(-) create mode 100644 catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/TwRebuildStatusDto.java diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminTwController.java b/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminTwController.java index 54e8674d1..8b1f7c3dd 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminTwController.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminTwController.java @@ -8,6 +8,7 @@ import fr.enimaloc.catapult.common.dto.admin.SteamKeywordBody; import fr.enimaloc.catapult.common.dto.admin.SteamKeywordsBody; import fr.enimaloc.catapult.common.dto.admin.SteamSignalTestBody; +import fr.enimaloc.catapult.common.dto.admin.TwRebuildStatusDto; import fr.enimaloc.catapult.common.dto.admin.UpdateBody; import fr.enimaloc.catapult.domain.tw.TwDefinition; import fr.enimaloc.catapult.service.notification.AdminEventPublisher; @@ -129,32 +130,39 @@ public AdminTwService.SteamSignalTestResult testSteamKw(@PathVariable String id, } @PostMapping("/rebuild") - public ResponseEntity rebuildNonOverriddenMappings() { - return backfillService - .map(backfill -> { - backfill.rebuildAllNonOverridden(); - return ResponseEntity.accepted().build(); - }) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "The TW backfill is not available")); + public ResponseEntity rebuildNonOverriddenMappings() { + return start(TwBackfillService.Mode.NON_OVERRIDDEN); } @PostMapping("/rebuild/force") - public ResponseEntity rebuildMappings() { - return backfillService - .map(backfill -> { - backfill.rebuildAll(); - return ResponseEntity.accepted().build(); - }) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "The TW backfill is not available")); + public ResponseEntity rebuildMappings() { + return start(TwBackfillService.Mode.UNTOUCHED); } @PostMapping("/rebuild/force-pinned") - public ResponseEntity rebuildMappingsIncludingPinned() { - return backfillService - .map(backfill -> { - backfill.forceRebuildAllIncludingPinned(); - return ResponseEntity.accepted().build(); - }) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "The TW backfill is not available")); + public ResponseEntity rebuildMappingsIncludingPinned() { + return start(TwBackfillService.Mode.FORCE_PINNED); + } + + /** The TWs' recalculation running or last run (IDLE without the backfill: none ever). */ + @GetMapping("/rebuild/status") + public TwRebuildStatusDto rebuildStatus() { + return backfillService.map(TwBackfillService::status).orElse(TwRebuildStatusDto.IDLE); + } + + /** Asks the running recalculation to stop after its current binding. */ + @PostMapping("/rebuild/cancel") + public TwRebuildStatusDto cancelRebuild() { + return backfill().cancel(); + } + + /** 202 with the recalculation started; 409 while one runs already, 503 without the backfill. */ + private ResponseEntity start(TwBackfillService.Mode mode) { + return ResponseEntity.accepted().body(backfill().start(mode)); + } + + private TwBackfillService backfill() { + return backfillService.orElseThrow( + () -> new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "The TW backfill is not available")); } } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwBackfillService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwBackfillService.java index bc6c4f99a..9d5e369b0 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwBackfillService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwBackfillService.java @@ -1,14 +1,16 @@ package fr.enimaloc.catapult.service.tw; -import fr.enimaloc.catapult.service.debug.DebugContext; +import fr.enimaloc.catapult.common.dto.admin.TwRebuildStatusDto; import fr.enimaloc.catapult.domain.binding.GameBinding; import fr.enimaloc.catapult.domain.binding.GameMapping; import fr.enimaloc.catapult.domain.config.AppState; import fr.enimaloc.catapult.repository.binding.GameBindingRepository; import fr.enimaloc.catapult.repository.binding.GameMappingRepository; import fr.enimaloc.catapult.repository.config.AppStateRepository; +import fr.enimaloc.catapult.service.debug.DebugContext; import fr.enimaloc.catapult.service.igdb.IgdbService; import io.micrometer.core.instrument.MeterRegistry; +import lombok.AccessLevel; import lombok.RequiredArgsConstructor; import lombok.Setter; import lombok.extern.slf4j.Slf4j; @@ -18,12 +20,20 @@ import org.springframework.context.event.EventListener; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.time.Clock; +import java.time.Duration; import java.time.Instant; +import java.util.Locale; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; @Slf4j @Service @@ -31,6 +41,20 @@ @RequiredArgsConstructor public class TwBackfillService { + /** The recalculations: the start-up backfill, and the admin's three rebuilds. */ + public enum Mode { BACKFILL, NON_OVERRIDDEN, UNTOUCHED, FORCE_PINNED } + + /** How many times a binding waits for the Steam store's rate limit before it counts as failed. */ + static final int MAX_STEAM_WAITS = 10; + /** Sleeps that long at most at once, so a cancel is seen soon. */ + private static final long SLEEP_SLICE_MS = 1_000L; + + /** Waits {@code millis}; interrupted, the recalculation ends as cancelled. */ + @FunctionalInterface + interface Sleeper { + void sleep(long millis) throws InterruptedException; + } + private final AppStateRepository appStateRepo; private final GameBindingRepository bindingRepo; private final GameMappingRepository mappingRepo; @@ -46,6 +70,36 @@ public class TwBackfillService { @Value("${tw.backfill.batch-size:50}") int batchSize; + @Setter(AccessLevel.PACKAGE) + private Sleeper sleeper = Thread::sleep; + @Setter(AccessLevel.PACKAGE) + private Clock clock = Clock.systemUTC(); + + private final AtomicBoolean running = new AtomicBoolean(); + private final AtomicReference status = new AtomicReference<>(TwRebuildStatusDto.IDLE); + private volatile boolean cancelled; + + public TwRebuildStatusDto status() { + return status.get(); + } + + /** Asks the running recalculation to stop after its current binding; nothing when none runs. */ + public TwRebuildStatusDto cancel() { + if (running.get()) cancelled = true; + return status.get(); + } + + /** + * Starts an admin rebuild in the background, its progress in {@link #status()}. + * + * @throws ResponseStatusException 409 while a recalculation runs already + */ + public TwRebuildStatusDto start(Mode mode) { + claim(mode); + Thread.ofVirtual().name("tw-rebuild").start(() -> runClaimed(mode)); + return status.get(); + } + @Async @EventListener(ApplicationReadyEvent.class) public void run() { @@ -53,13 +107,16 @@ public void run() { log.debug("TW backfill already completed; skipping."); return; } - log.info("TW backfill starting (throttle={}/s, batchSize={})", throttle, batchSize); - Counts c = process(true); + if (!running.compareAndSet(false, true)) { + log.info("TW backfill not started: a recalculation runs already."); + return; + } + begin(Mode.BACKFILL); + Counts c = runClaimed(Mode.BACKFILL); appStateRepo.save(new AppState( "tw_backfill_completed", "processed=%d skipped=%d failed=%d".formatted(c.processed, c.skipped, c.failed), Instant.now())); - log.info("TW backfill done: processed={} skipped={} failed={}", c.processed, c.skipped, c.failed); } /** @@ -70,22 +127,18 @@ public void run() { */ @Async public void rebuildAllNonOverridden() { - log.info("TW rebuild starting (throttle={}/s, batchSize={})", throttle, batchSize); - Counts c = process(false); - log.info("TW rebuild done: processed={} skipped={} failed={}", c.processed, c.skipped, c.failed); + claim(Mode.NON_OVERRIDDEN); + runClaimed(Mode.NON_OVERRIDDEN); } /** - * Admin-triggered rebuild: re-resolve TW suggestions for every binding - * whose streamer has not pinned them ({@code twOverride=false}), including - * those that already carry a non-empty TW set. Used after the mapping - * tables change so existing bindings pick up the new signals. + * Admin-triggered rebuild: re-resolve TW suggestions for the bindings that + * carry none yet and whose streamer has not pinned them, as the start-up backfill. */ @Async public void rebuildAll() { - log.info("TW rebuild starting (throttle={}/s, batchSize={})", throttle, batchSize); - Counts c = process(true); - log.info("TW rebuild done: processed={} skipped={} failed={}", c.processed, c.skipped, c.failed); + claim(Mode.UNTOUCHED); + runClaimed(Mode.UNTOUCHED); } /** @@ -97,9 +150,8 @@ public void rebuildAll() { */ @Async public void forceRebuildAllIncludingPinned() { - log.info("TW force-rebuild (including pinned) starting (throttle={}/s, batchSize={})", throttle, batchSize); - Counts c = processForceAll(); - log.info("TW force-rebuild done: processed={} skipped={} failed={}", c.processed, c.skipped, c.failed); + claim(Mode.FORCE_PINNED); + runClaimed(Mode.FORCE_PINNED); } private static class Counts { @@ -108,56 +160,84 @@ private static class Counts { int failed; } - /** - * Shared loop body. {@code backfillOnly=true} restricts to the original - * empty-TW candidates and applies the historical inner skip guard; - * {@code false} processes every non-overridden binding (rebuild path). - */ - private Counts process(boolean backfillOnly) { - long sleepMs = 1000L / Math.max(throttle, 1); + /** Takes the one recalculation slot for {@code mode}, its status reset. */ + private void claim(Mode mode) { + if (!running.compareAndSet(false, true)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "A TW recalculation runs already"); + } + try { + begin(mode); + } catch (RuntimeException e) { + running.set(false); + throw e; + } + } + + private void begin(Mode mode) { + cancelled = false; + long total = candidates(mode, PageRequest.of(0, Math.max(batchSize, 1))).getTotalElements(); + status.set(new TwRebuildStatusDto("RUNNING", mode.name(), total, 0, 0, 0, clock.instant(), null, null)); + log.info("TW {} starting: {} binding(s) (throttle={}/s, batchSize={})", mode, total, throttle, batchSize); + } + + /** Runs the recalculation the slot was claimed for, then frees it. */ + private Counts runClaimed(Mode mode) { Counts c = new Counts(); - int page = 0; - Page batch; - String mode = backfillOnly ? "backfill" : "rebuild"; - do { - batch = backfillOnly - ? bindingRepo.findCandidatesForTwBackfill(PageRequest.of(page++, batchSize)) - : bindingRepo.findCandidatesForTwRebuild(PageRequest.of(page++, batchSize)); - for (GameBinding b : batch) { - if (b.isTwOverride() || (backfillOnly && !b.getTws().isEmpty()) || hasAdminTws(b)) { - c.skipped++; - meterRegistry.counter("catapult.tw." + mode + ".skipped").increment(); - continue; - } - if (!remap(b, mode, c, sleepMs)) { - return c; - } - } - } while (batch.hasNext()); + String state = "DONE"; + try { + if (!process(mode, c)) state = "CANCELLED"; + } catch (RuntimeException e) { + log.warn("TW {} ended on an error: {}", mode, e.getMessage(), e); + } finally { + TwRebuildStatusDto current = status.get(); + status.set(new TwRebuildStatusDto(state, mode.name(), current.total(), c.processed, c.skipped, c.failed, + current.startedAt(), clock.instant(), null)); + running.set(false); + log.info("TW {} {}: processed={} skipped={} failed={}", mode, state.toLowerCase(Locale.ROOT), + c.processed, c.skipped, c.failed); + } return c; } - /** Same loop as {@link #process}, but never skips on {@code twOverride} (the admin's mapped TWs still win). */ - private Counts processForceAll() { + private Page candidates(Mode mode, Pageable page) { + return switch (mode) { + case BACKFILL, UNTOUCHED -> bindingRepo.findCandidatesForTwBackfill(page); + case NON_OVERRIDDEN -> bindingRepo.findCandidatesForTwRebuild(page); + case FORCE_PINNED -> bindingRepo.findAllCandidatesForTwForceRebuild(page); + }; + } + + /** + * The loop: every candidate of {@code mode}, the streamer's pinned TWs left alone but by {@code FORCE_PINNED}, + * an admin's TWs always. Returns {@code false} when cancelled (or interrupted). + */ + private boolean process(Mode mode, Counts c) { long sleepMs = 1000L / Math.max(throttle, 1); - Counts c = new Counts(); + boolean backfillOnly = mode == Mode.BACKFILL || mode == Mode.UNTOUCHED; + String metric = switch (mode) { + case BACKFILL, UNTOUCHED -> "backfill"; + case NON_OVERRIDDEN -> "rebuild"; + case FORCE_PINNED -> "force_all"; + }; int page = 0; Page batch; - String mode = "force_all"; do { - batch = bindingRepo.findAllCandidatesForTwForceRebuild(PageRequest.of(page++, batchSize)); + batch = candidates(mode, PageRequest.of(page++, batchSize)); for (GameBinding b : batch) { - if (hasAdminTws(b)) { + if (cancelled) return false; + boolean pinned = mode != Mode.FORCE_PINNED && b.isTwOverride(); + if (pinned || (backfillOnly && !b.getTws().isEmpty()) || hasAdminTws(b)) { c.skipped++; - meterRegistry.counter("catapult.tw." + mode + ".skipped").increment(); + meterRegistry.counter("catapult.tw." + metric + ".skipped").increment(); + progress(c, null); continue; } - if (!remap(b, mode, c, sleepMs)) { - return c; + if (!remap(b, metric, c, sleepMs)) { + return false; } } } while (batch.hasNext()); - return c; + return true; } /** @@ -173,37 +253,74 @@ private boolean hasAdminTws(GameBinding b) { /** * Re-resolves one binding's TW suggestions, records the outcome in {@code c}, then - * throttles. Returns {@code false} when interrupted during the throttle sleep. + * throttles. Missing the Steam store's signals (rate limited), it waits for the limit's end and tries again, + * never saving TWs without them. Returns {@code false} when cancelled or interrupted. */ - private boolean remap(GameBinding b, String mode, Counts c, long sleepMs) { - DebugContext.runAs(b.getUser().getId(), () -> resolveTws(b, mode, c)); + private boolean remap(GameBinding b, String metric, Counts c, long sleepMs) { try { - Thread.sleep(sleepMs); + for (int waits = 0; ; waits++) { + AtomicReference limited = new AtomicReference<>(); + DebugContext.runAs(b.getUser().getId(), () -> limited.set(resolveTws(b, metric, c))); + if (limited.get() == null) break; + if (waits >= MAX_STEAM_WAITS) { + log.warn("TW {} gave up on binding {}: the Steam store stayed rate limited", metric, b.getId()); + c.failed++; + meterRegistry.counter("catapult.tw." + metric + ".failed").increment(); + break; + } + progress(c, limited.get()); + if (!pause(Duration.between(clock.instant(), limited.get()).toMillis() + 1_000L)) return false; + } + progress(c, null); + return pause(sleepMs); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return false; } - return true; } - /** Re-resolves one binding's TW suggestions as its owner and records the outcome in {@code c}. */ - private void resolveTws(GameBinding b, String mode, Counts c) { + /** Sleeps {@code millis} in slices; {@code false} once cancelled. */ + private boolean pause(long millis) throws InterruptedException { + long left = millis; + while (left > 0) { + if (cancelled) return false; + long slice = Math.min(left, SLEEP_SLICE_MS); + sleeper.sleep(slice); + left -= slice; + } + return !cancelled; + } + + private void progress(Counts c, Instant waitingUntil) { + TwRebuildStatusDto current = status.get(); + status.set(new TwRebuildStatusDto("RUNNING", current.mode(), current.total(), c.processed, c.skipped, c.failed, + current.startedAt(), null, waitingUntil)); + } + + /** + * Re-resolves one binding's TW suggestions as its owner and records the outcome in {@code c}; returns until when + * the Steam store is rate limited when its signals were missed (nothing saved then), else null. + */ + private Instant resolveTws(GameBinding b, String metric, Counts c) { try { Optional igdbId = igdbService.resolveIgdbIdForBinding(b); Set descIds = igdbId.map(igdbService::fetchDescriptorIds).orElse(Set.of()); String steamApp = b.getSourceType() == GameBinding.SourceType.STEAM ? b.getSourceId() : null; - Set tws = resolver.suggest(new TwResolverService.SuggestInput( + TwResolverService.Suggestion suggestion = resolver.suggestReporting(new TwResolverService.SuggestInput( igdbId.orElse(null), descIds, steamApp, b.getSourceName())); + if (suggestion.steamLimitedUntil().isPresent()) return suggestion.steamLimitedUntil().get(); + Set tws = suggestion.tws(); log.debug("[TW] {} mapping for binding {} game '{}' (igdb={}): {}", - mode, b.getId(), b.getSourceName(), igdbId.orElse(null), tws); + metric, b.getId(), b.getSourceName(), igdbId.orElse(null), tws); b.setTws(tws); bindingRepo.save(b); c.processed++; - meterRegistry.counter("catapult.tw." + mode + ".processed").increment(); + meterRegistry.counter("catapult.tw." + metric + ".processed").increment(); } catch (Exception e) { - log.warn("TW {} failed for binding {}: {}", mode, b.getId(), e.getMessage()); + log.warn("TW {} failed for binding {}: {}", metric, b.getId(), e.getMessage()); c.failed++; - meterRegistry.counter("catapult.tw." + mode + ".failed").increment(); + meterRegistry.counter("catapult.tw." + metric + ".failed").increment(); } + return null; } } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminTwControllerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminTwControllerTest.java index 335a1f9aa..9186809b3 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminTwControllerTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminTwControllerTest.java @@ -51,7 +51,7 @@ void rebuildForcePinned_delegatesToBackfillService() throws Exception { MockMvc mvc = MockMvcBuilders.webAppContextSetup(wac).build(); mvc.perform(post("/api/internal/admin/tws/rebuild/force-pinned").with(csrf())) .andExpect(status().isAccepted()); - verify(twBackfillService).forceRebuildAllIncludingPinned(); + verify(twBackfillService).start(TwBackfillService.Mode.FORCE_PINNED); } @Test @@ -129,4 +129,20 @@ void testSteamKeywords_returnsMatchResult() throws Exception { .andExpect(jsonPath("$.draftKeywordMatch").value(true)) .andExpect(jsonPath("$.matchedContentDescriptorIds[0]").value(2)); } + + @Test + void theRebuild_isFollowedAndCancelled() throws Exception { + MockMvc mvc = MockMvcBuilders.webAppContextSetup(wac).build(); + var running = new fr.enimaloc.catapult.common.dto.admin.TwRebuildStatusDto("RUNNING", "NON_OVERRIDDEN", 10, 3, 1, + 0, null, null, null); + when(twBackfillService.status()).thenReturn(running); + when(twBackfillService.cancel()).thenReturn(running); + + mvc.perform(get("/api/internal/admin/tws/rebuild/status")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.state").value("RUNNING")) + .andExpect(jsonPath("$.total").value(10)); + mvc.perform(post("/api/internal/admin/tws/rebuild/cancel").with(csrf())).andExpect(status().isOk()); + verify(twBackfillService).cancel(); + } } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/tw/TwBackfillServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/tw/TwBackfillServiceTest.java index 32bab70e0..f6ca3cb1c 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/tw/TwBackfillServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/tw/TwBackfillServiceTest.java @@ -17,6 +17,7 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.time.Instant; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -25,6 +26,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; class TwBackfillServiceTest { @@ -75,7 +77,7 @@ void processesEligibleBindings_skipsOverride_persistsFlag() { when(igdb.resolveIgdbIdForBinding(b1)).thenReturn(Optional.of("igdb42")); when(igdb.fetchDescriptorIds("igdb42")).thenReturn(Set.of(10L)); TwResolverService res = mock(TwResolverService.class); - when(res.suggest(any())).thenReturn(Set.of("violence_graphic")); + when(res.suggestReporting(any())).thenReturn(new TwResolverService.Suggestion(Set.of("violence_graphic"), Optional.empty())); TwBackfillService svc = build(appRepo, bRepo, igdb, res); svc.run(); @@ -107,7 +109,7 @@ void forceRebuildAllIncludingPinned_doesNotSkipOverriddenBindings() { when(igdb.resolveIgdbIdForBinding(pinned)).thenReturn(Optional.of("igdb99")); when(igdb.fetchDescriptorIds("igdb99")).thenReturn(Set.of(20L)); TwResolverService res = mock(TwResolverService.class); - when(res.suggest(any())).thenReturn(Set.of("new_tw")); + when(res.suggestReporting(any())).thenReturn(new TwResolverService.Suggestion(Set.of("new_tw"), Optional.empty())); TwBackfillService svc = build(appRepo, bRepo, igdb, res); svc.forceRebuildAllIncludingPinned(); @@ -131,7 +133,7 @@ private MappedPair mappedPair() { IgdbService igdb = mock(IgdbService.class); when(igdb.resolveIgdbIdForBinding(any())).thenReturn(Optional.of("igdb")); TwResolverService res = mock(TwResolverService.class); - when(res.suggest(any())).thenReturn(Set.of("igdb_tw")); + when(res.suggestReporting(any())).thenReturn(new TwResolverService.Suggestion(Set.of("igdb_tw"), Optional.empty())); return new MappedPair(adminTws, igdbTws, igdb, res); } @@ -200,4 +202,133 @@ private TwBackfillService build(AppStateRepository app, GameBindingRepository b, s.setThrottle(1000); return s; } + + // --- progress, one at a time, the Steam store's rate limit --------------------------------- + + private static GameBinding steamBinding(String appId) { + GameBinding b = new GameBinding(); + b.setUser(owner()); + b.setId(UUID.randomUUID()); + b.setStatus(GameBinding.Status.AUTO); + b.setSourceType(GameBinding.SourceType.STEAM); + b.setSourceId(appId); + b.setSourceName("Game " + appId); + return b; + } + + @Test + void aRebuild_tellsItsProgress_andItsEnd() { + GameBindingRepository bRepo = mock(GameBindingRepository.class); + GameBinding pinned = steamBinding("1"); + pinned.setTwOverride(true); + GameBinding plain = steamBinding("2"); + when(bRepo.findCandidatesForTwRebuild(any(Pageable.class))).thenReturn(new PageImpl<>(List.of(pinned, plain))); + IgdbService igdb = mock(IgdbService.class); + TwResolverService res = mock(TwResolverService.class); + when(res.suggestReporting(any())).thenReturn(new TwResolverService.Suggestion(Set.of("gore"), Optional.empty())); + TwBackfillService svc = build(mock(AppStateRepository.class), bRepo, igdb, res); + svc.setSleeper(ms -> {}); + + svc.rebuildAllNonOverridden(); + + var status = svc.status(); + assertThat(status.state()).isEqualTo("DONE"); + assertThat(status.mode()).isEqualTo("NON_OVERRIDDEN"); + assertThat(status.total()).isEqualTo(2); + assertThat(status.processed()).isEqualTo(1); + assertThat(status.skipped()).isEqualTo(1); + assertThat(status.done()).isEqualTo(2); + assertThat(status.finishedAt()).isNotNull(); + } + + @Test + void theSteamStoresLimit_isWaitedFor_andTheBindingTriedAgain_neverSavedWithoutSteam() { + GameBindingRepository bRepo = mock(GameBindingRepository.class); + GameBinding plain = steamBinding("2"); + when(bRepo.findCandidatesForTwRebuild(any(Pageable.class))).thenReturn(new PageImpl<>(List.of(plain))); + TwResolverService res = mock(TwResolverService.class); + Instant start = Instant.parse("2026-11-12T10:00:00Z"); + Instant until = start.plusSeconds(5); + when(res.suggestReporting(any())).thenReturn( + new TwResolverService.Suggestion(Set.of("partial"), Optional.of(until)), + new TwResolverService.Suggestion(Set.of("gore"), Optional.empty())); + TwBackfillService svc = build(mock(AppStateRepository.class), bRepo, mock(IgdbService.class), res); + svc.setClock(java.time.Clock.fixed(start, java.time.ZoneOffset.UTC)); + List waitingShown = new java.util.ArrayList<>(); + java.util.concurrent.atomic.AtomicLong slept = new java.util.concurrent.atomic.AtomicLong(); + svc.setSleeper(ms -> { + slept.addAndGet(ms); + waitingShown.add(svc.status().waitingUntil()); + }); + + svc.rebuildAllNonOverridden(); + + assertThat(waitingShown.getFirst()).isEqualTo(until); + // Until the limit's end, and a second more + assertThat(slept.get()).isGreaterThanOrEqualTo(6_000L); + verify(bRepo, times(1)).save(plain); + assertThat(plain.getTws()).containsExactly("gore"); + assertThat(svc.status().processed()).isEqualTo(1); + } + + @Test + void aBindingTheStoreStaysLimitedFor_isFailed_afterSoManyWaits() { + GameBindingRepository bRepo = mock(GameBindingRepository.class); + GameBinding plain = steamBinding("2"); + when(bRepo.findCandidatesForTwRebuild(any(Pageable.class))).thenReturn(new PageImpl<>(List.of(plain))); + TwResolverService res = mock(TwResolverService.class); + Instant start = Instant.parse("2026-11-12T10:00:00Z"); + when(res.suggestReporting(any())).thenReturn( + new TwResolverService.Suggestion(Set.of(), Optional.of(start.plusSeconds(1)))); + TwBackfillService svc = build(mock(AppStateRepository.class), bRepo, mock(IgdbService.class), res); + svc.setClock(java.time.Clock.fixed(start, java.time.ZoneOffset.UTC)); + svc.setSleeper(ms -> {}); + + svc.rebuildAllNonOverridden(); + + verify(res, times(TwBackfillService.MAX_STEAM_WAITS + 1)).suggestReporting(any()); + verify(bRepo, never()).save(any()); + assertThat(svc.status().failed()).isEqualTo(1); + } + + @Test + void aCancel_endsItAfterTheCurrentBinding() { + GameBindingRepository bRepo = mock(GameBindingRepository.class); + when(bRepo.findCandidatesForTwRebuild(any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(steamBinding("1"), steamBinding("2")))); + TwResolverService res = mock(TwResolverService.class); + when(res.suggestReporting(any())).thenReturn(new TwResolverService.Suggestion(Set.of("gore"), Optional.empty())); + TwBackfillService svc = build(mock(AppStateRepository.class), bRepo, mock(IgdbService.class), res); + svc.setSleeper(ms -> svc.cancel()); + + svc.rebuildAllNonOverridden(); + + assertThat(svc.status().state()).isEqualTo("CANCELLED"); + assertThat(svc.status().processed()).isEqualTo(1); + } + + @Test + void oneAtATime() throws Exception { + GameBindingRepository bRepo = mock(GameBindingRepository.class); + when(bRepo.findCandidatesForTwRebuild(any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(steamBinding("1")))); + TwResolverService res = mock(TwResolverService.class); + java.util.concurrent.CountDownLatch inside = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch release = new java.util.concurrent.CountDownLatch(1); + when(res.suggestReporting(any())).thenAnswer(inv -> { + inside.countDown(); + release.await(); + return new TwResolverService.Suggestion(Set.of(), Optional.empty()); + }); + TwBackfillService svc = build(mock(AppStateRepository.class), bRepo, mock(IgdbService.class), res); + svc.setSleeper(ms -> {}); + + assertThat(svc.start(TwBackfillService.Mode.NON_OVERRIDDEN).state()).isEqualTo("RUNNING"); + assertThat(inside.await(5, java.util.concurrent.TimeUnit.SECONDS)).isTrue(); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> svc.start(TwBackfillService.Mode.FORCE_PINNED)) + .isInstanceOf(org.springframework.web.server.ResponseStatusException.class).hasMessageContaining("409"); + release.countDown(); + for (int i = 0; i < 100 && "RUNNING".equals(svc.status().state()); i++) Thread.sleep(20); + assertThat(svc.status().state()).isEqualTo("DONE"); + } } diff --git a/catapult-api/src/test/resources/access-matrix.csv b/catapult-api/src/test/resources/access-matrix.csv index d8f579361..25ec22e70 100644 --- a/catapult-api/src/test/resources/access-matrix.csv +++ b/catapult-api/src/test/resources/access-matrix.csv @@ -326,6 +326,8 @@ POST,/api/internal/admin/tws,,"{""id"":""matrix"",""label"":""matrix""}",401,PAS POST,/api/internal/admin/tws/rebuild,,,401,PASS,403,403,403,PASS,401 POST,/api/internal/admin/tws/rebuild/force,,,401,PASS,403,403,403,PASS,401 POST,/api/internal/admin/tws/rebuild/force-pinned,,,401,PASS,403,403,403,PASS,401 +GET,/api/internal/admin/tws/rebuild/status,,,401,PASS,403,403,403,PASS,401 +POST,/api/internal/admin/tws/rebuild/cancel,,,401,PASS,403,403,403,PASS,401 DELETE,/api/internal/admin/tws/{id},,,401,PASS,403,403,403,PASS,401 GET,/api/internal/admin/tws/{id},,,401,PASS,403,403,403,PASS,401 # ERROR: the dead id is no TW, and the handler throws IllegalArgumentException for it (test artefact) diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/TwRebuildStatusDto.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/TwRebuildStatusDto.java new file mode 100644 index 000000000..465d8f20a --- /dev/null +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/TwRebuildStatusDto.java @@ -0,0 +1,20 @@ +package fr.enimaloc.catapult.common.dto.admin; + +import java.time.Instant; + +/** + * The TWs' recalculation, one at a time: {@code state} IDLE (none since the start), RUNNING, DONE or CANCELLED; + * {@code mode} BACKFILL (at start, the bindings without TWs), NON_OVERRIDDEN, UNTOUCHED or FORCE_PINNED; + * {@code processed}, {@code skipped} and {@code failed} out of {@code total} bindings; {@code waitingUntil} while it + * waits for the Steam store's rate limit to end. + */ +public record TwRebuildStatusDto(String state, String mode, long total, int processed, int skipped, int failed, + Instant startedAt, Instant finishedAt, Instant waitingUntil) { + + public static final TwRebuildStatusDto IDLE = new TwRebuildStatusDto("IDLE", null, 0, 0, 0, 0, null, null, null); + + /** The bindings gone through so far. */ + public long done() { + return (long) processed + skipped + failed; + } +} From 9f3102f92216bdc32813b307a09ddf2d656e0f8f Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 05:27:51 +0100 Subject: [PATCH 25/53] feat(web): show the TW recalculation's progress in the admin tab, and let it be stopped --- .../AdminTabsFragmentController.java | 1 + .../controller/AdminTwController.java | 7 +++ .../enimaloc/catapult/service/ApiService.java | 4 ++ .../catapult/service/mock/MockApiService.java | 14 ++++- .../catapult/service/mock/MockTwStore.java | 43 +++++++++++++- .../catapult/service/real/RealApiService.java | 10 ++++ .../main/resources/lang/messages.properties | 12 ++++ .../resources/lang/messages_en.properties | 12 ++++ .../resources/lang/messages_fr.properties | 12 ++++ .../src/main/resources/static/js/admin-tw.js | 57 +++++++++++++++++-- .../templates/pages/admin-tab/tw.html | 30 +++++++++- .../AdminTabsFragmentControllerTest.java | 23 ++++++++ .../controller/AdminTwControllerTest.java | 11 ++++ .../service/mock/MockTwStoreTest.java | 15 ++++- catapult-web/src/test/js/admin-tw.test.js | 57 ++++++++++++++++++- 15 files changed, 296 insertions(+), 12 deletions(-) diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java index 260249d92..3aacbce2f 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java @@ -392,6 +392,7 @@ String whitelist(Model model, HttpServletResponse response) { String tw(Model model, HttpServletResponse response) { return guarded(model, response, () -> { model.addAttribute("definitions", apiService.adminTws()); + model.addAttribute("rebuild", apiService.adminTwRebuildStatus()); return "pages/admin-tab/tw :: content"; }); } diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTwController.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTwController.java index 7e8fa4bbd..c0ab631d7 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTwController.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTwController.java @@ -93,6 +93,13 @@ public ResponseEntity testKeyword(@PathVariable String id, @Re return result == null ? ResponseEntity.status(502).build() : ResponseEntity.ok(result); } + /** Asks the running recalculation to stop after its current binding. */ + @PostMapping("/rebuild/cancel") + public ResponseEntity cancelRebuild() { + AdminAccess.require(filler); + return ResponseEntity.status(apiService.adminCancelTwRebuild()).build(); + } + @PostMapping("/rebuild/{mode}") public ResponseEntity rebuild(@PathVariable String mode) { AdminAccess.require(filler); diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java index e130a2bb2..276b4e13d 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java @@ -480,6 +480,10 @@ record MinecraftDeviceCodeStart(int status, MinecraftDeviceCodeDto code, ApiErro int adminCreateTw(String id, String label, int sortOrder); /** Starts a background rebuild of the mappings; catapult-api's status (202; 503 when trigger warnings are off), 502 when unreachable. */ int adminRebuildTws(TwRebuild mode); + /** The TWs' recalculation running or last run; null when catapult-api fails. */ + fr.enimaloc.catapult.common.dto.admin.TwRebuildStatusDto adminTwRebuildStatus(); + /** Asks the running recalculation to stop: catapult-api's status (200; 503 TWs disabled), 502 when unreachable. */ + int adminCancelTwRebuild(); /** A definition's Steam keywords, sorted; null when unknown or when catapult-api fails. */ List adminTwKeywords(String id); /** Adds a keyword (already there changes nothing); catapult-api's status (204), 502 when unreachable. */ diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockApiService.java index cf8b98a9f..25738cffd 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockApiService.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockApiService.java @@ -1688,7 +1688,19 @@ public int adminCreateTw(String id, String label, int sortOrder) { @Override public int adminRebuildTws(TwRebuild mode) { log.trace("adminRebuildTws({})", mode); - return tws.rebuild(); + return tws.rebuild(mode.name()); + } + + @Override + public fr.enimaloc.catapult.common.dto.admin.TwRebuildStatusDto adminTwRebuildStatus() { + log.trace("adminTwRebuildStatus()"); + return tws.rebuildStatus(); + } + + @Override + public int adminCancelTwRebuild() { + log.trace("adminCancelTwRebuild()"); + return tws.cancelRebuild(); } @Override diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockTwStore.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockTwStore.java index ea3889f78..2780d9a11 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockTwStore.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockTwStore.java @@ -1,8 +1,10 @@ package fr.enimaloc.catapult.service.mock; import fr.enimaloc.catapult.common.dto.admin.TwDefinitionDto; +import fr.enimaloc.catapult.common.dto.admin.TwRebuildStatusDto; import fr.enimaloc.catapult.common.dto.admin.TwKeywordTestDto; +import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -54,11 +56,48 @@ synchronized int create(String id, String label, int sortOrder) { return 201; } - /** The rebuild runs in the background in catapult-api: accepted, nothing to change here. */ - int rebuild() { + /** How many bindings the mock's recalculation goes through, three more each time it is read. */ + static final int REBUILD_TOTAL = 12; + private static final int REBUILD_STEP = 3; + private TwRebuildStatusDto rebuild = TwRebuildStatusDto.IDLE; + + /** Runs "in the background" as catapult-api does: 202, 409 while one runs already. */ + synchronized int rebuild(String mode) { + if ("RUNNING".equals(rebuild.state())) return 409; + rebuild = new TwRebuildStatusDto("RUNNING", mode, REBUILD_TOTAL, 0, 0, 0, Instant.now(), null, null); return 202; } + /** + * The recalculation, three bindings further each time it is read; halfway, it waits a minute for the Steam + * store's rate limit once, as catapult-api would show it. + */ + synchronized TwRebuildStatusDto rebuildStatus() { + if (!"RUNNING".equals(rebuild.state())) return rebuild; + TwRebuildStatusDto r = rebuild; + if (r.done() == REBUILD_TOTAL / 2 && r.waitingUntil() == null) { + rebuild = new TwRebuildStatusDto("RUNNING", r.mode(), r.total(), r.processed(), r.skipped(), r.failed(), + r.startedAt(), null, Instant.now().plusSeconds(60)); + return rebuild; + } + int processed = r.processed() + REBUILD_STEP - 1; + int skipped = r.skipped() + 1; + boolean done = processed + skipped >= REBUILD_TOTAL; + rebuild = new TwRebuildStatusDto(done ? "DONE" : "RUNNING", r.mode(), r.total(), + Math.min(processed, REBUILD_TOTAL - skipped), skipped, r.failed(), r.startedAt(), + done ? Instant.now() : null, null); + return rebuild; + } + + synchronized int cancelRebuild() { + if ("RUNNING".equals(rebuild.state())) { + TwRebuildStatusDto r = rebuild; + rebuild = new TwRebuildStatusDto("CANCELLED", r.mode(), r.total(), r.processed(), r.skipped(), r.failed(), + r.startedAt(), Instant.now(), null); + } + return 200; + } + /** A definition's keywords, sorted; null when the definition is unknown. */ synchronized List keywords(String id) { return find(id).isEmpty() ? null : List.copyOf(keywords.getOrDefault(id, new TreeSet<>())); diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java index 0fe7c015f..d4ba03db9 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java @@ -1330,6 +1330,16 @@ public int adminCreateTw(String id, String label, int sortOrder) { return client.call(HttpMethod.POST, "/api/internal/admin/tws", new AdminTwCreateBody(id, label, null, sortOrder), Void.class).status(); } + @Override + public fr.enimaloc.catapult.common.dto.admin.TwRebuildStatusDto adminTwRebuildStatus() { + return client.get("/api/internal/admin/tws/rebuild/status", fr.enimaloc.catapult.common.dto.admin.TwRebuildStatusDto.class); + } + + @Override + public int adminCancelTwRebuild() { + return client.call(HttpMethod.POST, "/api/internal/admin/tws/rebuild/cancel", null, Void.class).status(); + } + @Override public int adminRebuildTws(TwRebuild mode) { ApiClient.Result result = switch (mode) { diff --git a/catapult-web/src/main/resources/lang/messages.properties b/catapult-web/src/main/resources/lang/messages.properties index d036293cc..7bf90dfff 100644 --- a/catapult-web/src/main/resources/lang/messages.properties +++ b/catapult-web/src/main/resources/lang/messages.properties @@ -914,6 +914,18 @@ admin.tw.unavailable=The trigger warnings are unavailable right now. admin.tw.disabled=Trigger warnings are disabled (tw.enabled=false). admin.tw.created=Trigger warning added. admin.tw.rebuild-started=Rebuild started, it continues in the background. +admin.tw.rebuild.running=A TW recalculation is running already. +admin.tw.rebuild.state.RUNNING=Recalculating the TWs +admin.tw.rebuild.state.DONE=TW recalculation done +admin.tw.rebuild.state.CANCELLED=TW recalculation stopped +admin.tw.rebuild.mode.BACKFILL=at start, games without TWs +admin.tw.rebuild.mode.UNTOUCHED=games without TWs +admin.tw.rebuild.mode.NON_OVERRIDDEN=games their streamer didn't change +admin.tw.rebuild.mode.FORCE_PINNED=every game, the streamers' choices included +admin.tw.rebuild.progress={0} / {1} games · {2} recalculated, {3} skipped, {4} failed +admin.tw.rebuild.waiting=Steam is rate limiting: going on at {0}. +admin.tw.rebuild.finished=Finished on {0} +admin.tw.rebuild.cancel=Stop admin.tw.kw.title=Trigger warning — {0} admin.tw.kw.empty=No Steam keyword defined for this trigger warning. admin.tw.kw.add.label=New keyword diff --git a/catapult-web/src/main/resources/lang/messages_en.properties b/catapult-web/src/main/resources/lang/messages_en.properties index 497f01e99..9b40cfeba 100644 --- a/catapult-web/src/main/resources/lang/messages_en.properties +++ b/catapult-web/src/main/resources/lang/messages_en.properties @@ -914,6 +914,18 @@ admin.tw.unavailable=The trigger warnings are unavailable right now. admin.tw.disabled=Trigger warnings are disabled (tw.enabled=false). admin.tw.created=Trigger warning added. admin.tw.rebuild-started=Rebuild started, it continues in the background. +admin.tw.rebuild.running=A TW recalculation is running already. +admin.tw.rebuild.state.RUNNING=Recalculating the TWs +admin.tw.rebuild.state.DONE=TW recalculation done +admin.tw.rebuild.state.CANCELLED=TW recalculation stopped +admin.tw.rebuild.mode.BACKFILL=at start, games without TWs +admin.tw.rebuild.mode.UNTOUCHED=games without TWs +admin.tw.rebuild.mode.NON_OVERRIDDEN=games their streamer didn't change +admin.tw.rebuild.mode.FORCE_PINNED=every game, the streamers' choices included +admin.tw.rebuild.progress={0} / {1} games · {2} recalculated, {3} skipped, {4} failed +admin.tw.rebuild.waiting=Steam is rate limiting: going on at {0}. +admin.tw.rebuild.finished=Finished on {0} +admin.tw.rebuild.cancel=Stop admin.tw.kw.title=Trigger warning — {0} admin.tw.kw.empty=No Steam keyword defined for this trigger warning. admin.tw.kw.add.label=New keyword diff --git a/catapult-web/src/main/resources/lang/messages_fr.properties b/catapult-web/src/main/resources/lang/messages_fr.properties index 3318e9bb9..cb1c6dafb 100644 --- a/catapult-web/src/main/resources/lang/messages_fr.properties +++ b/catapult-web/src/main/resources/lang/messages_fr.properties @@ -914,6 +914,18 @@ admin.tw.unavailable=Les trigger warnings sont indisponibles pour le moment. admin.tw.disabled=Les trigger warnings sont désactivés (tw.enabled=false). admin.tw.created=Trigger warning ajouté. admin.tw.rebuild-started=Recalcul lancé, il se poursuit en arrière-plan. +admin.tw.rebuild.running=Un recalcul des TW est déjà en cours. +admin.tw.rebuild.state.RUNNING=Recalcul des TW en cours +admin.tw.rebuild.state.DONE=Recalcul des TW terminé +admin.tw.rebuild.state.CANCELLED=Recalcul des TW arrêté +admin.tw.rebuild.mode.BACKFILL=au démarrage, jeux sans TW +admin.tw.rebuild.mode.UNTOUCHED=jeux sans TW +admin.tw.rebuild.mode.NON_OVERRIDDEN=jeux non modifiés par leur streamer +admin.tw.rebuild.mode.FORCE_PINNED=tous les jeux, choix des streamers compris +admin.tw.rebuild.progress={0} / {1} jeux · {2} recalculés, {3} ignorés, {4} en échec +admin.tw.rebuild.waiting=Steam limite les requêtes : reprise à {0}. +admin.tw.rebuild.finished=Fini le {0} +admin.tw.rebuild.cancel=Arrêter admin.tw.kw.title=Trigger warning — {0} admin.tw.kw.empty=Aucun mot-clé Steam défini pour ce trigger warning. admin.tw.kw.add.label=Nouveau mot-clé diff --git a/catapult-web/src/main/resources/static/js/admin-tw.js b/catapult-web/src/main/resources/static/js/admin-tw.js index 9d5cc9e13..2b6c1a036 100644 --- a/catapult-web/src/main/resources/static/js/admin-tw.js +++ b/catapult-web/src/main/resources/static/js/admin-tw.js @@ -6,6 +6,9 @@ * by its status, never by catapult-api's message: 400 (invalid), 409 (id taken), 503 (trigger warnings * disabled), else unavailable. "Manage keywords" is a plain data-admin-open link handled by admin-tabs.js. * + * A recalculation started (or one already running: 409) shows its progress: the tab's card is patched every few + * seconds (admin-tabs.js) while it reads RUNNING and the tab is the one shown, and it can be asked to stop. + * * The keywords sub-view (#tw/{id}) adds and removes (confirmed) Steam keywords of 2 to 50 characters, the * keyword travelling in the JSON body, and tests the saved keywords plus the one being typed on a Steam app: * the answer is built with textContent only (the notes are data). Adding or removing one patches the list @@ -76,18 +79,55 @@ window.CatapultAdminTw = (function () { } } - async function rebuild(root, button) { + async function rebuild(panel, root, button) { button.disabled = true; // before the confirmation: a second click must not open another try { if (!await CatapultAdminTabs.confirmed(root, button.dataset.confirm)) return; const status = await post(button, `/admin/tw/rebuild/${button.dataset.rebuild}`, {}); if (ok(status)) window.mdui?.snackbar?.({ message: root.dataset.labelRebuildStarted }); - else CatapultChannel.showError(refusal(root, status)); + else CatapultChannel.showError(status === 409 ? root.dataset.labelRebuildRunning : refusal(root, status)); + // started, or one runs already: its progress is shown + if (ok(status) || status === 409) { + await CatapultAdminTabs.patch(panel); + follow(panel); + } } finally { button.disabled = false; } } + async function cancelRebuild(panel, root, button) { + const status = await post(button, "/admin/tw/rebuild/cancel", {}); + if (!ok(status)) { + CatapultChannel.showError(refusal(root, status)); + return; + } + await CatapultAdminTabs.patch(panel); + } + + // --- following a recalculation ------------------------------------------------------------- + + const FOLLOW_MS = 3000; + let followTimer = null; + + /** Whether the list shown reads a running recalculation, in the tab shown. */ + function following(panel) { + const progress = panel.querySelector('.admin-tw [data-admin-value="tw-rebuild"]'); + return progress?.dataset.state === "RUNNING" && document.getElementById("admin-tabs")?.value === "tw"; + } + + /** Patches the list every FOLLOW_MS while it reads a running recalculation; stops by itself otherwise. */ + function follow(panel) { + clearTimeout(followTimer); + followTimer = null; + if (!following(panel)) return; + followTimer = setTimeout(async () => { + if (!panel.isConnected || !following(panel)) return; + await CatapultAdminTabs.patch(panel); + follow(panel); + }, FOLLOW_MS); + } + // --- the keywords sub-view --------------------------------------------------------------- const KEYWORD_MIN = 2; @@ -180,7 +220,7 @@ window.CatapultAdminTw = (function () { if (!panel) return; panel.addEventListener("click", event => { - const target = event.target.closest?.(".admin-tw-add-save, .admin-tw-rebuild," + const target = event.target.closest?.(".admin-tw-add-save, .admin-tw-rebuild, .admin-tw-rebuild-cancel," + " .admin-tw-keyword-add, .admin-tw-keyword-remove, .admin-tw-test-run"); if (!target || !panel.contains(target) || target.disabled) return; const keywordsRoot = target.closest(".admin-tw-kw"); @@ -193,12 +233,21 @@ window.CatapultAdminTw = (function () { const root = panel.querySelector(".admin-tw"); if (!root) return; if (target.matches(".admin-tw-add-save")) addDefinition(panel, root, target); - else rebuild(root, target); + else if (target.matches(".admin-tw-rebuild-cancel")) cancelRebuild(panel, root, target); + else rebuild(panel, root, target); }); panel.addEventListener("input", event => event.target.removeAttribute?.("invalid")); + // Back on the tab: a recalculation running is followed again (left, it stopped being) + document.getElementById("admin-tabs")?.addEventListener("change", event => { + if (event.target.id === "admin-tabs") follow(panel); + }); } document.addEventListener("catapult:render", attach); + // The list shown (the tab, back from a sub-view): a recalculation running is followed + document.addEventListener("catapult:admin-panel", event => { + if (event.detail?.tab === "tw") follow(event.detail.panel); + }); return { attach }; })(); diff --git a/catapult-web/src/main/resources/templates/pages/admin-tab/tw.html b/catapult-web/src/main/resources/templates/pages/admin-tab/tw.html index 3bec1ede2..476ae48bd 100644 --- a/catapult-web/src/main/resources/templates/pages/admin-tab/tw.html +++ b/catapult-web/src/main/resources/templates/pages/admin-tab/tw.html @@ -1,4 +1,5 @@ + +
    + +

    + Running + mode +

    + +

    0 / 0

    +

    waiting for Steam

    +

    finished

    +
    + Stop +
    +
    +
    +

    Add a trigger warning

    diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTabsFragmentControllerTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTabsFragmentControllerTest.java index fa7c5655a..a2bbc2f96 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTabsFragmentControllerTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTabsFragmentControllerTest.java @@ -995,6 +995,29 @@ void twFragment_listsDefinitions_andManageKeywordsLinks() throws Exception { assertThat(html.split("admin-tw-danger", -1)).hasSize(2); } + @Test + void twFragment_showsTheRecalculation_itsProgress_andItsStop() throws Exception { + when(apiService.adminTws()).thenReturn(List.of()); + when(apiService.adminTwRebuildStatus()).thenReturn(new fr.enimaloc.catapult.common.dto.admin.TwRebuildStatusDto( + "RUNNING", "NON_OVERRIDDEN", 40, 10, 3, 1, java.time.Instant.now(), null, + java.time.Instant.parse("2026-11-12T10:05:00Z"))); + + String html = asAdmin("/spa/admin/tw"); + + assertThat(html).contains("data-state=\"RUNNING\"", "value=\"14\"", "max=\"40\"", + "14 / 40 games · 10 recalculated, 3 skipped, 1 failed", "Steam is rate limiting: going on at", + "admin-tw-rebuild-cancel", "games their streamer didn't change"); + } + + @Test + void twFragment_beforeAnyRecalculation_showsNoProgress() throws Exception { + when(apiService.adminTws()).thenReturn(List.of()); + when(apiService.adminTwRebuildStatus()).thenReturn(fr.enimaloc.catapult.common.dto.admin.TwRebuildStatusDto.IDLE); + + assertThat(asAdmin("/spa/admin/tw")).contains("data-state=\"IDLE\"").doesNotContain("admin-tw-rebuild-cancel", + "mdui-linear-progress"); + } + @Test void twFragment_unreachableApi_showsThePlaceholder() throws Exception { when(apiService.adminTws()).thenReturn(null); diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTwControllerTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTwControllerTest.java index a676d5f6a..f97483e54 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTwControllerTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminTwControllerTest.java @@ -168,4 +168,15 @@ void test_relaysTheResult_or502_andChecksTheAppIdAndTheDraft() throws Exception } verify(apiService, org.mockito.Mockito.times(2)).adminTestTwKeyword(any(), any(), any()); } + + @Test + void cancelRebuild_relaysTheStatus_andIsNoMode() throws Exception { + when(apiService.isAdmin()).thenReturn(true); + when(apiService.adminCancelTwRebuild()).thenReturn(200); + + mvc.perform(admin(post(BASE + "/rebuild/cancel"), "{}")).andExpect(status().isOk()); + + org.mockito.Mockito.verify(apiService).adminCancelTwRebuild(); + org.mockito.Mockito.verify(apiService, org.mockito.Mockito.never()).adminRebuildTws(org.mockito.ArgumentMatchers.any()); + } } diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockTwStoreTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockTwStoreTest.java index 298ea7ac2..1b395d07c 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockTwStoreTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockTwStoreTest.java @@ -29,8 +29,19 @@ void create_refusesABadIdOrLabel_andADuplicate() { } @Test - void rebuild_isAccepted() { - assertThat(store.rebuild()).isEqualTo(202); + void rebuild_isAccepted_onceAtATime_andMovesOnEachTimeItIsRead() { + assertThat(store.rebuildStatus().state()).isEqualTo("IDLE"); + assertThat(store.rebuild("NON_OVERRIDDEN")).isEqualTo(202); + assertThat(store.rebuild("UNTOUCHED")).isEqualTo(409); + + var status = store.rebuildStatus(); + for (int i = 0; i < 20 && "RUNNING".equals(status.state()); i++) status = store.rebuildStatus(); + assertThat(status.state()).isEqualTo("DONE"); + assertThat(status.done()).isEqualTo(MockTwStore.REBUILD_TOTAL); + + store.rebuild("FORCE_PINNED"); + assertThat(store.cancelRebuild()).isEqualTo(200); + assertThat(store.rebuildStatus().state()).isEqualTo("CANCELLED"); } @Test diff --git a/catapult-web/src/test/js/admin-tw.test.js b/catapult-web/src/test/js/admin-tw.test.js index b91711d3f..a03096fa8 100644 --- a/catapult-web/src/test/js/admin-tw.test.js +++ b/catapult-web/src/test/js/admin-tw.test.js @@ -16,7 +16,11 @@ function page() {
    + data-label-unavailable="Down" data-label-created="Created" data-label-rebuild-started="Started" + data-label-rebuild-running="Already running"> +
    + +
    @@ -45,6 +49,7 @@ beforeEach(async () => { window.CatapultCsrf = { postJson: posted }; window.mdui = { confirm: vi.fn(() => (confirmAnswer ? Promise.resolve() : Promise.reject())), snackbar: vi.fn() }; await load("admin-tabs", "admin-tw"); + document.getElementById("admin-tabs")?.remove(); window.CatapultChannel = { showError: vi.fn() }; window.CatapultAdminTabs.patch = vi.fn(async () => {}); page(); @@ -65,7 +70,55 @@ describe("admin-tw", () => { expect(rebuildButton(mode).disabled).toBe(false); } expect(posted).toHaveBeenCalledTimes(3); - expect(CatapultAdminTabs.patch).not.toHaveBeenCalled(); + // each one's progress shown + expect(CatapultAdminTabs.patch).toHaveBeenCalledTimes(3); + }); + + it("tells a recalculation already running, and shows it", async () => { + posted.mockResolvedValueOnce(response(409)); + await click('[data-rebuild="untouched"]'); + + expect(CatapultChannel.showError).toHaveBeenLastCalledWith("Already running"); + expect(CatapultAdminTabs.patch).toHaveBeenCalledTimes(1); + expect(window.mdui.snackbar).not.toHaveBeenCalled(); + }); + + it("follows a running recalculation while its tab is shown, and stops once it ends", async () => { + vi.useFakeTimers(); + try { + const tabs = document.createElement("div"); + tabs.id = "admin-tabs"; + tabs.value = "tw"; + document.body.prepend(tabs); + const progress = document.querySelector('[data-admin-value="tw-rebuild"]'); + let reads = 0; + CatapultAdminTabs.patch = vi.fn(async () => { + reads++; + progress.dataset.state = reads < 3 ? "RUNNING" : "DONE"; + }); + + document.querySelector('[data-rebuild="untouched"]').click(); + await vi.advanceTimersByTimeAsync(0); + expect(CatapultAdminTabs.patch).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(3000); + expect(CatapultAdminTabs.patch).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(3000); + expect(CatapultAdminTabs.patch).toHaveBeenCalledTimes(3); + // Done: no more + await vi.advanceTimersByTimeAsync(9000); + expect(CatapultAdminTabs.patch).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + it("asks a running recalculation to stop, then shows it", async () => { + posted.mockResolvedValueOnce(response(200)); + await click(".admin-tw-rebuild-cancel"); + + expect(posted).toHaveBeenLastCalledWith("/admin/tw/rebuild/cancel", {}); + expect(CatapultAdminTabs.patch).toHaveBeenCalledTimes(1); }); it("does not rebuild when the confirmation is declined", async () => { From e13f736fd1e1e4ba4055614e67f8fb1118ef9dea Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 05:34:05 +0100 Subject: [PATCH 26/53] refactor(api): inject the binding service's event publisher through its constructor --- .../catapult/service/binding/BindingService.java | 13 ++++--------- .../event/listener/CustomBindingDetectionTest.java | 2 +- .../binding/AdminGameMappingServiceTest.java | 2 +- .../service/binding/BindingServiceEditsTest.java | 2 +- .../service/binding/BindingServiceMappingTest.java | 2 +- .../binding/BindingServiceSteamLimitTest.java | 3 +-- .../service/binding/BindingServiceTest.java | 2 +- .../catapult/service/category/ChannelFixture.java | 2 +- .../service/debug/DebugAttributionTest.java | 2 +- 9 files changed, 12 insertions(+), 18 deletions(-) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java index 8fafd4d1a..45340b223 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java @@ -18,9 +18,7 @@ import fr.enimaloc.catapult.service.tw.TwResolverService; import fr.enimaloc.catapult.service.twitch.TwitchService; import lombok.RequiredArgsConstructor; -import lombok.Setter; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -48,9 +46,8 @@ public class BindingService { private final GameBindingLookup gameBindingLookup; private final CategoryChain categoryChain; private final ExtensionLayoutApplier extensionLayoutApplier; - /** Tells a binding whose Steam signals were missed (the store rate limited); null outside Spring. */ - @Setter(onMethod_ = @Autowired) - private ApplicationEventPublisher events; + /** Tells a binding whose Steam signals were missed (the store rate limited). */ + private final ApplicationEventPublisher events; /** * Applies {@code change} to the user's binding (if it exists), saves it, and applies it when it is the game @@ -162,7 +159,7 @@ private GameBinding updateWithIgdbResolution(UserAccount user, DetectedGame dete * them: not the streamer's, nor an admin's) miss Steam's signals, and are resolved again once it may be called. */ private GameBinding missedSteam(GameBinding saved, boolean adminTws) { - if (events == null || adminTws || saved.isTwOverride() + if (adminTws || saved.isTwOverride() || saved.getSourceType() != GameBinding.SourceType.STEAM || saved.getId() == null) { return saved; } @@ -192,9 +189,7 @@ public Optional retryTwsAfterSteamLimit(UUID bindingId) { if (igdbId.isEmpty()) return Optional.empty(); TwResolverService.Suggestion suggestion = twResolverService.suggestReporting(twInput(igdbId.get(), detectedOf(binding))); if (suggestion.steamLimitedUntil().isPresent()) { - if (events != null) { - events.publishEvent(new TwsMissedSteamEvent(bindingId, suggestion.steamLimitedUntil().get())); - } + events.publishEvent(new TwsMissedSteamEvent(bindingId, suggestion.steamLimitedUntil().get())); return Optional.empty(); } if (binding.getTws().equals(suggestion.tws())) return Optional.empty(); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/event/listener/CustomBindingDetectionTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/event/listener/CustomBindingDetectionTest.java index 42efed344..3f701ec9a 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/event/listener/CustomBindingDetectionTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/event/listener/CustomBindingDetectionTest.java @@ -302,7 +302,7 @@ void editingAGameBinding_duringActivation_isSavedNotApplied() { BindingService realBindings = new BindingService(gameBindings, mock(IgdbService.class), mock(IgdbGameDetailsService.class), twitchService, mock(TwResolverService.class), mock(GameMappingRepository.class), new GameBindingLookup(gameBindings), realChain, - mock(ExtensionLayoutApplier.class)); + mock(ExtensionLayoutApplier.class), org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class)); realBindings.setTwitchGame(user, gameBinding.getId(), "1234", "Other"); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/AdminGameMappingServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/AdminGameMappingServiceTest.java index 1ba517c65..5a8cfd276 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/AdminGameMappingServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/AdminGameMappingServiceTest.java @@ -88,7 +88,7 @@ void setUp() { chain = spy(new CategoryChain(activations, streamStateService, userSettingsRepository, raidRestoreHold, gameStateService, mock(UserAccountRepository.class), new CategoryApplier(twitchService, streamStateService), mock(RaidRestoreGateService.class), mock(GameBindingLookup.class), mock(ExtensionLayoutApplier.class))); bindingService = spy(new BindingService(bindingRepo, igdbService, igdbGameDetailsService, twitchService, - twResolverService, mappingRepo, new GameBindingLookup(bindingRepo), chain, mock(ExtensionLayoutApplier.class))); + twResolverService, mappingRepo, new GameBindingLookup(bindingRepo), chain, mock(ExtensionLayoutApplier.class), org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class))); service = new AdminGameMappingService(mappingRepo, bindingRepo, userRepo, bindingService, gameStateService, streamStateService, channelEventPublisher, chain, transactionManager); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceEditsTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceEditsTest.java index f180b46e8..b40a02a20 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceEditsTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceEditsTest.java @@ -55,7 +55,7 @@ class BindingServiceEditsTest { private final BindingService service = new BindingService(bindings, igdb, igdbDetails, twitch, twResolver, mappings, new GameBindingLookup(bindings), new CategoryChain(mock(CustomBindingActivations.class), streamState, mock(UserSettingsRepository.class), new RaidRestoreHold(), - gameState, mock(UserAccountRepository.class), new CategoryApplier(twitch, streamState), mock(RaidRestoreGateService.class), mock(GameBindingLookup.class), mock(ExtensionLayoutApplier.class)), layouts); + gameState, mock(UserAccountRepository.class), new CategoryApplier(twitch, streamState), mock(RaidRestoreGateService.class), mock(GameBindingLookup.class), mock(ExtensionLayoutApplier.class)), layouts, org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class)); private UserAccount user; private GameBinding binding; diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceMappingTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceMappingTest.java index 45ed8b0a8..5e6aabdb2 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceMappingTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceMappingTest.java @@ -51,7 +51,7 @@ class BindingServiceMappingTest { new BindingService(bindings, igdb, igdbDetails, twitch, twResolver, mappings, new GameBindingLookup(bindings), new CategoryChain(mock(CustomBindingActivations.class), mock(StreamStateService.class), mock(UserSettingsRepository.class), new RaidRestoreHold(), - gameState, mock(UserAccountRepository.class), new CategoryApplier(twitch, mock(StreamStateService.class)), mock(RaidRestoreGateService.class), mock(GameBindingLookup.class), mock(ExtensionLayoutApplier.class)), mock(ExtensionLayoutApplier.class)); + gameState, mock(UserAccountRepository.class), new CategoryApplier(twitch, mock(StreamStateService.class)), mock(RaidRestoreGateService.class), mock(GameBindingLookup.class), mock(ExtensionLayoutApplier.class)), mock(ExtensionLayoutApplier.class), org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class)); private final DetectedGame cs2 = new DetectedGame("730", GameBinding.SourceType.STEAM, "Counter-Strike 2"); private UserAccount user; diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceSteamLimitTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceSteamLimitTest.java index d52f3057a..291851d7f 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceSteamLimitTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceSteamLimitTest.java @@ -50,14 +50,13 @@ class BindingServiceSteamLimitTest { mock(StreamStateService.class), mock(UserSettingsRepository.class), new RaidRestoreHold(), mock(GameStateService.class), mock(UserAccountRepository.class), new CategoryApplier(twitch, mock(StreamStateService.class)), mock(RaidRestoreGateService.class), - mock(GameBindingLookup.class), mock(ExtensionLayoutApplier.class)), mock(ExtensionLayoutApplier.class)); + mock(GameBindingLookup.class), mock(ExtensionLayoutApplier.class)), mock(ExtensionLayoutApplier.class), events); private final UserAccount user = new UserAccount(); private final DetectedGame steamGame = new DetectedGame("730", GameBinding.SourceType.STEAM, "Counter-Strike 2"); @BeforeEach void setUp() { - service.setEvents(events); user.setId(UUID.randomUUID()); when(bindings.save(any())).thenAnswer(call -> { GameBinding b = call.getArgument(0); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceTest.java index e74559ede..f05e71b64 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceTest.java @@ -60,7 +60,7 @@ void setup() { bindingService = new BindingService(gameBindingRepository, igdbService, igdbGameDetailsService, twitchService, twResolverService, gameMappingRepository, new GameBindingLookup(gameBindingRepository), new CategoryChain(mock(CustomBindingActivations.class), streamState, mock(UserSettingsRepository.class), new RaidRestoreHold(), gameStateService, - mock(UserAccountRepository.class), new CategoryApplier(twitchService, streamState), mock(RaidRestoreGateService.class), mock(GameBindingLookup.class), mock(ExtensionLayoutApplier.class)), mock(ExtensionLayoutApplier.class)); + mock(UserAccountRepository.class), new CategoryApplier(twitchService, streamState), mock(RaidRestoreGateService.class), mock(GameBindingLookup.class), mock(ExtensionLayoutApplier.class)), mock(ExtensionLayoutApplier.class), org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class)); user = new UserAccount(); bindingId = UUID.randomUUID(); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/category/ChannelFixture.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/category/ChannelFixture.java index ddf61b4c6..28833a807 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/category/ChannelFixture.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/category/ChannelFixture.java @@ -232,7 +232,7 @@ public ChannelFixture() { new CategoryApplier(twitch, streamStateService), new RaidRestoreGateService(experimentService), lookup, mock(ExtensionLayoutApplier.class)); bindingService = new BindingService(bindingRepository, igdbService, mock(IgdbGameDetailsService.class), twitch, mock(TwResolverService.class), mappingRepository, lookup, categoryChain, - mock(ExtensionLayoutApplier.class)); + mock(ExtensionLayoutApplier.class), org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class)); raidRestoreGameService = new RaidRestoreGameService(categoryChain, new CategoryApplier(twitch, streamStateService), raidRestoreHold); listener = new GameEventListener(bindingService, streamStateService, publisher, diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/debug/DebugAttributionTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/debug/DebugAttributionTest.java index 92ff12aa4..2c3204859 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/debug/DebugAttributionTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/debug/DebugAttributionTest.java @@ -315,7 +315,7 @@ void incompleteBindingRefresh_attributesEachBindingToItsOwner() { }); BindingService service = new BindingService(bindings, mock(IgdbService.class), mock(IgdbGameDetailsService.class), mock(TwitchService.class), mock(TwResolverService.class), - mappings, new GameBindingLookup(bindings), mock(CategoryChain.class), mock(ExtensionLayoutApplier.class)); + mappings, new GameBindingLookup(bindings), mock(CategoryChain.class), mock(ExtensionLayoutApplier.class), org.mockito.Mockito.mock(org.springframework.context.ApplicationEventPublisher.class)); service.refreshIncompleteBindings(); From 859daae63d3845bf76d6f53edb8abab747231ac3 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 05:45:44 +0100 Subject: [PATCH 27/53] fix(api): decide the title help's parts in a service, not in the controller --- .../catapult/api/ApiHelpController.java | 29 +++++-------- .../service/title/TitleHelpSections.java | 34 +++++++++++++++ .../catapult/api/ApiHelpControllerTest.java | 28 ++++--------- .../service/title/TitleHelpSectionsTest.java | 42 +++++++++++++++++++ 4 files changed, 94 insertions(+), 39 deletions(-) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/service/title/TitleHelpSections.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/service/title/TitleHelpSectionsTest.java diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/api/ApiHelpController.java b/catapult-api/src/main/java/fr/enimaloc/catapult/api/ApiHelpController.java index 05174aa84..f2aa0856e 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/api/ApiHelpController.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/api/ApiHelpController.java @@ -1,11 +1,8 @@ package fr.enimaloc.catapult.api; import fr.enimaloc.catapult.common.dto.HelpContent; -import fr.enimaloc.catapult.domain.account.UserAccount; -import fr.enimaloc.catapult.repository.account.UserAccountRepository; -import fr.enimaloc.catapult.service.drops.DropsGate; import fr.enimaloc.catapult.service.minecraft.MinecraftGateService; -import fr.enimaloc.catapult.service.title.StreamTitleService; +import fr.enimaloc.catapult.service.title.TitleHelpSections; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.MessageSource; @@ -17,7 +14,6 @@ import org.springframework.web.bind.annotation.RestController; import java.util.Locale; -import java.util.Optional; import java.util.UUID; @RestController @@ -27,9 +23,7 @@ public class ApiHelpController { private final MessageSource messageSource; private final MinecraftGateService gateService; - private final StreamTitleService streamTitleService; - private final DropsGate dropsGate; - private final UserAccountRepository userAccountRepository; + private final TitleHelpSections titleHelpSections; @Value("${app.account.deletion-delay-days:7}") private int accountDeletionDelayDays; @@ -58,24 +52,23 @@ public HelpContent help(@PathVariable String cardId, Locale locale, } } if ("title-settings".equals(cardId)) { - // Read-only, as the title fields offered them (the settings page enrols): the drops need the conditions - Optional account = account(jwt); - if (account.map(streamTitleService::conditionsAvailableReadOnly).orElse(false)) { + TitleHelpSections.Offered offered = titleSections(jwt); + if (offered.conditions()) { body += messageSource.getMessage("help.title-settings.body.conditions", args, "", locale); - if (dropsGate.isAvailableReadOnly(account.get())) { - body += messageSource.getMessage("help.title-settings.body.drops", args, "", locale); - } + } + if (offered.drops()) { + body += messageSource.getMessage("help.title-settings.body.drops", args, "", locale); } } return new HelpContent(title, body); } - private Optional account(Jwt jwt) { - if (jwt == null) return Optional.empty(); + private TitleHelpSections.Offered titleSections(Jwt jwt) { + if (jwt == null) return TitleHelpSections.Offered.NONE; try { - return userAccountRepository.findById(UUID.fromString(jwt.getSubject())); + return titleHelpSections.forAccount(UUID.fromString(jwt.getSubject())); } catch (IllegalArgumentException e) { - return Optional.empty(); + return TitleHelpSections.Offered.NONE; } } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/title/TitleHelpSections.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/title/TitleHelpSections.java new file mode 100644 index 000000000..d944db360 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/title/TitleHelpSections.java @@ -0,0 +1,34 @@ +package fr.enimaloc.catapult.service.title; + +import fr.enimaloc.catapult.repository.account.UserAccountRepository; +import fr.enimaloc.catapult.service.drops.DropsGate; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +/** + * Which parts of the title's help an account is offered, as its title fields are: the conditions (experiment + * stream.title.conditions), and the drops with them (experiment drops). Read-only: the settings page enrols. + */ +@Service +@RequiredArgsConstructor +public class TitleHelpSections { + + /** The help's parts offered; the drops never without the conditions, as the title reads them. */ + public record Offered(boolean conditions, boolean drops) { + public static final Offered NONE = new Offered(false, false); + } + + private final UserAccountRepository accounts; + private final StreamTitleService streamTitleService; + private final DropsGate dropsGate; + + /** The parts offered to the account {@code accountId}; none for an unknown one. */ + public Offered forAccount(UUID accountId) { + return accounts.findById(accountId).map(account -> { + boolean conditions = streamTitleService.conditionsAvailableReadOnly(account); + return new Offered(conditions, conditions && dropsGate.isAvailableReadOnly(account)); + }).orElse(Offered.NONE); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/ApiHelpControllerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/ApiHelpControllerTest.java index da31dd5ad..c2d7fb03b 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/ApiHelpControllerTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/ApiHelpControllerTest.java @@ -1,10 +1,7 @@ package fr.enimaloc.catapult.api; -import fr.enimaloc.catapult.domain.account.UserAccount; -import fr.enimaloc.catapult.repository.account.UserAccountRepository; -import fr.enimaloc.catapult.service.drops.DropsGate; import fr.enimaloc.catapult.service.minecraft.MinecraftGateService; -import fr.enimaloc.catapult.service.title.StreamTitleService; +import fr.enimaloc.catapult.service.title.TitleHelpSections; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -17,7 +14,6 @@ import org.springframework.security.oauth2.jwt.Jwt; import java.util.Locale; -import java.util.Optional; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -31,9 +27,7 @@ class ApiHelpControllerTest { @Mock private MessageSource messageSource; @Mock private MinecraftGateService gateService; - @Mock private StreamTitleService streamTitleService; - @Mock private DropsGate dropsGate; - @Mock private UserAccountRepository userAccountRepository; + @Mock private TitleHelpSections titleHelpSections; @Mock private Jwt jwt; @InjectMocks private ApiHelpController controller; @@ -137,10 +131,7 @@ void connections_xboxDisabled_omitsXboxSection() { @Test void titleSettings_conditionsAndDrops_appendsBothSections() { - UserAccount user = new UserAccount(); - when(userAccountRepository.findById(userId)).thenReturn(Optional.of(user)); - when(streamTitleService.conditionsAvailableReadOnly(user)).thenReturn(true); - when(dropsGate.isAvailableReadOnly(user)).thenReturn(true); + when(titleHelpSections.forAccount(userId)).thenReturn(new TitleHelpSections.Offered(true, true)); var content = controller.help("title-settings", locale, jwt); @@ -149,10 +140,7 @@ void titleSettings_conditionsAndDrops_appendsBothSections() { @Test void titleSettings_conditionsWithoutDrops_appendsConditionsOnly() { - UserAccount user = new UserAccount(); - when(userAccountRepository.findById(userId)).thenReturn(Optional.of(user)); - when(streamTitleService.conditionsAvailableReadOnly(user)).thenReturn(true); - when(dropsGate.isAvailableReadOnly(user)).thenReturn(false); + when(titleHelpSections.forAccount(userId)).thenReturn(new TitleHelpSections.Offered(true, false)); var content = controller.help("title-settings", locale, jwt); @@ -160,11 +148,8 @@ void titleSettings_conditionsWithoutDrops_appendsConditionsOnly() { } @Test - void titleSettings_dropsWithoutConditions_omitsBoth() { - UserAccount user = new UserAccount(); - when(userAccountRepository.findById(userId)).thenReturn(Optional.of(user)); - when(streamTitleService.conditionsAvailableReadOnly(user)).thenReturn(false); - when(dropsGate.isAvailableReadOnly(user)).thenReturn(true); + void titleSettings_noneOffered_omitsBoth() { + when(titleHelpSections.forAccount(userId)).thenReturn(TitleHelpSections.Offered.NONE); var content = controller.help("title-settings", locale, jwt); @@ -176,5 +161,6 @@ void titleSettings_anonymous_omitsBoth() { var content = controller.help("title-settings", locale, null); assertThat(content.body()).isEqualTo("

    titre

    "); + org.mockito.Mockito.verifyNoInteractions(titleHelpSections); } } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/title/TitleHelpSectionsTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/title/TitleHelpSectionsTest.java new file mode 100644 index 000000000..eb88abd95 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/title/TitleHelpSectionsTest.java @@ -0,0 +1,42 @@ +package fr.enimaloc.catapult.service.title; + +import fr.enimaloc.catapult.domain.account.UserAccount; +import fr.enimaloc.catapult.repository.account.UserAccountRepository; +import fr.enimaloc.catapult.service.drops.DropsGate; +import org.junit.jupiter.api.Test; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** The title help's parts an account is offered: the drops only with the conditions, nothing for an unknown one. */ +class TitleHelpSectionsTest { + + private final UserAccountRepository accounts = mock(UserAccountRepository.class); + private final StreamTitleService streamTitle = mock(StreamTitleService.class); + private final DropsGate drops = mock(DropsGate.class); + private final TitleHelpSections sections = new TitleHelpSections(accounts, streamTitle, drops); + private final UUID id = UUID.randomUUID(); + private final UserAccount account = new UserAccount(); + + @Test + void theDrops_needTheConditions() { + when(accounts.findById(id)).thenReturn(Optional.of(account)); + when(drops.isAvailableReadOnly(account)).thenReturn(true); + + when(streamTitle.conditionsAvailableReadOnly(account)).thenReturn(true); + assertThat(sections.forAccount(id)).isEqualTo(new TitleHelpSections.Offered(true, true)); + when(streamTitle.conditionsAvailableReadOnly(account)).thenReturn(false); + assertThat(sections.forAccount(id)).isEqualTo(TitleHelpSections.Offered.NONE); + } + + @Test + void anUnknownAccount_isOfferedNothing() { + when(accounts.findById(id)).thenReturn(Optional.empty()); + + assertThat(sections.forAccount(id)).isEqualTo(TitleHelpSections.Offered.NONE); + } +} From 025d80460d19cf16007016f29a422e33f0fd934a Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 06:09:02 +0100 Subject: [PATCH 28/53] fix(web): write the DTDD caches' API paths in their calls --- .../catapult/service/real/RealApiService.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java index 600c5dc6c..d222589f4 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java @@ -1005,21 +1005,22 @@ public DtddCachesSummaryDto adminDtddCaches() { @Override public AdminPage adminDtddTopicsCache(String q, int page) { - return client.get(DTDD_CACHE_PAGE, DTDD_TOPICS, "topics", q == null ? "" : q, page); + return client.get("/api/internal/admin/dtdd-caches/{kind}?q={q}&page={page}&size=20", DTDD_TOPICS, "topics", + q == null ? "" : q, page); } @Override public AdminPage adminDtddGamesCache(String q, int page) { - return client.get(DTDD_CACHE_PAGE, DTDD_GAMES, "games", q == null ? "" : q, page); + return client.get("/api/internal/admin/dtdd-caches/{kind}?q={q}&page={page}&size=20", DTDD_GAMES, "games", + q == null ? "" : q, page); } @Override public AdminPage adminDtddSearchesCache(String q, int page) { - return client.get(DTDD_CACHE_PAGE, DTDD_SEARCHES, "searches", q == null ? "" : q, page); + return client.get("/api/internal/admin/dtdd-caches/{kind}?q={q}&page={page}&size=20", DTDD_SEARCHES, "searches", + q == null ? "" : q, page); } - private static final String DTDD_CACHE_PAGE = "/api/internal/admin/dtdd-caches/{kind}?q={q}&page={page}&size=20"; - @Override public int adminRefetchDtddCache(String kind, String key) { return client.call(HttpMethod.POST, "/api/internal/admin/dtdd-caches/{kind}/refetch?key={key}", null, From bc3ce92387c6c91598ee48c812d01ba9a86cd91c Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 06:09:02 +0100 Subject: [PATCH 29/53] test(web): cover the DTDD caches calls of the real and mock API services --- .../service/mock/MockApiServiceTest.java | 12 ++++++ .../service/real/RealApiServiceTest.java | 43 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java index 49160e785..6321025b9 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java @@ -1271,4 +1271,16 @@ void apiToken_isShownOnceAtGeneration_andReplacedOrRevoked() { assertThat(service.revokeApiToken()).isEqualTo(204); assertThat(service.apiTokenStatus().exists()).isFalse(); } + + @Test + void dtddCaches_areTheMocksStore() { + assertThat(service.adminDtddCaches().topics()).isEqualTo(4); + assertThat(service.adminDtddTopicsCache("", 0).content()).isNotEmpty(); + assertThat(service.adminDtddGamesCache("", 0).content()).isNotEmpty(); + assertThat(service.adminDtddSearchesCache("", 0).content()).isNotEmpty(); + assertThat(service.adminRefetchDtddCache("topics", "16758")).isEqualTo(200); + assertThat(service.adminDtddCacheEntry("topics", "16758").dtddId()).isEqualTo(16758L); + assertThat(service.adminStartDtddRefetch().status()).isEqualTo(202); + assertThat(service.adminCancelDtddRefetch().body().state()).isEqualTo("CANCELLED"); + } } diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java index b686fc53e..c30b38636 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java @@ -1061,6 +1061,49 @@ void adminCacheEntries_sendsABlankSearchWhenThereIsNone() { assertThat(service.adminCacheEntries("igdb-game-cache", null, 0).content()).isEmpty(); } + @Test + void adminDtddCaches_readTheSummaryAndEachCachePage() { + respond(HttpMethod.GET, "/api/internal/admin/dtdd-caches", + "{\"topics\":4,\"topicsStale\":1,\"games\":3,\"gamesStale\":0,\"searches\":2,\"searchesStale\":2," + + "\"topicsTtlHours\":168,\"gamesTtlHours\":4320,\"searchesTtlHours\":720,\"quotas\":[]," + + "\"monthlyReserve\":500,\"job\":{\"state\":\"IDLE\",\"total\":0,\"done\":0,\"failed\":0}}"); + respond(HttpMethod.GET, "/api/internal/admin/dtdd-caches/topics?q=halo&page=1&size=20", + "{\"content\":[{\"dtddId\":7,\"name\":\"Halo\",\"yesTopics\":[\"gore\"],\"noTopics\":[]," + + "\"mostlyTopics\":[],\"stale\":true}],\"number\":1,\"totalPages\":2,\"totalElements\":21}"); + respond(HttpMethod.GET, "/api/internal/admin/dtdd-caches/games?q=&page=0&size=20", + "{\"content\":[{\"dtddId\":7,\"name\":\"Halo\",\"mediaType\":\"Video Game\",\"stale\":false}]," + + "\"number\":0,\"totalPages\":1,\"totalElements\":1}"); + respond(HttpMethod.GET, "/api/internal/admin/dtdd-caches/searches?q=&page=0&size=20", + "{\"content\":[{\"query\":\"halo\",\"dtddIds\":[7],\"stale\":false}]," + + "\"number\":0,\"totalPages\":1,\"totalElements\":1}"); + + assertThat(service.adminDtddCaches().topicsStale()).isEqualTo(1L); + assertThat(service.adminDtddTopicsCache("halo", 1).content().getFirst().yesTopics()).containsExactly("gore"); + assertThat(service.adminDtddGamesCache(null, 0).content().getFirst().mediaType()).isEqualTo("Video Game"); + assertThat(service.adminDtddSearchesCache(null, 0).content().getFirst().dtddIds()).containsExactly(7L); + } + + @Test + void adminDtddCaches_refetchAnEntry_andStartOrStopTheFullRefetch() { + expect(HttpMethod.POST, "/api/internal/admin/dtdd-caches/searches/refetch?key=halo%20infinite") + .andRespond(withStatus(HttpStatus.OK)); + expect(HttpMethod.POST, "/api/internal/admin/dtdd-caches/refetch-all") + .andRespond(withStatus(HttpStatus.ACCEPTED).contentType(MediaType.APPLICATION_JSON) + .body("{\"state\":\"RUNNING\",\"total\":9,\"done\":0,\"failed\":0}")); + expect(HttpMethod.DELETE, "/api/internal/admin/dtdd-caches/refetch-all") + .andRespond(withSuccess("{\"state\":\"CANCELLED\",\"total\":9,\"done\":2,\"failed\":0}", + MediaType.APPLICATION_JSON)); + respond(HttpMethod.GET, "/api/internal/admin/dtdd-caches/topics/entry?key=7", + "{\"dtddId\":7,\"searches\":[],\"titles\":[]}"); + + assertThat(service.adminRefetchDtddCache("searches", "halo infinite")).isEqualTo(200); + var started = service.adminStartDtddRefetch(); + assertThat(started.status()).isEqualTo(202); + assertThat(started.body().total()).isEqualTo(9); + assertThat(service.adminCancelDtddRefetch().body().state()).isEqualTo("CANCELLED"); + assertThat(service.adminDtddCacheEntry("topics", "7").dtddId()).isEqualTo(7L); + } + @Test void adminDeleteCacheEntry_relaysTheStatus() { expect(HttpMethod.DELETE, "/api/internal/admin/caches/igdb-game-cache?key=1001").andRespond(withNoContent()); From 6cf1834a304dbbbfecdb8421673587ac9629dab6 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 06:09:53 +0100 Subject: [PATCH 30/53] test(web): cover the TW details and DTDD topics calls of the real and mock API services --- .../service/mock/MockApiServiceTest.java | 9 ++++++ .../service/real/RealApiServiceTest.java | 31 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java index 6321025b9..b3ca013d6 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java @@ -1283,4 +1283,13 @@ void dtddCaches_areTheMocksStore() { assertThat(service.adminStartDtddRefetch().status()).isEqualTo(202); assertThat(service.adminCancelDtddRefetch().body().state()).isEqualTo("CANCELLED"); } + + @Test + void twDetailsAndDtddTopics_areTheMocks() { + String bindingId = service.channelPage("enimaloc", 0, null, null).bindings().content().getFirst().id(); + assertThat(service.twDetails(bindingId)).isNotNull(); + assertThat(service.twDetails("nope")).isNull(); + assertThat(service.adminTwDtddTopics("violence")).isNotEmpty(); + assertThat(service.adminSetTwDtddTopics("violence", java.util.Map.of("someone dies", "Depicted"))).isEqualTo(204); + } } diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java index c30b38636..1f184db33 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java @@ -122,6 +122,7 @@ void selfRoutesWithoutASessionDoNotCall() { assertThat(service.enabledChatCommands()).isEmpty(); // The TW overrides are the member's own too. assertThat(service.saveTws("b1", Set.of("spiders"))).isEqualTo(401); + assertThat(service.twDetails("b1")).isNull(); // api.verify() in the teardown, and the mock server refusing any request it did not expect: none was sent. } @@ -621,6 +622,36 @@ void resetBinding() { service.resetBinding("b1"); } + @Test + void twDetails_readWhyTheBindingHasItsTws() { + respond(HttpMethod.GET, OWN + "/bindings/b1/tws/details", + "{\"streamerChose\":false,\"tws\":[{\"id\":\"gore\",\"label\":\"Gore\",\"active\":true," + + "\"origin\":\"DETECTED\",\"sources\":[{\"twId\":\"gore\",\"twLabel\":\"Gore\"," + + "\"type\":\"STEAM_KEYWORD\",\"value\":\"blood\"}]}],\"keptOff\":[]}"); + + var details = service.twDetails("b1"); + + assertThat(details.tws()).singleElement().satisfies(t -> { + assertThat(t.origin()).isEqualTo("DETECTED"); + assertThat(t.sources().getFirst().value()).isEqualTo("blood"); + }); + } + + @Test + void adminTwDtddTopics_readThem_andReplaceThemWithTheirThresholds() { + respond(HttpMethod.GET, "/api/internal/admin/tws/gore/dtdd-topics", + "[{\"topic\":\"there's blood/gore\",\"minSeverity\":\"Depicted\",\"knownLevels\":[\"Minor\",\"Depicted\"]}]"); + expectPut("/api/internal/admin/tws/gore/dtdd-topics", + "{\"topics\":[\"there's blood/gore\",\"someone dies\"],\"minSeverities\":{\"there's blood/gore\":\"Depicted\"}}"); + + assertThat(service.adminTwDtddTopics("gore")).singleElement() + .satisfies(t -> assertThat(t.knownLevels()).containsExactly("Minor", "Depicted")); + java.util.Map topics = new java.util.LinkedHashMap<>(); + topics.put("there's blood/gore", "Depicted"); + topics.put("someone dies", null); + assertThat(service.adminSetTwDtddTopics("gore", topics)).isEqualTo(204); + } + @Test void resetTws() { expectDelete(OWN + "/bindings/b1/tws"); From e9b65e8561a26df2a030da949d324eea419e2dc1 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 06:26:19 +0100 Subject: [PATCH 31/53] test(common): cover the admin DTOs' shorter constructors and older payloads --- .../catapult/common/dto/AdminDtosTest.java | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 catapult-common/src/test/java/fr/enimaloc/catapult/common/dto/AdminDtosTest.java diff --git a/catapult-common/src/test/java/fr/enimaloc/catapult/common/dto/AdminDtosTest.java b/catapult-common/src/test/java/fr/enimaloc/catapult/common/dto/AdminDtosTest.java new file mode 100644 index 000000000..fc40e044c --- /dev/null +++ b/catapult-common/src/test/java/fr/enimaloc/catapult/common/dto/AdminDtosTest.java @@ -0,0 +1,75 @@ +package fr.enimaloc.catapult.common.dto; + +import fr.enimaloc.catapult.common.dto.admin.DtddTopicSeverityDto; +import fr.enimaloc.catapult.common.dto.admin.DtddTopicsBody; +import fr.enimaloc.catapult.common.dto.admin.DtddTopicsCacheEntryDto; +import fr.enimaloc.catapult.common.dto.admin.GameMappingDto; +import fr.enimaloc.catapult.common.dto.admin.GameMappingRequest; +import fr.enimaloc.catapult.common.dto.admin.TwRebuildStatusDto; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; + +import java.time.Instant; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** The admin DTOs' shorter constructors and their read of payloads from before a field. */ +class AdminDtosTest { + + private final JsonMapper json = JsonMapper.builder().build(); + + @Test + void aSeverity_namesItsLevels_andTheReviewersOne() { + DtddTopicSeverityDto severity = new DtddTopicSeverityDto(List.of("Implied", "Depicted", "Excessive"), + List.of(2, 9, 1), List.of(17, 75, 8), 12, 2); + + assertThat(severity.verifiedLevel()).isEqualTo("Depicted"); + assertThat(severity.level(1)).isEqualTo("Implied"); + assertThat(severity.level(0)).isNull(); + assertThat(severity.level(4)).isNull(); + assertThat(severity.level(null)).isNull(); + assertThat(new DtddTopicSeverityDto(null, List.of(), List.of(), 0, null).verifiedLevel()).isNull(); + } + + @Test + void cachedTopics_withoutTheirSeverities_haveNone() { + DtddTopicsCacheEntryDto entry = new DtddTopicsCacheEntryDto(7, "Halo", List.of("gore"), List.of(), List.of(), + Instant.EPOCH, false); + + assertThat(entry.severities()).isNull(); + } + + @Test + void dtddTopics_withoutThresholds_haveNone() { + assertThat(new DtddTopicsBody(Set.of("gore")).minSeverities()).isNull(); + assertThat(json.readValue("{\"topics\":[\"gore\"]}", DtddTopicsBody.class).minSeverities()).isNull(); + } + + @Test + void aGameMapping_withoutItsIgnore_doesNotIgnoreTheGame() { + GameMappingRequest request = new GameMappingRequest("STEAM", "730", "1", "CS", null, null, false); + assertThat(request.ignoredByDefault()).isFalse(); + assertThat(new GameMappingRequest("STEAM", "730", null, null, null, null, false, null).ignoredByDefault()) + .isFalse(); + assertThat(json.readValue("{\"sourceType\":\"STEAM\",\"sourceId\":\"730\",\"applyLive\":true}", + GameMappingRequest.class).ignoredByDefault()).isFalse(); + + GameMappingDto mapping = new GameMappingDto(null, "STEAM", "730", "CS2", "1", "CS", null, null, 3, + Instant.EPOCH, "admin"); + assertThat(mapping.ignoredByDefault()).isFalse(); + assertThat(json.readValue("{\"sourceType\":\"STEAM\",\"sourceId\":\"730\",\"bindingCount\":0}", + GameMappingDto.class).ignoredByDefault()).isFalse(); + } + + @Test + void aRecalculation_countsWhatItWentThrough() { + TwRebuildStatusDto status = new TwRebuildStatusDto("RUNNING", "NON_OVERRIDDEN", 40, 10, 3, 1, Instant.EPOCH, + null, null); + + assertThat(status.done()).isEqualTo(14L); + assertThat(TwRebuildStatusDto.IDLE.done()).isZero(); + assertThat(TwRebuildStatusDto.IDLE.state()).isEqualTo("IDLE"); + } +} From cc3231dc2c10ea0646bde4085b95e44dfd66f9ce Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:34:44 +0100 Subject: [PATCH 32/53] feat(ratelimit): add route keys and caller priority --- .../catapult/ratelimit/RateLimitPriority.java | 47 ++++++++++++++ .../enimaloc/catapult/ratelimit/RouteKey.java | 58 +++++++++++++++++ .../catapult/ratelimit/RouteKeyTest.java | 65 +++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitPriority.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RouteKey.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/RouteKeyTest.java diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitPriority.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitPriority.java new file mode 100644 index 000000000..2158c5004 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitPriority.java @@ -0,0 +1,47 @@ +package fr.enimaloc.catapult.ratelimit; + +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; + +/** + * Who is calling: {@link #LIVE} (a streamer waiting, the default) passes while under a route's learned limit; + * {@link #BULK} (a recalculation, a refetch) keeps a margin under it and waits its turn. Set around the calls with + * {@link #run}/{@link #call}, read by {@link LearnedRateLimiter} on the calling thread. + */ +public enum RateLimitPriority { + LIVE, BULK; + + private record Context(RateLimitPriority priority, BooleanSupplier cancelled) {} + + private static final Context DEFAULT = new Context(LIVE, () -> false); + private static final ThreadLocal CURRENT = ThreadLocal.withInitial(() -> DEFAULT); + + /** The calling thread's priority. */ + public static RateLimitPriority current() { + return CURRENT.get().priority(); + } + + /** Whether the calling thread's work was cancelled: a wait for the limit then gives up. */ + public static boolean cancelled() { + return CURRENT.get().cancelled().getAsBoolean(); + } + + /** Runs {@code body} at this priority; {@code cancelled} tells a wait for the limit to give up. */ + public static T call(RateLimitPriority priority, BooleanSupplier cancelled, Supplier body) { + Context previous = CURRENT.get(); + CURRENT.set(new Context(priority, cancelled)); + try { + return body.get(); + } finally { + CURRENT.set(previous); + } + } + + /** As {@link #call}, without a result. */ + public static void run(RateLimitPriority priority, BooleanSupplier cancelled, Runnable body) { + call(priority, cancelled, () -> { + body.run(); + return null; + }); + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RouteKey.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RouteKey.java new file mode 100644 index 000000000..238fbd515 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RouteKey.java @@ -0,0 +1,58 @@ +package fr.enimaloc.catapult.ratelimit; + +import org.springframework.http.HttpRequest; + +import java.net.URI; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * One route of a platform: the method, the host and the path as written by the caller ({@code /app/{id}}), never the + * resolved URL, so every app's page counts as the same route. Without the caller's URI template, the path's + * identifier-looking segments become {@code {id}}. + */ +public record RouteKey(String host, String method, String path) { + + /** The request attribute {@code RestClient} keeps the URI template in. */ + static final String URI_TEMPLATE_ATTRIBUTE = "org.springframework.web.client.DefaultRestClient.uriTemplate"; + + private static final Pattern ID_SEGMENT = Pattern.compile("\\d+|[A-Za-z0-9_-]{16,}|.*\\(.*\\).*"); + + public static RouteKey of(HttpRequest request) { + URI uri = request.getURI(); + String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT); + Object template = request.getAttributes().get(URI_TEMPLATE_ATTRIBUTE); + String path = template instanceof String t ? templatePath(t) : normalizedPath(uri.getRawPath()); + return new RouteKey(host, request.getMethod().name(), path); + } + + /** The route's ID: {@code GET store.steampowered.com/app/{id}}. */ + public String id() { + return method + " " + host + path; + } + + /** A template's path: no scheme, host or query. */ + static String templatePath(String template) { + String path = template; + int scheme = path.indexOf("://"); + if (scheme >= 0) { + int slash = path.indexOf('/', scheme + 3); + path = slash < 0 ? "/" : path.substring(slash); + } + int query = path.indexOf('?'); + if (query >= 0) path = path.substring(0, query); + return path.isEmpty() ? "/" : path; + } + + /** A resolved path whose identifier-looking segments become {@code {id}}. */ + static String normalizedPath(String rawPath) { + if (rawPath == null || rawPath.isEmpty()) return "/"; + StringBuilder out = new StringBuilder(); + for (String segment : rawPath.split("/", -1)) { + if (segment.isEmpty()) continue; + out.append('/').append(ID_SEGMENT.matcher(segment).matches() ? "{id}" : segment); + } + if (rawPath.endsWith("/") && !out.isEmpty()) out.append('/'); + return out.isEmpty() ? "/" : out.toString(); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/RouteKeyTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/RouteKeyTest.java new file mode 100644 index 000000000..87c352222 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/RouteKeyTest.java @@ -0,0 +1,65 @@ +package fr.enimaloc.catapult.ratelimit; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.mock.http.client.MockClientHttpRequest; + +import java.net.URI; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class RouteKeyTest { + + @Test + void theCallersTemplate_isTheRoute() { + MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, + URI.create("https://Store.SteamPowered.com/app/620?l=french")); + request.getAttributes().put(RouteKey.URI_TEMPLATE_ATTRIBUTE, "https://store.steampowered.com/app/{id}?l={l}"); + + RouteKey key = RouteKey.of(request); + + assertThat(key).isEqualTo(new RouteKey("store.steampowered.com", "GET", "/app/{id}")); + assertThat(key.id()).isEqualTo("GET store.steampowered.com/app/{id}"); + } + + @Test + void withoutATemplate_identifiersBecomeId() { + MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, + URI.create("https://displaycatalog.mp.microsoft.com/v7.0/products/9NBLGGH4R315ABCD?market=FR")); + + assertThat(RouteKey.of(request).path()).isEqualTo("/v7.0/products/{id}"); + assertThat(RouteKey.normalizedPath("/users/xuid(123)/titles/42/")).isEqualTo("/users/{id}/titles/{id}/"); + assertThat(RouteKey.normalizedPath("")).isEqualTo("/"); + assertThat(RouteKey.normalizedPath(null)).isEqualTo("/"); + } + + @Test + void aTemplatesPath_dropsSchemeHostAndQuery() { + assertThat(RouteKey.templatePath("/api/appdetails?appids={id}")).isEqualTo("/api/appdetails"); + assertThat(RouteKey.templatePath("https://host.example")).isEqualTo("/"); + assertThat(RouteKey.templatePath("?x={x}")).isEqualTo("/"); + } + + @Test + void thePriority_isLiveUnlessSetAndComesBackAfter() { + AtomicReference inner = new AtomicReference<>(); + AtomicReference innerCancelled = new AtomicReference<>(); + + assertThat(RateLimitPriority.current()).isEqualTo(RateLimitPriority.LIVE); + assertThat(RateLimitPriority.cancelled()).isFalse(); + RateLimitPriority.run(RateLimitPriority.BULK, () -> true, () -> { + String nested = RateLimitPriority.call(RateLimitPriority.LIVE, () -> false, () -> { + inner.set(RateLimitPriority.current()); + return "ok"; + }); + assertThat(nested).isEqualTo("ok"); + innerCancelled.set(RateLimitPriority.cancelled()); + assertThat(RateLimitPriority.current()).isEqualTo(RateLimitPriority.BULK); + }); + + assertThat(inner.get()).isEqualTo(RateLimitPriority.LIVE); + assertThat(innerCancelled.get()).isTrue(); + assertThat(RateLimitPriority.current()).isEqualTo(RateLimitPriority.LIVE); + } +} From cd059a60b072b30759b931ab0b88092edfb62f6d Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:34:44 +0100 Subject: [PATCH 33/53] feat(ratelimit): learn limits and resume delays from 429s --- .../catapult/ratelimit/LimitEstimator.java | 102 ++++++++++++++++ .../catapult/ratelimit/ResumeDelay.java | 73 +++++++++++ .../catapult/ratelimit/LearningTest.java | 115 ++++++++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LimitEstimator.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/ResumeDelay.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LimitEstimator.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LimitEstimator.java new file mode 100644 index 000000000..eea023d16 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LimitEstimator.java @@ -0,0 +1,102 @@ +package fr.enimaloc.catapult.ratelimit; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; + +/** + * A scope's limit, guessed from its 429s: the calls sent in the window the platform seems to count, minus the one it + * refused. Only tightened by a 429; raised by a tenth after a long time near it without any. Not thread-safe: its + * {@link RateLimitScope} guards it. + */ +final class LimitEstimator { + + /** The windows a platform may count calls in, shortest first. */ + static final List WINDOWS = List.of(Duration.ofSeconds(10), Duration.ofMinutes(1), + Duration.ofMinutes(5), Duration.ofHours(1)); + private static final Duration KEPT = WINDOWS.getLast(); + private static final int MAX_KEPT = 10_000; + + /** A limit: at most {@code calls} in any {@code window}. */ + record Estimate(int calls, Duration window) {} + + private final Duration raiseAfter; + private final Deque calls = new ArrayDeque<>(); + private Estimate estimate; + private Instant last429; + private Instant lastRaise; + + LimitEstimator(Duration raiseAfter) { + this.raiseAfter = raiseAfter; + } + + void record(Instant at) { + calls.addLast(at); + prune(at); + } + + int countWithin(Duration window, Instant now) { + Instant from = now.minus(window); + int n = 0; + for (Iterator it = calls.descendingIterator(); it.hasNext(); ) { + if (!it.next().isAfter(from)) break; + n++; + } + return n; + } + + /** The oldest call still counted in {@code window}: the limit frees a call when it leaves the window. */ + Optional oldestWithin(Duration window, Instant now) { + Instant from = now.minus(window); + return calls.stream().filter(at -> at.isAfter(from)).findFirst(); + } + + Optional estimate() { + return Optional.ofNullable(estimate); + } + + /** + * A 429: the window is the shortest one at least as long as the wait the platform asks or the one learned + * ({@code resumeHint}), the limit the calls sent in it before the refused one. Never loosened by a 429. + */ + void on429(Instant now, Duration resumeHint) { + last429 = now; + Duration window = WINDOWS.stream().filter(w -> w.compareTo(resumeHint) >= 0).findFirst().orElse(KEPT); + int limit = Math.max(1, countWithin(window, now) - 1); + if (estimate == null || !estimate.window().equals(window) || limit < estimate.calls()) { + estimate = estimate != null && estimate.window().equals(window) + ? new Estimate(Math.min(limit, estimate.calls()), window) + : new Estimate(limit, window); + } + } + + /** Raises the limit by a tenth (at least one call) when it was nearly reached for long without a 429. */ + void maybeRaise(Instant now) { + if (estimate == null) return; + Instant since = last429 == null ? lastRaise : (lastRaise == null || last429.isAfter(lastRaise) ? last429 : lastRaise); + if (since != null && since.plus(raiseAfter).isAfter(now)) return; + if (countWithin(estimate.window(), now) * 10 < estimate.calls() * 8) return; + estimate = new Estimate(estimate.calls() + Math.max(1, estimate.calls() / 10), estimate.window()); + lastRaise = now; + } + + void set(Estimate estimate, Instant last429) { + this.estimate = estimate; + this.last429 = last429; + } + + void reset() { + estimate = null; + last429 = null; + lastRaise = null; + } + + private void prune(Instant now) { + Instant from = now.minus(KEPT); + while (!calls.isEmpty() && (calls.size() > MAX_KEPT || !calls.peekFirst().isAfter(from))) calls.removeFirst(); + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/ResumeDelay.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/ResumeDelay.java new file mode 100644 index 000000000..fcd47fe8b --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/ResumeDelay.java @@ -0,0 +1,73 @@ +package fr.enimaloc.catapult.ratelimit; + +import java.time.Duration; +import java.time.Instant; + +/** + * How long a scope waits after a 429 without a Retry-After, learned from the first call after each wait: an answer + * tries a fifth shorter next time, another 429 doubles it. Never shorter than the longest wait that failed lately + * ({@code fail}, forgotten after a long time without failing). Not thread-safe: its {@link RateLimitScope} guards it. + */ +final class ResumeDelay { + + static final Duration INITIAL = Duration.ofSeconds(60); + static final Duration MAX = Duration.ofMinutes(10); + private static final Duration MIN = Duration.ofSeconds(1); + + /** What is kept of it. */ + record State(Duration current, Duration fail, Duration ok, Instant lastFail) {} + + private final Duration forgetFailAfter; + private Duration current = INITIAL; + private Duration fail; + private Duration ok; + private Instant lastFail; + + ResumeDelay(Duration forgetFailAfter) { + this.forgetFailAfter = forgetFailAfter; + } + + Duration current() { + return current; + } + + /** The first call after waiting {@link #current} got a 429 again: too short. */ + void onFailure(Instant now) { + forgetOldFailure(now); + fail = fail == null || current.compareTo(fail) > 0 ? current : fail; + Duration doubled = current.multipliedBy(2); + current = doubled.compareTo(MAX) > 0 ? MAX : doubled; + lastFail = now; + } + + /** The first call after waiting {@link #current} was answered: long enough, a shorter wait is tried next. */ + void onSuccess(Instant now) { + forgetOldFailure(now); + ok = ok == null || current.compareTo(ok) < 0 ? current : ok; + Duration shorter = current.multipliedBy(4).dividedBy(5); + if (fail != null && shorter.compareTo(fail) < 0) shorter = fail; + current = shorter.compareTo(MIN) < 0 ? MIN : shorter; + } + + State state() { + return new State(current, fail, ok, lastFail); + } + + void restore(State state) { + current = state.current() == null ? INITIAL : state.current(); + fail = state.fail(); + ok = state.ok(); + lastFail = state.lastFail(); + } + + void reset() { + restore(new State(INITIAL, null, null, null)); + } + + private void forgetOldFailure(Instant now) { + if (lastFail != null && lastFail.plus(forgetFailAfter).isBefore(now)) { + fail = null; + lastFail = null; + } + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java new file mode 100644 index 000000000..a9282507d --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java @@ -0,0 +1,115 @@ +package fr.enimaloc.catapult.ratelimit; + +import fr.enimaloc.catapult.ratelimit.LimitEstimator.Estimate; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; + +/** The limit and the resume delay learned from 429s. */ +class LearningTest { + + private static final Instant T0 = Instant.parse("2026-11-12T10:00:00Z"); + + private static LimitEstimator calls(int n, Duration spread) { + LimitEstimator e = new LimitEstimator(Duration.ofHours(1)); + for (int i = 0; i < n; i++) e.record(T0.plusMillis(spread.toMillis() * i / n)); + return e; + } + + @Test + void a429_limitsToTheCallsBeforeTheRefusedOne_inTheWindowOfTheWait() { + LimitEstimator e = calls(30, Duration.ofSeconds(50)); + + e.on429(T0.plusSeconds(50), Duration.ofSeconds(60)); + + assertThat(e.estimate()).contains(new Estimate(29, Duration.ofMinutes(1))); + } + + @Test + void a429_neverLoosensTheLimit_butAnotherWindowReplacesIt() { + LimitEstimator e = calls(30, Duration.ofSeconds(50)); + e.on429(T0.plusSeconds(50), Duration.ofSeconds(60)); + for (int i = 0; i < 20; i++) e.record(T0.plusSeconds(51)); + + e.on429(T0.plusSeconds(52), Duration.ofSeconds(30)); + assertThat(e.estimate()).contains(new Estimate(29, Duration.ofMinutes(1))); + + e.on429(T0.plusSeconds(52), Duration.ofSeconds(5)); + assertThat(e.estimate()).contains(new Estimate(23, Duration.ofSeconds(10))); + + e.on429(T0.plusSeconds(52), Duration.ofHours(3)); + assertThat(e.estimate()).contains(new Estimate(49, Duration.ofHours(1))); + } + + @Test + void theLimit_isNeverUnderOneCall() { + LimitEstimator e = new LimitEstimator(Duration.ofHours(1)); + e.on429(T0, Duration.ofSeconds(1)); + assertThat(e.estimate()).contains(new Estimate(1, Duration.ofSeconds(10))); + assertThat(e.oldestWithin(Duration.ofSeconds(10), T0)).isEmpty(); + } + + @Test + void longNearTheLimitWithoutA429_raisesItByATenth() { + LimitEstimator e = calls(30, Duration.ofSeconds(50)); + e.on429(T0.plusSeconds(50), Duration.ofSeconds(60)); + Instant later = T0.plus(Duration.ofHours(2)); + + e.maybeRaise(later); + assertThat(e.estimate()).contains(new Estimate(29, Duration.ofMinutes(1))); + + for (int i = 24; i >= 0; i--) e.record(later.minusSeconds(i)); + e.maybeRaise(later); + assertThat(e.estimate()).contains(new Estimate(31, Duration.ofMinutes(1))); + + e.maybeRaise(later.plusSeconds(1)); + assertThat(e.estimate()).contains(new Estimate(31, Duration.ofMinutes(1))); + assertThat(e.oldestWithin(Duration.ofMinutes(1), later)).contains(later.minusSeconds(24)); + + e.reset(); + e.maybeRaise(later); + assertThat(e.estimate()).isEmpty(); + e.set(new Estimate(5, Duration.ofSeconds(10)), null); + assertThat(e.estimate()).contains(new Estimate(5, Duration.ofSeconds(10))); + } + + @Test + void theResumeDelay_convergesWithoutGoingUnderWhatFailed() { + ResumeDelay d = new ResumeDelay(Duration.ofHours(24)); + assertThat(d.current()).isEqualTo(Duration.ofSeconds(60)); + + d.onSuccess(T0); + assertThat(d.current()).isEqualTo(Duration.ofSeconds(48)); + d.onFailure(T0); + assertThat(d.current()).isEqualTo(Duration.ofSeconds(96)); + d.onSuccess(T0); + assertThat(d.current()).isEqualTo(Duration.ofMillis(76_800)); + for (int i = 0; i < 10; i++) d.onSuccess(T0); + assertThat(d.current()).isEqualTo(Duration.ofSeconds(48)); + assertThat(d.state().ok()).isEqualTo(Duration.ofSeconds(48)); + assertThat(d.state().fail()).isEqualTo(Duration.ofSeconds(48)); + } + + @Test + void theResumeDelay_forgetsAnOldFailure_isCapped_andResets() { + ResumeDelay d = new ResumeDelay(Duration.ofHours(24)); + for (int i = 0; i < 6; i++) d.onFailure(T0); + assertThat(d.current()).isEqualTo(ResumeDelay.MAX); + + d.onSuccess(T0.plus(Duration.ofDays(2))); + assertThat(d.state().fail()).isNull(); + assertThat(d.current()).isEqualTo(Duration.ofMinutes(8)); + + d.restore(new ResumeDelay.State(Duration.ofMillis(1100), null, null, null)); + d.onSuccess(T0); + assertThat(d.current()).isEqualTo(Duration.ofSeconds(1)); + d.restore(new ResumeDelay.State(null, null, null, null)); + assertThat(d.current()).isEqualTo(ResumeDelay.INITIAL); + d.onFailure(T0); + d.reset(); + assertThat(d.state()).isEqualTo(new ResumeDelay.State(ResumeDelay.INITIAL, null, null, null)); + } +} From a62732af32a1aacbc3c8bd5e247fb02398fe6d55 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:35:18 +0100 Subject: [PATCH 34/53] feat(ratelimit): persist learned rate limits --- .../domain/ratelimit/LearnedRateLimit.java | 75 +++++++++++++++++++ .../ratelimit/LearnedRateLimitRepository.java | 7 ++ .../migration/V107__learned_rate_limits.sql | 23 ++++++ 3 files changed, 105 insertions(+) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/domain/ratelimit/LearnedRateLimit.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/repository/ratelimit/LearnedRateLimitRepository.java create mode 100644 catapult-api/src/main/resources/db/migration/V107__learned_rate_limits.sql diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/domain/ratelimit/LearnedRateLimit.java b/catapult-api/src/main/java/fr/enimaloc/catapult/domain/ratelimit/LearnedRateLimit.java new file mode 100644 index 000000000..2b0ea0505 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/domain/ratelimit/LearnedRateLimit.java @@ -0,0 +1,75 @@ +package fr.enimaloc.catapult.domain.ratelimit; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +/** + * What was learned of one scope's rate limit: a host (its shared budget; {@code method} and {@code path} null) or a + * route ({@code scope} HOST: counted in its host's budget, ROUTE: its own). {@code override*}: an admin's correction. + */ +@Entity +@Table(name = "learned_rate_limits") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder(toBuilder = true) +public class LearnedRateLimit { + + @Id + @Column(name = "id", nullable = false, length = 800) + private String id; + + @Column(name = "host", nullable = false) + private String host; + + @Column(name = "method") + private String method; + + @Column(name = "path") + private String path; + + @Column(name = "scope") + private String scope; + + @Column(name = "scope_pinned", nullable = false) + private boolean scopePinned; + + @Column(name = "limit_calls") + private Integer limitCalls; + + @Column(name = "limit_window_seconds") + private Integer limitWindowSeconds; + + @Column(name = "resume_ms", nullable = false) + private long resumeMs; + + @Column(name = "resume_fail_ms") + private Long resumeFailMs; + + @Column(name = "resume_ok_ms") + private Long resumeOkMs; + + @Column(name = "last_fail_at") + private Instant lastFailAt; + + @Column(name = "rate_limited_count", nullable = false) + private int rateLimitedCount; + + @Column(name = "last_rate_limited_at") + private Instant lastRateLimitedAt; + + @Column(name = "override_calls") + private Integer overrideCalls; + + @Column(name = "override_window_seconds") + private Integer overrideWindowSeconds; + + @Column(name = "override_resume_ms") + private Long overrideResumeMs; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/repository/ratelimit/LearnedRateLimitRepository.java b/catapult-api/src/main/java/fr/enimaloc/catapult/repository/ratelimit/LearnedRateLimitRepository.java new file mode 100644 index 000000000..6acfc525f --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/repository/ratelimit/LearnedRateLimitRepository.java @@ -0,0 +1,7 @@ +package fr.enimaloc.catapult.repository.ratelimit; + +import fr.enimaloc.catapult.domain.ratelimit.LearnedRateLimit; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LearnedRateLimitRepository extends JpaRepository { +} diff --git a/catapult-api/src/main/resources/db/migration/V107__learned_rate_limits.sql b/catapult-api/src/main/resources/db/migration/V107__learned_rate_limits.sql new file mode 100644 index 000000000..0360ff5aa --- /dev/null +++ b/catapult-api/src/main/resources/db/migration/V107__learned_rate_limits.sql @@ -0,0 +1,23 @@ +-- What Catapult learned of the rate limits platforms don't document, from their 429s: one row per host (its shared +-- budget, method and path NULL) and one per route seen (scope HOST: counted in its host's budget; ROUTE: its own). +-- override_*: an admin's correction, kept until reset; the learning leaves it alone. +CREATE TABLE learned_rate_limits ( + id VARCHAR(800) PRIMARY KEY, + host VARCHAR(255) NOT NULL, + method VARCHAR(16), + path VARCHAR(512), + scope VARCHAR(8), + scope_pinned BOOLEAN NOT NULL DEFAULT FALSE, + limit_calls INTEGER, + limit_window_seconds INTEGER, + resume_ms BIGINT NOT NULL, + resume_fail_ms BIGINT, + resume_ok_ms BIGINT, + last_fail_at TIMESTAMPTZ, + rate_limited_count INTEGER NOT NULL DEFAULT 0, + last_rate_limited_at TIMESTAMPTZ, + override_calls INTEGER, + override_window_seconds INTEGER, + override_resume_ms BIGINT, + updated_at TIMESTAMPTZ NOT NULL +); From 434b489ca1274be00bf17087300693558c1dee58 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:35:49 +0100 Subject: [PATCH 35/53] fix(web): stop completing an SSE stream the container already failed When a client disconnected mid-write, the outbox completed the emitter after the IOException; Tomcat had already run onError, so the dispatch threw an IllegalStateException out of the writing virtual thread. Leave the stream to the container after an IOException, and complete quietly on the other paths. --- .../catapult/controller/SseStreams.java | 27 +++++++-- .../catapult/controller/SseStreamsTest.java | 59 ++++++++++++++++++- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/SseStreams.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/SseStreams.java index 65dae5893..deba4f8ec 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/SseStreams.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/SseStreams.java @@ -194,7 +194,7 @@ void offer(SseEmitter.SseEventBuilder event) { if (size.incrementAndGet() > MAX_PENDING) { log.debug("Closing an SSE stream that does not keep up"); closed(); - emitter.complete(); + complete(); return; } pending.add(event); @@ -226,15 +226,20 @@ private void write() { size.decrementAndGet(); if (event == CLOSE) { closed(); - emitter.complete(); + complete(); return; } try { emitter.send(event); - } catch (IOException | IllegalStateException e) { - // Gone, or completed meanwhile. + } catch (IOException e) { + // Gone: the container reports the error itself (onError) and ends the request; completing + // it from here as well is refused (Spring: not to be used after an error while sending). + closed(); + return; + } catch (IllegalStateException e) { + // Completed meanwhile. closed(); - emitter.complete(); + complete(); return; } } @@ -245,6 +250,18 @@ private void write() { if (!closed.get() && !pending.isEmpty()) schedule(); } + /** + * Ends the request, unless the container ended it first: a client gone between our last write and this + * call has the container fail it (onError), after which Tomcat refuses an application thread's dispatch. + */ + private void complete() { + try { + emitter.complete(); + } catch (IllegalStateException e) { + log.debug("SSE stream already ended by the container: {}", e.getMessage()); + } + } + /** Forgotten, once; {@code onClosed} told. */ void closed() { if (closed.compareAndSet(false, true)) { diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/SseStreamsTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/SseStreamsTest.java index 92ef8ca13..6f0dbdd56 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/SseStreamsTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/SseStreamsTest.java @@ -13,6 +13,7 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.doAnswer; @@ -109,8 +110,13 @@ void aStreamThatFailsOnOpening_isClosedAndForgotten() throws IOException { assertThat(sse.openCount()).isZero(); } + /* + * Not completed after an IOException: the container reports the error itself (onError), and Tomcat refuses + * an application thread completing the stream after that (an IllegalStateException out of the writing + * thread, seen in production). + */ @Test - void aStreamThatFails_isClosedAndForgotten_once() throws IOException { + void aStreamThatFails_isClosedAndForgotten_once_withoutBeingCompleted() throws IOException { SseStreams sse = SseStreams.direct(metrics); SseEmitter emitter = mock(SseEmitter.class); sse.open(emitter, closed::incrementAndGet); @@ -119,11 +125,62 @@ void aStreamThatFails_isClosedAndForgotten_once() throws IOException { sse.send(emitter, "A", "1"); sse.send(emitter, "B", "2"); + verify(emitter, never()).complete(); + assertThat(closed).hasValue(1); + assertThat(sse.openCount()).isZero(); + } + + /* + * Completed once the container has failed it, the emitter refuses: still closed, and nothing thrown out of + * the writing task (its own thread in production, where the exception was only logged as uncaught). + */ + @Test + void aStreamCompletedAfterTheContainersError_isClosedQuietly() { + List tasks = new ArrayList<>(); + SseStreams sse = new SseStreams(metrics, InstanceBus.local(), tasks::add); + SseEmitter emitter = mock(SseEmitter.class); + sse.open(emitter, closed::incrementAndGet); + doThrow(new IllegalStateException("AsyncContext used after an error")).when(emitter).complete(); + sse.close(emitter); + + assertThatCode(() -> tasks.getFirst().run()).doesNotThrowAnyException(); + verify(emitter).complete(); assertThat(closed).hasValue(1); assertThat(sse.openCount()).isZero(); } + @Test + void aStreamThatFailsOtherwise_isCompletedQuietly() throws IOException { + List tasks = new ArrayList<>(); + SseStreams sse = new SseStreams(metrics, InstanceBus.local(), tasks::add); + SseEmitter emitter = mock(SseEmitter.class); + sse.open(emitter, closed::incrementAndGet); + doThrow(new IllegalStateException("completed")).when(emitter).send(any(SseEmitter.SseEventBuilder.class)); + doThrow(new IllegalStateException("AsyncContext used after an error")).when(emitter).complete(); + sse.send(emitter, "A", "1"); + + assertThatCode(() -> tasks.getFirst().run()).doesNotThrowAnyException(); + + verify(emitter).complete(); + assertThat(closed).hasValue(1); + } + + @Test + void aStreamThatDoesNotKeepUp_isClosedQuietly_evenWhenItsCompletionIsRefused() { + List never = new ArrayList<>(); + SseStreams sse = new SseStreams(metrics, InstanceBus.local(), never::add); + SseEmitter emitter = mock(SseEmitter.class); + sse.open(emitter, closed::incrementAndGet); + doThrow(new IllegalStateException("AsyncContext used after an error")).when(emitter).complete(); + + assertThatCode(() -> { + for (int i = 0; i <= SseStreams.MAX_PENDING; i++) sse.send(emitter, "A", i); + }).doesNotThrowAnyException(); + + assertThat(closed).hasValue(1); + } + @Test void aStreamThatDoesNotKeepUp_isClosed() { List never = new ArrayList<>(); From 8ff3dc22990e57542738a626f5be895011481dab Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:37:22 +0100 Subject: [PATCH 36/53] chore(release): promote dev straight to main, skipping beta Until a v2, the bot opens a single dev -> main promotion: its version is the last stable bumped by the promoted commits (no -BETA.N tag), and a stable or a hotfix is brought back to dev straight from main. The chain lives in .github/release/lib/flow.mjs (BETA = false); the beta pairs stay accepted by the scripts and workflows, so setting it back to true restores dev -> beta -> main. --- .github/release/dashboard.mjs | 2 +- .github/release/lib/flow.mjs | 21 +++++++++++++ .github/release/lib/version.mjs | 17 ++++++++++- .github/release/promote.mjs | 25 ++++++++++------ .github/release/publish.mjs | 29 +++++++++++------- .github/release/ready.mjs | 9 ++++-- .github/release/schedule.mjs | 22 +++++++------- .github/release/test/dashboard.test.mjs | 4 ++- .github/release/test/promote.test.mjs | 18 ++++++++++++ .github/release/test/publish.test.mjs | 39 +++++++++++++++++++++---- .github/release/test/ready.test.mjs | 16 +++++++++- .github/release/test/schedule.test.mjs | 12 ++++++-- .github/release/test/version.test.mjs | 14 ++++++++- .github/workflows/commitlint.yml | 2 ++ .github/workflows/promote.yml | 8 +++-- .github/workflows/publish.yml | 5 +++- .github/workflows/release-ready.yml | 4 ++- .github/workflows/release-schedule.yml | 5 ++-- 18 files changed, 198 insertions(+), 54 deletions(-) create mode 100644 .github/release/lib/flow.mjs diff --git a/.github/release/dashboard.mjs b/.github/release/dashboard.mjs index 8804c7b95..91c301bc2 100644 --- a/.github/release/dashboard.mjs +++ b/.github/release/dashboard.mjs @@ -10,7 +10,7 @@ import { createGitHub } from "./lib/github.mjs"; const MARKER = ""; const TITLE = "🚀 Releases en cours"; const LABEL = "release"; -const PAIRS = new Set(["dev>beta", "beta>main"]); +const PAIRS = new Set(["dev>main", "dev>beta", "beta>main"]); const TITLE_VERSION = /^chore\(release\): (v\S+)$/; // GitHub turns #N into a link to the pull request. diff --git a/.github/release/lib/flow.mjs b/.github/release/lib/flow.mjs new file mode 100644 index 000000000..a411da054 --- /dev/null +++ b/.github/release/lib/flow.mjs @@ -0,0 +1,21 @@ +// The promotion chain. Until a v2 (maybe never), `beta` is skipped: dev is promoted straight to main +// as a stable, and a release is brought back to dev straight from main. +// To bring beta back: set BETA to true here, then put `beta` back in promote.yml's push branches (the +// workflows' other conditions accept both chains). + +export const BETA = false; + +/** The promotions the bot opens, in release order. */ +export function promotions(beta = BETA) { + return beta + ? [ + { source: "dev", target: "beta" }, + { source: "beta", target: "main" }, + ] + : [{ source: "dev", target: "main" }]; +} + +/** The branch `source` is promoted to, or null when it is not promoted. */ +export function promotionTarget(source, beta = BETA) { + return promotions(beta).find((p) => p.source === source)?.target ?? null; +} diff --git a/.github/release/lib/version.mjs b/.github/release/lib/version.mjs index c65fe6d1c..7b4e39c9c 100644 --- a/.github/release/lib/version.mjs +++ b/.github/release/lib/version.mjs @@ -1,4 +1,5 @@ -// Version computation for the dev -> beta -> main release flow. +// Version computation for the release flow: dev -> main while beta is skipped (lib/flow.mjs), else +// dev -> beta -> main. // Tags are `vX.Y.Z` (stable) and `vX.Y.Z-BETA.N` (pre-release). const TAG = /^v(\d+)\.(\d+)\.(\d+)(?:-BETA\.(\d+))?$/; @@ -74,6 +75,20 @@ export function nextBeta(lastStable, betaTags, subjects) { return format(target, n); } +/** + * Next stable promoted straight from dev (beta skipped): lastStable bumped by the commits, or the highest + * target among the betas newer than lastStable when that is higher; null when no commit is releasable. + */ +export function nextDirectStable(lastStable, betaTags, subjects) { + const kind = bumpKind(subjects, lastStable); + if (kind === null) return null; + let target = bump(parseVersion(lastStable), kind); + for (const beta of betaTags.map(parseVersion)) { + if (beta.beta !== null && compare(beta, target) > 0) target = beta; + } + return format(target); +} + export function nextStable(betaTag) { const version = parseVersion(betaTag); if (version.beta === null) throw new Error(`Not a beta tag: ${betaTag}`); diff --git a/.github/release/promote.mjs b/.github/release/promote.mjs index cb4e6cd84..4f0761411 100644 --- a/.github/release/promote.mjs +++ b/.github/release/promote.mjs @@ -1,10 +1,12 @@ // Opens (in draft) or refreshes the single promotion PR from `source` to `target`. -// CLI: node .github/release/promote.mjs --source dev --target beta -// (env: GH_TOKEN, GITHUB_REPOSITORY=owner/repo, APP_SLUG = the release App's slug) +// CLI: node .github/release/promote.mjs --source dev [--target main] +// (--target defaults to the source's next branch in lib/flow.mjs; env: GH_TOKEN, +// GITHUB_REPOSITORY=owner/repo, APP_SLUG = the release App's slug) import { parseArgs } from "node:util"; import { pathToFileURL } from "node:url"; -import { parseVersion, bumpKind, nextBeta, nextStable } from "./lib/version.mjs"; +import { parseVersion, bumpKind, nextBeta, nextDirectStable, nextStable } from "./lib/version.mjs"; +import { promotionTarget } from "./lib/flow.mjs"; import { buildBody, parseBody, scheduledText, seenId, setPromotedHead, setScheduledText, updateBody } from "./lib/pr-body.mjs"; import { activeSchedule, NO_SCHEDULE } from "./lib/schedule.mjs"; import { createGitHub } from "./lib/github.mjs"; @@ -37,16 +39,20 @@ export function compareVersions(a, b) { } /** - * Stable for a promotion to `main`, beta otherwise; null when there is nothing to release. `commits` - * are the promoted ones (release commits left out). ready.mjs checks the PR title against it. + * Stable for a promotion to `main` (dev -> main bumps the last stable, beta -> main finishes the last + * beta), beta otherwise; null when there is nothing to release. `commits` are the promoted ones + * (release commits left out). ready.mjs checks the PR title against it. */ -export function releaseVersion(target, tags, commits) { +export function releaseVersion(source, target, tags, commits) { const versions = versionTags(tags).sort(compareVersions); const stables = versions.filter((v) => v.beta === null); const lastStable = stables.length ? stables[stables.length - 1] : { tag: "v0.0.0", ...parseVersion("v0.0.0") }; const betas = versions.filter((v) => v.beta !== null && compareVersions(v, lastStable) > 0); - if (target === "main") return betas.length ? nextStable(betas[betas.length - 1].tag) : null; const messages = commits.map((c) => c.message); + if (target === "main" && source === "dev") { + return nextDirectStable(lastStable.tag, betas.map((v) => v.tag), messages); + } + if (target === "main") return betas.length ? nextStable(betas[betas.length - 1].tag) : null; if (bumpKind(messages, lastStable.tag) === null && betas.length === 0) return null; return nextBeta(lastStable.tag, betas.map((v) => v.tag), messages); } @@ -97,7 +103,7 @@ export async function promote({ gh, source, target, owner, repo, appSlug }) { return { action: "closed", number: pull.number }; } - const version = releaseVersion(target, await gh.listTags(), commits); + const version = releaseVersion(source, target, await gh.listTags(), commits); if (version === null) return withNumber("none"); const title = `chore(release): ${version}`; @@ -137,7 +143,8 @@ export async function promote({ gh, source, target, owner, repo, appSlug }) { if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { const { values } = parseArgs({ options: { source: { type: "string" }, target: { type: "string" } } }); - if (!values.source || !values.target) throw new Error("Usage: promote.mjs --source --target "); + values.target ??= values.source ? (promotionTarget(values.source) ?? undefined) : undefined; + if (!values.source || !values.target) throw new Error("Usage: promote.mjs --source [--target ]"); const [owner, repo] = (process.env.GITHUB_REPOSITORY ?? "").split("/"); const gh = createGitHub({ token: process.env.GH_TOKEN, owner, repo }); const result = await promote({ ...values, gh, owner, repo, appSlug: process.env.APP_SLUG }); diff --git a/.github/release/publish.mjs b/.github/release/publish.mjs index fd26eb781..deedcfff7 100644 --- a/.github/release/publish.mjs +++ b/.github/release/publish.mjs @@ -1,5 +1,6 @@ // A promotion (or hotfix) PR was merged: tag its merge commit, and after a stable or a hotfix open -// the backport PRs that bring it back to dev (main -> beta, then beta -> dev once that one is merged). The tag is pushed with the App token, so its push event runs +// the backport PRs that bring it back to dev (main -> dev while beta is skipped, else +// main -> beta, then beta -> dev once that one is merged). The tag is pushed with the App token, so its push event runs // release.yml, the single publication path (it reads the PR's notes itself). After a beta, the beta -> main // promotion is retitled by publish.yml's own job, in the promote-beta concurrency group, not here. // CLI: node .github/release/publish.mjs --number 12 @@ -10,6 +11,7 @@ import { pathToFileURL } from "node:url"; import { createGitHub } from "./lib/github.mjs"; import { refreshDashboardSafely } from "./dashboard.mjs"; import { kindOf, titleVersion } from "./ready.mjs"; +import { BETA } from "./lib/flow.mjs"; const short = (sha) => sha.slice(0, 7); const pattern = (kind) => (kind === "beta" ? "vX.Y.Z-BETA.N" : "vX.Y.Z"); @@ -62,10 +64,15 @@ async function openBackport(gh, tag, pr, from, to) { /** * Brings the release back to dev, whose version line would otherwise conflict with the next - * promotion: a stable through beta -> dev; a hotfix through main -> beta first, beta -> dev being - * opened once that one is merged (or right away when beta needs nothing). Returns the PRs opened. + * promotion. Beta skipped (lib/flow.mjs): main -> dev. Otherwise a stable through beta -> dev; a hotfix + * through main -> beta first, beta -> dev being opened once that one is merged (or right away when beta + * needs nothing). Returns the PRs opened. */ -async function openBackports(gh, tag, pr, kind) { +async function openBackports(gh, tag, pr, kind, beta) { + if (!beta && kind !== "backport") { + const toDev = await openBackport(gh, tag, pr, "main", "dev"); + return toDev?.number ? [toDev.number] : []; + } if (kind === "hotfix") { const toBeta = await openBackport(gh, tag, pr, "main", "beta"); if (toBeta !== null) return toBeta.number ? [toBeta.number] : []; @@ -74,31 +81,33 @@ async function openBackports(gh, tag, pr, kind) { return toDev?.number ? [toDev.number] : []; } -async function release(gh, pr, kind) { +async function release(gh, pr, kind, beta) { if (!pr.merged || !pr.mergeCommitSha) throw new Error(`#${pr.number} is not merged`); const tag = titleVersion(pr.title, kind); if (tag === null) throw new Error(`#${pr.number}: the title does not carry the version to publish: ${pr.title}`); await ensureTag(gh, tag, pr.mergeCommitSha, pr.title); - const backports = kind === "beta" ? [] : await openBackports(gh, tag, pr, kind); + // A stable promoted from beta goes back through beta -> dev, whatever the current chain. + const through = kind === "stable" && pr.head === "beta" ? true : beta; + const backports = kind === "beta" ? [] : await openBackports(gh, tag, pr, kind, through); return { tag, backports }; } /** * -> {tag, backports} (numbers of the backport PRs opened). Throws on an unmerged or unrelated PR; a - * failure on a release PR is also reported on it. + * failure on a release PR is also reported on it. `beta`: whether the chain goes through beta. */ -export async function publish({ gh, pr, owner, repo }) { +export async function publish({ gh, pr, owner, repo, beta = BETA }) { if (isBackportToBeta(pr, `${owner}/${repo}`)) { if (!pr.merged) throw new Error(`#${pr.number} is not merged`); const tag = BACKPORT_TITLE.exec(pr.title)?.[1]; if (!tag) throw new Error(`#${pr.number}: the title does not carry the backported version: ${pr.title}`); - return { tag, backports: await openBackports(gh, tag, pr, "stable") }; + return { tag, backports: await openBackports(gh, tag, pr, "backport", true) }; } const kind = kindOf(pr, `${owner}/${repo}`); if (kind === null) throw new Error(`#${pr.number} is not a promotion or hotfix pull request`); try { - return await release(gh, pr, kind); + return await release(gh, pr, kind, beta); } catch (error) { const version = titleVersion(pr.title, kind); if (pr.merged) { diff --git a/.github/release/ready.mjs b/.github/release/ready.mjs index 41697e07e..a3cfb2eb2 100644 --- a/.github/release/ready.mjs +++ b/.github/release/ready.mjs @@ -30,11 +30,14 @@ export function bumpGradleVersion(text, version) { return text.replace(VERSION_LINE, (_, prefix) => `${prefix}"${version}"`); } -/** Promotions dev -> beta and beta -> main, and hotfixes into main, all from this repository. */ +/** + * Promotions dev -> main (beta skipped, lib/flow.mjs), dev -> beta and beta -> main, and hotfixes into + * main, all from this repository. + */ export function kindOf(pull, repository) { if (pull.headRepo !== repository) return null; if (pull.head === "dev" && pull.base === "beta") return "beta"; - if (pull.head === "beta" && pull.base === "main") return "stable"; + if ((pull.head === "dev" || pull.head === "beta") && pull.base === "main") return "stable"; if (pull.head.startsWith("hotfix/") && pull.base === "main") return "hotfix"; return null; } @@ -164,7 +167,7 @@ async function prepare({ gh, pr, pull, kind, owner, repo, appSlug, promote, wait if (promotion) { const [tags, compared] = await Promise.all([gh.listTags(), gh.compareCommits(pull.base, pull.head)]); const promoted = compared.filter((c) => !isReleaseCommit(c, appSlug)); - const expected = releaseVersion(pull.base, tags, promoted); + const expected = releaseVersion(pull.head, pull.base, tags, promoted); if (expected !== version) return refuseAndRefresh(gh, pull, MESSAGES.staleVersion(version, expected), refresh); } diff --git a/.github/release/schedule.mjs b/.github/release/schedule.mjs index 63a9fb7bf..3c2aa86b1 100644 --- a/.github/release/schedule.mjs +++ b/.github/release/schedule.mjs @@ -1,9 +1,10 @@ // Scheduled releases. --confirm answers the owner's edits of a promotion PR: only the App's comment // makes a date active. --due lists the source branches whose date has come, and --run takes their PR // out of draft, then follows the same path as the owner doing it. -// CLI: node .github/release/schedule.mjs --confirm | --due | --run --source dev --target beta +// CLI: node .github/release/schedule.mjs --confirm | --due | --run --source dev [--target main] // (env: GH_TOKEN, GITHUB_REPOSITORY=owner/repo, APP_SLUG = the release App's slug; GITHUB_EVENT_PATH -// for --confirm, GITHUB_OUTPUT for --due) +// for --confirm, GITHUB_OUTPUT for --due). --run takes the target from lib/flow.mjs: --target is only +// a fallback, so an older release-schedule.yml (still on main until the next release) stays right. import { appendFileSync, readFileSync } from "node:fs"; import { parseArgs } from "node:util"; @@ -13,11 +14,7 @@ import { activeSchedule, formatParis, NO_SCHEDULE, parseScheduleDate, scheduleMa import { createGitHub } from "./lib/github.mjs"; import { isReleaseCommit } from "./promote.mjs"; import { kindOf, onReady } from "./ready.mjs"; - -export const PROMOTIONS = [ - { source: "dev", target: "beta" }, - { source: "beta", target: "main" }, -]; +import { BETA, promotions, promotionTarget } from "./lib/flow.mjs"; // Past this delay, the start of the release says it is late (GitHub's schedules may be delayed). export const LATE_NOTICE_MS = 60 * 60 * 1000; @@ -72,9 +69,9 @@ export async function confirm({ gh, event, owner, repo, appSlug, now = new Date( } /** The source branches of the open promotions whose scheduled date has come. */ -export async function dueSources({ gh, appSlug, now = new Date() }) { +export async function dueSources({ gh, appSlug, now = new Date(), beta = BETA }) { const due = []; - for (const { source, target } of PROMOTIONS) { + for (const { source, target } of promotions(beta)) { const [pull] = await gh.listPulls({ base: target, head: source }); if (!pull) continue; const active = activeSchedule(await gh.listComments(pull.number), appSlug); @@ -156,9 +153,10 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { const due = await dueSources({ gh, appSlug }); console.log(due); appendFileSync(process.env.GITHUB_OUTPUT, `sources=${JSON.stringify(due)}\n`); - } else if (values.run && values.source && values.target) { - console.log(await run({ gh, source: values.source, target: values.target, owner, repo, appSlug })); + } else if (values.run && values.source && (promotionTarget(values.source) ?? values.target)) { + const target = promotionTarget(values.source) ?? values.target; + console.log(await run({ gh, source: values.source, target, owner, repo, appSlug })); } else { - throw new Error("Usage: schedule.mjs --confirm | --due | --run --source --target "); + throw new Error("Usage: schedule.mjs --confirm | --due | --run --source [--target ]"); } } diff --git a/.github/release/test/dashboard.test.mjs b/.github/release/test/dashboard.test.mjs index 1b78fdc8c..fb5baf078 100644 --- a/.github/release/test/dashboard.test.mjs +++ b/.github/release/test/dashboard.test.mjs @@ -71,15 +71,17 @@ test("refresh_derivesStateFromThePullRequests", async () => { pull(1), pull(2, { draft: false }), pull(3, { draft: false, autoMerge: true, head: "beta", base: "main", title: "chore(release): v1.0.0" }), - pull(4, { draft: false, base: "main", head: "dev" }), // not a promotion pair + pull(4, { draft: false, base: "main", head: "feat/x" }), // not a promotion pair pull(5, { labels: ["backport"], head: "main", base: "beta" }), pull(6, { labels: [] }), + pull(7, { base: "main", head: "dev", title: "chore(release): v1.2.0" }), // beta skipped ], mergeStates: { 2: "DIRTY" }, }); await refreshDashboard({ gh }); const body = gh.calls.find(([name]) => name === "create")[1].body; assert.match(body, /v1\.0\.0-BETA\.1 \| draft \| #1/); + assert.match(body, /`dev` → `main` \| v1\.2\.0 \| draft \| #7/); assert.match(body, /v1\.0\.0-BETA\.2 \| en conflit \| #2/); assert.match(body, /\| v1\.0\.0 \| prête \(auto-merge\) \| #3/); assert.doesNotMatch(body, /#4|#6/); diff --git a/.github/release/test/promote.test.mjs b/.github/release/test/promote.test.mjs index 7d7306dbc..3080b82b0 100644 --- a/.github/release/test/promote.test.mjs +++ b/.github/release/test/promote.test.mjs @@ -171,6 +171,24 @@ test("betaToMain_usesTheStableOfTheLatestBeta", async () => { assert.equal(gh.calls[0][1].title, "chore(release): v1.0.0"); }); +test("devToMain_bumpsTheLastStableStraightAway", async () => { + const gh = fakeGitHub({ + tags: ["v1.0.0-BETA.102", "v1.1.0-BETA.1", "v1.1.0"], + ahead: { "main...dev": [commit("a", "feat: x"), commit("b", "fix: y")] }, + }); + assert.deepEqual(await run(gh, "dev", "main"), { action: "created", number: 100 }); + const [[, created]] = gh.calls; + assert.equal(created.title, "chore(release): v1.2.0"); + assert.equal(created.base, "main"); + assert.equal(created.head, "dev"); +}); + +test("devToMain_withoutReleasableCommit_doesNothing", async () => { + const gh = fakeGitHub({ tags: ["v1.1.0"], ahead: { "main...dev": [commit("a", "not conventional")] } }); + assert.deepEqual(await run(gh, "dev", "main"), { action: "none" }); + assert.deepEqual(gh.calls, []); +}); + test("betaToMain_withoutBeta_doesNothing", async () => { const gh = fakeGitHub({ tags: ["v1.0.0"], ahead: { "main...beta": [commit("a", "fix: x")] } }); assert.deepEqual(await run(gh, "beta", "main"), { action: "none" }); diff --git a/.github/release/test/publish.test.mjs b/.github/release/test/publish.test.mjs index 4b90b1c85..e877e677c 100644 --- a/.github/release/test/publish.test.mjs +++ b/.github/release/test/publish.test.mjs @@ -9,7 +9,7 @@ const sha = (c) => c.repeat(40); const CHANGELOG = "### Fonctionnalités\n\n- abc1234 edited by hand"; // In-memory GitHub: tags map name -> commit SHA, pulls the open ones. -function fakeGitHub({ tags = {}, pulls = [], compare = {} } = {}) { +function fakeGitHub({ tags = {}, pulls = [], compare = {}, autoMergeFails = false } = {}) { const calls = []; const record = (name, ...args) => calls.push([name, ...args]); return { @@ -32,7 +32,14 @@ function fakeGitHub({ tags = {}, pulls = [], compare = {} } = {}) { }, async createPull(fields) { record("createPull", fields); - return { number: 100 + calls.length, ...fields }; + return { number: 100 + calls.length, nodeId: "PR_N", headSha: sha("h"), ...fields }; + }, + async enableAutoMerge(pull, options) { + record("enableAutoMerge", pull.number, options); + if (autoMergeFails) throw new Error("Pull request is in clean status"); + }, + async updatePull(number, fields) { + record("updatePull", number, fields); }, async comment(number, text) { record("comment", number, text); @@ -51,7 +58,9 @@ function mergedPull({ return { number: 7, title, body, state: "closed", merged, base, head, headRepo, mergeCommitSha: sha("m") }; } -const run = (gh, pr) => publish({ gh, pr, owner: OWNER, repo: REPO }); +// `beta`: whether the chain goes through beta (lib/flow.mjs); left out, the current chain. +const run = (gh, pr, beta) => publish({ gh, pr, owner: OWNER, repo: REPO, beta }); +const withBeta = (gh, pr) => run(gh, pr, true); const named = (gh, name) => gh.calls.filter((c) => c[0] === name); const commit = (c, subject) => ({ sha: sha(c), subject, message: subject, author: OWNER, parents: [sha("0")] }); @@ -128,7 +137,7 @@ test("hotfixMerge_publishesPatch_andOpensOnlyTheBackportToBeta", async () => { "dev...beta": [commit("r", "chore(release): v1.0.1-BETA.1")], }, }); - const result = await run(gh, hotfix()); + const result = await withBeta(gh, hotfix()); assert.equal(result.tag, "v1.0.1"); assert.equal(result.backports.length, 1); assert.deepEqual(named(gh, "createTag"), [ @@ -143,14 +152,14 @@ test("hotfixBackports_skipOpenOrEmptyPairs", async () => { pulls: [{ number: 40, base: "beta", head: "main", labels: ["backport"] }], compare: { "beta...main": [commit("h", "fix: login")], "dev...beta": [commit("r", "fix: r")] }, }); - const result = await run(gh, hotfix()); + const result = await withBeta(gh, hotfix()); assert.deepEqual(result.backports, []); assert.deepEqual(named(gh, "createPull"), [], "the open main -> beta is reused; beta -> dev waits for it"); }); test("hotfixWithNothingToBringToBeta_opensTheBackportToDevRightAway", async () => { const gh = fakeGitHub({ compare: { "dev...beta": [commit("r", "fix: r")] } }); - const result = await run(gh, hotfix()); + const result = await withBeta(gh, hotfix()); assert.equal(result.backports.length, 1); assert.deepEqual(opened(gh), [backport("dev", "beta", "v1.0.1")]); }); @@ -173,6 +182,24 @@ test("stableMerge_reusesAnOpenBackportToDev", async () => { assert.deepEqual(named(gh, "createPull"), []); }); +// Beta skipped (lib/flow.mjs): dev -> main is a stable, brought back to dev straight from main. +test("devToMainMerge_tagsTheStable_andOpensTheBackportOfMainToDev", async () => { + const gh = fakeGitHub({ compare: { "dev...main": [commit("r", "chore(release): v1.2.0")] } }); + const result = await run(gh, mergedPull({ base: "main", head: "dev", title: "chore(release): v1.2.0" }), false); + assert.equal(result.tag, "v1.2.0"); + assert.deepEqual(named(gh, "createTag"), [ + ["createTag", { tag: "v1.2.0", sha: sha("m"), message: "chore(release): v1.2.0" }], + ]); + assert.deepEqual(opened(gh), [backport("dev", "main", "v1.2.0")]); +}); + +test("hotfixWithoutBeta_opensTheBackportOfMainToDev", async () => { + const gh = fakeGitHub({ compare: { "beta...main": [commit("h", "fix: login")], "dev...main": [commit("h", "fix: login")] } }); + const result = await run(gh, hotfix(), false); + assert.equal(result.backports.length, 1); + assert.deepEqual(opened(gh), [backport("dev", "main", "v1.0.1")]); +}); + test("betaMerge_opensNoBackport", async () => { const gh = fakeGitHub({ compare: { "dev...beta": [commit("r", "fix: r")] } }); await run(gh, mergedPull()); diff --git a/.github/release/test/ready.test.mjs b/.github/release/test/ready.test.mjs index 9b300b6ab..c7d3fe3c6 100644 --- a/.github/release/test/ready.test.mjs +++ b/.github/release/test/ready.test.mjs @@ -251,6 +251,20 @@ test("stablePromotion_bumpsToTheStableVersion", async () => { assert.match(file.text, /version = "1\.0\.0"\n/); }); +test("devToMainPromotion_bumpsDevToTheStableVersion", async () => { + const gh = fakeGitHub({ + base: "main", + head: "dev", + title: "chore(release): v1.2.0", + tags: ["v1.1.0-BETA.1", "v1.1.0"], + ahead: [commit("a", "feat(web): one")], + }); + assert.equal(await run(gh), "auto-merge"); + const [[, file]] = named(gh, "commitFile"); + assert.equal(file.branch, "dev"); + assert.match(file.text, /version = "1\.2\.0"\n/); +}); + test("hotfixWithoutVersionTitle_getsTheNextPatch", async () => { const tags = ["v0.0.0", "v1.0.0"]; const gh = fakeGitHub({ base: "main", head: "hotfix/login", title: "fix: login", marker: null, tags }); @@ -279,7 +293,7 @@ test("otherSender_isIgnored", async () => { }); test("unrelatedPr_isIgnored", async () => { - for (const [base, head] of [["dev", "feat/x"], ["main", "dev"], ["beta", "hotfix/x"], ["main", "feat/x"]]) { + for (const [base, head] of [["dev", "feat/x"], ["dev", "main"], ["beta", "hotfix/x"], ["main", "feat/x"]]) { const gh = fakeGitHub({ base, head }); assert.equal(await run(gh), "ignored"); assert.deepEqual(gh.calls, [["getPull", 7]]); diff --git a/.github/release/test/schedule.test.mjs b/.github/release/test/schedule.test.mjs index 63fb24b2a..bff2fe713 100644 --- a/.github/release/test/schedule.test.mjs +++ b/.github/release/test/schedule.test.mjs @@ -171,8 +171,16 @@ test("the App's own draft changes never cancel", async () => { test("a release is due only once its date has come", async () => { const gh = fakeGitHub({ comments: [said(scheduleMarker(AT, sha("a")))] }); - assert.deepEqual(await dueSources({ gh, appSlug: APP, now: new Date(AT - 1) }), []); - assert.deepEqual(await dueSources({ gh, appSlug: APP, now: AT }), ["dev"]); + assert.deepEqual(await dueSources({ gh, appSlug: APP, now: new Date(AT - 1), beta: true }), []); + assert.deepEqual(await dueSources({ gh, appSlug: APP, now: AT, beta: true }), ["dev"]); +}); + +test("beta skipped, the due release is the dev -> main promotion", async () => { + const marker = [said(scheduleMarker(AT, sha("a")))]; + const direct = fakeGitHub({ pull: { base: "main", title: "chore(release): v1.2.0" }, comments: marker }); + assert.deepEqual(await dueSources({ gh: direct, appSlug: APP, now: AT }), ["dev"]); + const viaBeta = fakeGitHub({ comments: marker }); + assert.deepEqual(await dueSources({ gh: viaBeta, appSlug: APP, now: AT }), [], "dev -> beta is not scheduled"); }); test("before the date, the run does nothing", async () => { diff --git a/.github/release/test/version.test.mjs b/.github/release/test/version.test.mjs index 1c63a2b9e..be7618548 100644 --- a/.github/release/test/version.test.mjs +++ b/.github/release/test/version.test.mjs @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { parseVersion, bumpKind, nextBeta, nextStable } from "../lib/version.mjs"; +import { parseVersion, bumpKind, nextBeta, nextDirectStable, nextStable } from "../lib/version.mjs"; test("parseVersion reads stable and beta tags", () => { assert.deepEqual(parseVersion("v1.0.0-BETA.3"), { major: 1, minor: 0, patch: 0, beta: 3 }); @@ -56,6 +56,18 @@ test("nextBeta with no new commits continues the existing beta line, else throws assert.throws(() => nextBeta("v1.0.0", [], [])); }); +test("nextDirectStable bumps the last stable from the commits, or returns null", () => { + assert.equal(nextDirectStable("v1.1.0", [], ["fix: a"]), "v1.1.1"); + assert.equal(nextDirectStable("v1.1.0", [], ["fix: a", "feat: b"]), "v1.2.0"); + assert.equal(nextDirectStable("v1.1.0", [], ["feat!: c"]), "v2.0.0"); + assert.equal(nextDirectStable("v1.1.0", [], ["docs no prefix"]), null); +}); + +test("nextDirectStable never goes below a pending beta's target", () => { + assert.equal(nextDirectStable("v1.1.0", ["v1.2.0-BETA.1"], ["fix: a"]), "v1.2.0"); + assert.equal(nextDirectStable("v1.1.0", ["v1.0.0-BETA.9"], ["fix: a"]), "v1.1.1"); +}); + test("nextStable strips the beta suffix", () => { assert.equal(nextStable("v1.1.0-BETA.3"), "v1.1.0"); assert.throws(() => nextStable("v1.1.0")); diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 70e6fcdcb..82d823acc 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -15,6 +15,8 @@ jobs: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || !( + (github.event.pull_request.head.ref == 'dev' && github.event.pull_request.base.ref == 'main') || + (github.event.pull_request.head.ref == 'main' && github.event.pull_request.base.ref == 'dev') || (github.event.pull_request.head.ref == 'dev' && github.event.pull_request.base.ref == 'beta') || (github.event.pull_request.head.ref == 'beta' && github.event.pull_request.base.ref == 'main') || (github.event.pull_request.head.ref == 'main' && github.event.pull_request.base.ref == 'beta') || diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 455d01db6..d35ea0f5b 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -1,8 +1,10 @@ name: Promote on: + # Beta is skipped (.github/release/lib/flow.mjs): dev is promoted straight to main. Put `beta` back + # here when it is brought back. push: - branches: [dev, beta] + branches: [dev] # Lets renovate-merge.yml run it on dev after a checkbox merge, which GITHUB_TOKEN pushes cannot trigger workflow_dispatch: @@ -33,5 +35,5 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} APP_SLUG: ${{ steps.app-token.outputs.app-slug }} SOURCE: ${{ github.ref_name }} - TARGET: ${{ github.ref_name == 'dev' && 'beta' || 'main' }} - run: node .github/release/promote.mjs --source "$SOURCE" --target "$TARGET" + # The target is the source's next branch in lib/flow.mjs + run: node .github/release/promote.mjs --source "$SOURCE" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 50e743892..091bd05a8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,7 +2,9 @@ name: Publish # Tags the merge commit only on an actual merge: a closed but unmerged promotion PR releases nothing. # The tag is pushed with the App token, whose pushes do trigger workflows: that tag push runs -# release.yml, the single publication path. A merged hotfix backport main -> beta opens beta -> dev. +# release.yml, the single publication path. A stable or a hotfix opens its backport to dev: +# main -> dev while beta is skipped (.github/release/lib/flow.mjs); with beta, a merged hotfix +# backport main -> beta opens beta -> dev. on: pull_request: types: [closed] @@ -20,6 +22,7 @@ jobs: github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository && ( + (github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev') || (github.event.pull_request.base.ref == 'beta' && github.event.pull_request.head.ref == 'dev') || (github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'beta') || (github.event.pull_request.base.ref == 'main' && startsWith(github.event.pull_request.head.ref, 'hotfix/')) || diff --git a/.github/workflows/release-ready.yml b/.github/workflows/release-ready.yml index ea591e484..5723e1d43 100644 --- a/.github/workflows/release-ready.yml +++ b/.github/workflows/release-ready.yml @@ -2,7 +2,8 @@ name: Release ready # `pull_request` rather than `pull_request_target`: `main` holds no workflow until the first stable # release, so a target-based trigger would never fire for beta -> main. The job only runs for the -# owner on this repository's release branches, and it runs dev's scripts. +# owner on this repository's release branches, and it runs dev's scripts. Beta is skipped for now +# (.github/release/lib/flow.mjs): the promotion is dev -> main; the beta pairs stay accepted. on: pull_request: types: [ready_for_review] @@ -21,6 +22,7 @@ jobs: github.event.sender.login == github.repository_owner && github.event.pull_request.head.repo.full_name == github.repository && ( + (github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev') || (github.event.pull_request.base.ref == 'beta' && github.event.pull_request.head.ref == 'dev') || (github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'beta') || (github.event.pull_request.base.ref == 'main' && startsWith(github.event.pull_request.head.ref, 'hotfix/')) diff --git a/.github/workflows/release-schedule.yml b/.github/workflows/release-schedule.yml index c9fdc89a9..b610d7ee4 100644 --- a/.github/workflows/release-schedule.yml +++ b/.github/workflows/release-schedule.yml @@ -18,6 +18,7 @@ jobs: github.event.sender.login == github.repository_owner && github.event.pull_request.head.repo.full_name == github.repository && ( + (github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'dev') || (github.event.pull_request.base.ref == 'beta' && github.event.pull_request.head.ref == 'dev') || (github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'beta') ) @@ -98,5 +99,5 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} APP_SLUG: ${{ steps.app-token.outputs.app-slug }} SOURCE: ${{ matrix.source }} - TARGET: ${{ matrix.source == 'dev' && 'beta' || 'main' }} - run: node .github/release/schedule.mjs --run --source "$SOURCE" --target "$TARGET" + # The target is the source's next branch in lib/flow.mjs (dev -> main while beta is skipped) + run: node .github/release/schedule.mjs --run --source "$SOURCE" From 353eaafbfdda7bdd624a20f3d66898a8ab321a46 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:37:36 +0100 Subject: [PATCH 37/53] chore(release): auto-merge the backports that bring a release back to dev The bot enables auto-merge (merge commit, pinned to the head it opened) on each backport it opens, so dev catches up with main as soon as the checks pass instead of waiting for a manual merge. If GitHub refuses it, the PR stays open and its body asks for the manual merge as before. --- .github/release/publish.mjs | 32 +++++++++++++++++++++------ .github/release/test/publish.test.mjs | 20 +++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/.github/release/publish.mjs b/.github/release/publish.mjs index deedcfff7..9800ee02d 100644 --- a/.github/release/publish.mjs +++ b/.github/release/publish.mjs @@ -1,5 +1,5 @@ // A promotion (or hotfix) PR was merged: tag its merge commit, and after a stable or a hotfix open -// the backport PRs that bring it back to dev (main -> dev while beta is skipped, else +// the backport PRs that bring it back to dev, with auto-merge (main -> dev while beta is skipped, else // main -> beta, then beta -> dev once that one is merged). The tag is pushed with the App token, so its push event runs // release.yml, the single publication path (it reads the PR's notes itself). After a beta, the beta -> main // promotion is retitled by publish.yml's own job, in the promote-beta concurrency group, not here. @@ -34,31 +34,49 @@ function isBackportToBeta(pr, repository) { return pr.headRepo === repository && pr.head === "main" && pr.base === "beta" && pr.labels?.includes("backport"); } -function backportBody(tag, pr, from, to) { +function backportBody(tag, pr, from, to, autoMerge) { return ( `Report de \`${tag}\` (#${pr.number}) de \`${from}\` vers \`${to}\`.\n\n` + - `Fusionne cette PR par commit de merge pour ramener la release sur \`${to}\` ; tant qu'elle est ` + - `ouverte, la promotion depuis et vers \`${to}\` est refusée.\n` + (autoMerge + ? `L'auto-merge est activé : la PR se fusionne d'elle-même (commit de merge) dès que les checks passent` + : `Fusionne cette PR par commit de merge pour ramener la release sur \`${to}\``) + + ` ; tant qu'elle est ouverte, la promotion depuis et vers \`${to}\` est refusée.\n` ); } +/** Auto-merge (merge commit) pinned to the backport's head; a failure leaves the merge to the owner. */ +async function autoMergeBackport(gh, created, title) { + try { + await gh.enableAutoMerge(created, { expectedHeadOid: created.headSha, commitHeadline: title }); + return true; + } catch (error) { + console.warn(`::warning::#${created.number}: could not enable auto-merge: ${error.message}`); + return false; + } +} + /** - * Opens the backport PR `from` -> `to` unless one is open (it is reused) or there is nothing to bring. + * Opens the backport PR `from` -> `to`, with auto-merge, unless one is open (it is reused) or there is + * nothing to bring. * -> {number} when opened, {pending: true} when one is already open, null when there is nothing to bring. */ async function openBackport(gh, tag, pr, from, to) { const [open, commits] = await Promise.all([gh.listPulls({ base: to, head: from }), gh.compareCommits(to, from)]); if (open.length) return { pending: true }; if (commits.length === 0) return null; + const title = `chore(release): backport ${tag} to ${to}`; const created = await gh.createPull({ - title: `chore(release): backport ${tag} to ${to}`, + title, base: to, head: from, draft: false, labels: ["backport"], - body: backportBody(tag, pr, from, to), + body: backportBody(tag, pr, from, to, true), }); console.log(`opened the backport #${created.number} ${from} -> ${to}`); + if (!(await autoMergeBackport(gh, created, title))) { + await gh.updatePull(created.number, { body: backportBody(tag, pr, from, to, false) }); + } return { number: created.number }; } diff --git a/.github/release/test/publish.test.mjs b/.github/release/test/publish.test.mjs index e877e677c..4606a4787 100644 --- a/.github/release/test/publish.test.mjs +++ b/.github/release/test/publish.test.mjs @@ -182,6 +182,25 @@ test("stableMerge_reusesAnOpenBackportToDev", async () => { assert.deepEqual(named(gh, "createPull"), []); }); +test("backport_isOpenedWithAutoMergePinnedToItsHead", async () => { + const gh = fakeGitHub({ compare: { "dev...beta": [commit("r", "chore(release): v1.0.0")] } }); + const [number] = (await run(gh, mergedPull({ base: "main", head: "beta", title: "chore(release): v1.0.0" }))).backports; + assert.deepEqual(named(gh, "enableAutoMerge"), [ + ["enableAutoMerge", number, { expectedHeadOid: sha("h"), commitHeadline: "chore(release): backport v1.0.0 to dev" }], + ]); + assert.match(named(gh, "createPull")[0][1].body, /auto-merge est activé/); + assert.deepEqual(named(gh, "updatePull"), []); +}); + +test("backportWhoseAutoMergeFails_isLeftToTheOwner", async () => { + const gh = fakeGitHub({ compare: { "dev...main": [commit("r", "fix: r")] }, autoMergeFails: true }); + const result = await run(gh, mergedPull({ base: "main", head: "dev", title: "chore(release): v1.2.0" }), false); + assert.equal(result.backports.length, 1, "the backport stays open"); + const [[, number, { body }]] = named(gh, "updatePull"); + assert.equal(number, result.backports[0]); + assert.match(body, /Fusionne cette PR par commit de merge/); +}); + // Beta skipped (lib/flow.mjs): dev -> main is a stable, brought back to dev straight from main. test("devToMainMerge_tagsTheStable_andOpensTheBackportOfMainToDev", async () => { const gh = fakeGitHub({ compare: { "dev...main": [commit("r", "chore(release): v1.2.0")] } }); @@ -191,6 +210,7 @@ test("devToMainMerge_tagsTheStable_andOpensTheBackportOfMainToDev", async () => ["createTag", { tag: "v1.2.0", sha: sha("m"), message: "chore(release): v1.2.0" }], ]); assert.deepEqual(opened(gh), [backport("dev", "main", "v1.2.0")]); + assert.equal(named(gh, "enableAutoMerge").length, 1); }); test("hotfixWithoutBeta_opensTheBackportOfMainToDev", async () => { From 89bb8600c256458ecefa1bd1c5a2a962addfdeb3 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:38:22 +0100 Subject: [PATCH 38/53] ci: run the checks on pull requests only and cancel superseded runs A push to a branch with an open pull request ran build, test, commitlint and release-scripts twice (push, then pull_request). The four workflows now run on pull requests only, into any base so stacked PRs keep their checks: dev's head is covered by its dev -> main promotion PR, main's by the backport main -> dev, and renovate-merge.yml still dispatches build and test on dev. A new push to a pull request cancels the run it supersedes. The obsolete master branch filter is gone. --- .github/workflows/build.yml | 16 ++++++++-------- .github/workflows/commitlint.yml | 11 ++++++----- .github/workflows/release-scripts.yml | 9 ++++++--- .github/workflows/test.yml | 16 ++++++++-------- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b3f81a99c..b4b29799c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,17 +1,17 @@ name: Build +# Pull requests (into any branch, stacked ones included) rather than `push`: a push to a branch with +# an open PR ran the same job twice. dev's head is checked by its dev -> main promotion PR, main's by +# the backport main -> dev, and a version tag push runs release.yml. on: - # Branches only: a version tag push runs release.yml, not the checks again - push: - branches: ['**'] # Lets renovate-merge.yml run it on dev after a checkbox merge, which GITHUB_TOKEN pushes cannot trigger workflow_dispatch: pull_request: - branches: - - master # until it is renamed dev - - dev - - beta - - main + +# A new push to a pull request cancels its previous run; a dispatched run is never cancelled. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: build: diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 82d823acc..36e8ce4f4 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -1,18 +1,19 @@ name: Commit lint +# Pull requests only: the commits pushed to dev were linted in their pull request. on: pull_request: - push: - branches: - - master # until it is renamed dev - - dev + +# A new push to a pull request cancels its previous run; a dispatched run is never cancelled. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: commitlint: # Promotion and backport PRs carry commits already on a long-lived branch (old subjects, merge # commits): their job is skipped, and a skipped job still satisfies the required check. if: >- - github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository || !( (github.event.pull_request.head.ref == 'dev' && github.event.pull_request.base.ref == 'main') || diff --git a/.github/workflows/release-scripts.yml b/.github/workflows/release-scripts.yml index d65c915ff..f8bb0242f 100644 --- a/.github/workflows/release-scripts.yml +++ b/.github/workflows/release-scripts.yml @@ -1,10 +1,13 @@ name: Release scripts +# Pull requests only, no `push`: a branch push with an open PR would run the same job twice. on: pull_request: - # Branches only: a version tag push runs release.yml, not the checks again - push: - branches: ['**'] + +# A new push to a pull request cancels its previous run; a dispatched run is never cancelled. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: # Not `test`: ready.mjs requires the check named `test` (Tests workflow) before merging a release. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6ad5af7ef..92ffc3aba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,17 +1,17 @@ name: Tests +# Pull requests (into any branch, stacked ones included) rather than `push`: a push to a branch with +# an open PR ran the same job twice. dev's head is checked by its dev -> main promotion PR, main's by +# the backport main -> dev, and a version tag push runs release.yml. on: - # Branches only: a version tag push runs release.yml, not the checks again - push: - branches: ['**'] # Lets renovate-merge.yml run it on dev after a checkbox merge, which GITHUB_TOKEN pushes cannot trigger workflow_dispatch: pull_request: - branches: - - master # until it is renamed dev - - dev - - beta - - main + +# A new push to a pull request cancels its previous run; a dispatched run is never cancelled. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: test: From 902924011c86210fde8b7fda16f16b311132d403 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:38:27 +0100 Subject: [PATCH 39/53] fix(api): leave the query string out of the http.client.requests uri tag The Helix calls concatenate the streamer's id into the query, which Spring takes as the URI template: one uri tag per streamer until Micrometer's cap (100) dropped every new one. Tag the template's path only. --- .../HttpClientUriTagConvention.java | 23 +++++++++ .../HttpClientUriTagConventionTest.java | 50 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/monitoring/HttpClientUriTagConvention.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/monitoring/HttpClientUriTagConventionTest.java diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/monitoring/HttpClientUriTagConvention.java b/catapult-api/src/main/java/fr/enimaloc/catapult/monitoring/HttpClientUriTagConvention.java new file mode 100644 index 000000000..e0dc30ffc --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/monitoring/HttpClientUriTagConvention.java @@ -0,0 +1,23 @@ +package fr.enimaloc.catapult.monitoring; + +import io.micrometer.common.KeyValue; +import org.springframework.http.client.observation.ClientRequestObservationContext; +import org.springframework.http.client.observation.DefaultClientRequestObservationConvention; +import org.springframework.stereotype.Component; + +/** + * Spring's {@code http.client.requests} tags, the {@code uri} without its query string: many calls build theirs + * by concatenation ("/helix/channels?broadcaster_id=" + id), which Spring takes as the template, so each + * streamer made a tag of its own until Micrometer's cap dropped every new uri. Picked up by Spring Boot's + * RestClient observation customizer in place of its default. + */ +@Component +public class HttpClientUriTagConvention extends DefaultClientRequestObservationConvention { + + @Override + protected KeyValue uri(ClientRequestObservationContext context) { + KeyValue uri = super.uri(context); + int query = uri.getValue().indexOf('?'); + return query < 0 ? uri : KeyValue.of(uri.getKey(), uri.getValue().substring(0, query)); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/monitoring/HttpClientUriTagConventionTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/monitoring/HttpClientUriTagConventionTest.java new file mode 100644 index 000000000..150706888 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/monitoring/HttpClientUriTagConventionTest.java @@ -0,0 +1,50 @@ +package fr.enimaloc.catapult.monitoring; + +import io.micrometer.common.KeyValue; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.observation.ClientRequestObservationContext; +import org.springframework.mock.http.client.MockClientHttpRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpClientUriTagConventionTest { + + private final HttpClientUriTagConvention convention = new HttpClientUriTagConvention(); + + /* + * The Helix calls put the streamer's id in the query ("/helix/channels?broadcaster_id=" + id): one uri tag + * per streamer, until Micrometer's cap (100) drops every new uri, seen in production. + */ + @Test + void theQueryString_isLeftOutOfTheUriTag() { + assertThat(uriTag("https://api.twitch.tv/helix/channels?broadcaster_id=12345")) + .isEqualTo("/helix/channels"); + assertThat(uriTag("https://api.twitch.tv/helix/games?name={name}")).isEqualTo("/helix/games"); + } + + @Test + void aTemplateWithoutQuery_isTaggedAsBefore() { + assertThat(uriTag("https://api.twitch.tv/helix/users/extensions")).isEqualTo("/helix/users/extensions"); + assertThat(uriTag("campaigns/{id}/")).isEqualTo("/campaigns/{id}/"); + } + + @Test + void aRequestWithoutTemplate_isTaggedNone() { + assertThat(uriTag(null)).isEqualTo(KeyValue.NONE_VALUE); + } + + @Test + void keepsSpringsObservationName() { + assertThat(convention.getName()).isEqualTo("http.client.requests"); + } + + private String uriTag(String template) { + ClientRequestObservationContext context = new ClientRequestObservationContext( + new MockClientHttpRequest(HttpMethod.GET, "https://api.twitch.tv/helix/channels")); + context.setUriTemplate(template); + return convention.getLowCardinalityKeyValues(context).stream() + .filter(keyValue -> keyValue.getKey().equals("uri")) + .findFirst().orElseThrow().getValue(); + } +} From 3a1bc1cb54b60804c888bade818b35b7f69c32c3 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:40:07 +0100 Subject: [PATCH 40/53] fix(api): never take a Twitch website URL for a binding's category id IgdbWebsiteKeys keeps a game's Twitch website URL under "twitch" when IGDB has no Twitch external_game, and the binding used it as the game_id: Twitch then refused every update ("The ID in game_id is not valid"). Only accept a numeric id there, falling back to IGDB's lookup and the name search; log the game_id sent with Twitch's errors. --- .../service/binding/BindingService.java | 8 +++++- .../service/twitch/TwitchServiceImpl.java | 3 +- .../service/binding/BindingServiceTest.java | 28 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java index a3b71849a..bf32f9700 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/binding/BindingService.java @@ -302,7 +302,8 @@ private GameBinding updateWithoutMapping(UserAccount user, DetectedGame detected String twitchId = igdbGameDetailsService.getDetails(igdbId) .map(IgdbGameDetails::getWebsites) .map(websites -> websites.get("twitch")) - .filter(id -> !id.isBlank()) + // A category id (external_games' uid), not a Twitch website's URL kept under the same key + .filter(BindingService::isTwitchCategoryId) .or(() -> igdbService.findTwitchGameId(igdbId)) .or(() -> twitchService.findCategoryIdByName(user, gameName)) .orElse(null); @@ -337,6 +338,11 @@ private GameBinding updateWithoutMapping(UserAccount user, DetectedGame detected return gameBindingRepository.save(binding); } + /** Twitch's category ids are numeric; anything else (a website URL) is refused as the PATCH's game_id. */ + static boolean isTwitchCategoryId(String id) { + return !id.isEmpty() && id.chars().allMatch(c -> c >= '0' && c <= '9'); + } + private Optional resolveViaIgdb(DetectedGame detectedGame) { return detectedGame.getSourceId() != null ? igdbService.findByExternalAppId(detectedGame.getSourceType(), detectedGame.getSourceId()) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/twitch/TwitchServiceImpl.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/twitch/TwitchServiceImpl.java index 7bbb6a3e1..a1337f257 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/twitch/TwitchServiceImpl.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/twitch/TwitchServiceImpl.java @@ -214,7 +214,8 @@ private ChannelUpdateOutcome doUpdateChannel(UserAccount user, GameBinding bindi if (e.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS) { log.warn("Twitch rate limit hit for user {} — will retry next cycle", user.getId()); } else { - log.error("Twitch API error for user {}: {} {}", user.getId(), e.getStatusCode(), e.getMessage()); + log.error("Twitch API error for user {} (game_id={}): {} {}", + user.getId(), binding.getTwitchGameId(), e.getStatusCode(), e.getMessage()); } throw refused[0] = e; } catch (RuntimeException e) { diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceTest.java index f05e71b64..917b30b51 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/binding/BindingServiceTest.java @@ -261,6 +261,34 @@ void resolveOrCreate_usesIgdbExternalGamesTwitchId_beforeNameMatching() { verifyNoInteractions(twitchService); } + /* + * A game without a Twitch external_game but with a Twitch website keeps that website's URL under "twitch" + * (IgdbWebsiteKeys#links): sent as the game_id, Twitch refused every update ("The ID in game_id is not valid"). + */ + @Test + void resolveOrCreate_ignoresATwitchWebsiteUrl_forTheCategoryId() { + DetectedGame game = new DetectedGame("steam-123", GameBinding.SourceType.STEAM, "Portal"); + when(gameBindingRepository.findByUserAndSourceIdAndSourceType(user, "steam-123", GameBinding.SourceType.STEAM)) + .thenReturn(Optional.empty()); + IgdbService.IgdbGame igdbGame = new IgdbService.IgdbGame("1234", "Portal"); + when(igdbService.findByExternalAppId(GameBinding.SourceType.STEAM, "steam-123")).thenReturn(Optional.of(igdbGame)); + + IgdbGameDetails details = new IgdbGameDetails(); + details.setIgdbId("1234"); + details.setWebsites(Map.of("twitch", "https://www.twitch.tv/valve")); + when(igdbGameDetailsService.getDetails("1234")).thenReturn(Optional.of(details)); + when(igdbService.findTwitchGameId("1234")).thenReturn(Optional.empty()); + when(twitchService.findCategoryIdByName(user, "Portal")).thenReturn(Optional.of("6187")); + + when(igdbService.suggestCcls("1234")).thenReturn(Set.of()); + when(gameBindingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + GameBinding result = bindingService.resolveOrCreate(user, game); + + assertThat(result.getStatus()).isEqualTo(GameBinding.Status.AUTO); + assertThat(result.getTwitchGameId()).isEqualTo("6187"); + } + @Test void resolveOrCreate_noExisting_igdbNotFound_createsIncompleteBinding() { DetectedGame game = new DetectedGame(null, GameBinding.SourceType.MANUAL, "Unknown Game"); From 4465212ee669bfa125e903eb1bcc17ede3e237a0 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:40:25 +0100 Subject: [PATCH 41/53] feat(ratelimit): throttle routes by learned limits with live priority --- .../ratelimit/LearnedRateLimitProperties.java | 31 ++ .../ratelimit/LearnedRateLimiter.java | 289 ++++++++++++++++ .../catapult/ratelimit/LimitEstimator.java | 15 +- .../catapult/ratelimit/RateLimitScope.java | 239 +++++++++++++ .../ratelimit/LearnedRateLimiterTest.java | 320 ++++++++++++++++++ .../catapult/ratelimit/LearningTest.java | 11 + .../admin/LearnedRateLimitCorrectionDto.java | 9 + .../common/dto/admin/LearnedRateLimitDto.java | 23 ++ 8 files changed, 931 insertions(+), 6 deletions(-) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitProperties.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitScope.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java create mode 100644 catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/LearnedRateLimitCorrectionDto.java create mode 100644 catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/LearnedRateLimitDto.java diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitProperties.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitProperties.java new file mode 100644 index 000000000..435ff9f9c --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitProperties.java @@ -0,0 +1,31 @@ +package fr.enimaloc.catapult.ratelimit; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * The learned rate limits ({@code rate-limit.learning.*}): the hosts left to their documented limits + * ({@code *.example.com} for every subdomain), the share of a limit bulk work may use, how long a live call may wait, + * how long without a 429 before a limit is raised, and before a failed resume delay is forgotten. + */ +@Component +@ConfigurationProperties(prefix = "rate-limit.learning") +@Getter +@Setter +public class LearnedRateLimitProperties { + + private boolean enabled = true; + private List excludedHosts = new ArrayList<>(List.of("api.steampowered.com", "api.twitch.tv", + "id.twitch.tv", "api.igdb.com", "www.doesthedogdie.com", "git.enimaloc.fr", "oauth.battle.net", + "*.battle.net", "*.api.blizzard.com")); + private double bulkMargin = 0.8; + private Duration liveMaxWait = Duration.ofSeconds(2); + private Duration raiseAfter = Duration.ofHours(1); + private Duration forgetFailAfter = Duration.ofHours(24); +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java new file mode 100644 index 000000000..f6b46e88c --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java @@ -0,0 +1,289 @@ +package fr.enimaloc.catapult.ratelimit; + +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitDto; +import fr.enimaloc.catapult.domain.ratelimit.LearnedRateLimit; +import fr.enimaloc.catapult.ratelimit.LimitEstimator.Estimate; +import fr.enimaloc.catapult.ratelimit.RateLimitScope.Kind; +import fr.enimaloc.catapult.repository.ratelimit.LearnedRateLimitRepository; +import lombok.AccessLevel; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Rate limits the platforms don't document, guessed from their 429s and kept to before calling them: a route counts + * in its host's budget until it shows a limit of its own. A live call passes while under the limit and gives up + * after a short wait; bulk work keeps a margin under it and waits its turn. What is learned is stored, shared by the + * instances, and can be corrected or reset by an admin. + */ +@Slf4j +@Component +public class LearnedRateLimiter { + + /** The header a refusal made here (no call sent) carries: never learned from. */ + public static final String LOCAL_REFUSAL_HEADER = "X-Catapult-Local-Limit"; + private static final long SLICE_MS = 1_000L; + + interface Sleeper { + void sleep(long millis) throws InterruptedException; + } + + private final LearnedRateLimitProperties props; + private final LearnedRateLimitRepository repository; + private final Clock clock; + private final Map scopes = new ConcurrentHashMap<>(); + + @Setter(AccessLevel.PACKAGE) + private Sleeper sleeper = Thread::sleep; + + public LearnedRateLimiter(LearnedRateLimitProperties props, LearnedRateLimitRepository repository, Clock clock) { + this.props = props; + this.repository = repository; + this.clock = clock; + } + + /** Whether calls to {@code host} are learned from (not one of the hosts left to their documented limits). */ + public boolean learns(String host) { + if (!props.isEnabled() || host == null || host.isEmpty()) return false; + String h = host.toLowerCase(Locale.ROOT); + for (String excluded : props.getExcludedHosts()) { + String e = excluded.trim().toLowerCase(Locale.ROOT); + if (e.startsWith("*.") ? h.endsWith(e.substring(1)) : h.equals(e)) return false; + } + return true; + } + + /** + * Waits until the route may be called, counting the call in. Refuses with a local 429 (carrying + * {@link #LOCAL_REFUSAL_HEADER}) when a live call would wait longer than allowed, or bulk work was cancelled. + */ + public void acquire(RouteKey key) { + if (!learns(key.host())) return; + RateLimitScope route = route(key); + RateLimitScope host = host(key.host()); + boolean bulk = RateLimitPriority.current() == RateLimitPriority.BULK; + Instant deadline = bulk ? null : clock.instant().plus(props.getLiveMaxWait()); + while (true) { + Instant now = clock.instant(); + RateLimitScope budget = route.kind() == Kind.ROUTE ? route : host; + boolean[] raised = {false}; + Optional retryAt = budget.admit(now, bulk, props.getBulkMargin(), raised); + if (raised[0]) save(budget); + if (retryAt.isEmpty()) { + if (budget != route) route.record(now); + return; + } + Instant at = retryAt.get(); + if (deadline != null && at.isAfter(deadline)) throw refusal(now, at); + if (bulk && RateLimitPriority.cancelled()) throw refusal(now, at); + try { + sleeper.sleep(Math.max(1, Math.min(SLICE_MS, Duration.between(now, at).toMillis()))); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw refusal(now, at); + } + } + } + + /** What the platform answered a call {@link #acquire}d for: learns from a 429, ends a probe on an answer. */ + public void onResponse(RouteKey key, HttpStatusCode status, HttpHeaders headers) { + if (!learns(key.host())) return; + if (headers != null && headers.containsHeader(LOCAL_REFUSAL_HEADER)) return; + RateLimitScope route = route(key); + RateLimitScope host = host(key.host()); + Instant now = clock.instant(); + if (status.value() != HttpStatus.TOO_MANY_REQUESTS.value()) { + host.answered(route.id(), now); + RateLimitScope budget = route.kind() == Kind.ROUTE ? route : host; + if (budget.onAnswer(now)) save(budget); + return; + } + Optional retryAfter = retryAfter(headers == null ? null : headers.getFirst(HttpHeaders.RETRY_AFTER), now); + if (route.kind() == Kind.HOST && ownLimitShown(route, host, now) && route.splitOff()) { + log.info("Learned rate limits: {} has a limit of its own, apart from {}", route.id(), host.id()); + } + if (route.kind() == Kind.ROUTE) { + route.on429(now, retryAfter); + } else { + route.countRateLimited(now); + host.on429(now, retryAfter); + save(host); + } + save(route); + log.warn("Rate limited (429) on {}: left alone until {}", route.id(), + (route.kind() == Kind.ROUTE ? route : host).blockedUntil(now).orElse(now)); + } + + /** + * A 429 while the host's budget is well under its learned limit and another of its routes is still answered: + * the route's limit is its own. + */ + private boolean ownLimitShown(RateLimitScope route, RateLimitScope host, Instant now) { + Optional limit = host.limit(); + if (limit.isEmpty()) return false; + Duration window = limit.get().window(); + return host.used(now) * 2 < limit.get().calls() + && host.otherRouteAnsweredSince(route.id(), now.minus(window)); + } + + /** Until when calls to this GET route wait after a 429 (its own block, or its host's). */ + public Optional blockedUntil(String host, String path) { + if (!learns(host)) return Optional.empty(); + RateLimitScope route = route(new RouteKey(host.toLowerCase(Locale.ROOT), "GET", path)); + RateLimitScope budget = route.kind() == Kind.ROUTE ? route : host(host.toLowerCase(Locale.ROOT)); + return budget.blockedUntil(clock.instant()); + } + + /** Every scope learned or seen: hosts and their routes, stored ones included, by host then route. */ + public List list() { + Instant now = clock.instant(); + Map out = new TreeMap<>(); + try { + for (LearnedRateLimit e : repository.findAll()) { + RateLimitScope scope = scopes.computeIfAbsent(e.getId(), id -> fromEntity(e)); + out.put(e.getId(), scope.toDto(now)); + } + } catch (RuntimeException ex) { + log.warn("Learned rate limits: the stored ones could not be read: {}", ex.getMessage()); + } + scopes.values().forEach(s -> out.putIfAbsent(s.id(), s.toDto(now))); + return out.values().stream() + .sorted(Comparator.comparing(LearnedRateLimitDto::host) + .thenComparing(d -> d.isHost() ? "" : d.path() + " " + d.method())) + .toList(); + } + + /** + * An admin's correction of a scope (404 if unknown): the limit ({@code calls} per {@code windowSeconds}, both or + * neither), the resume delay, a route's scope; null leaves that part to the learning. + */ + public LearnedRateLimitDto correct(String id, Integer calls, Integer windowSeconds, Integer resumeSeconds, + String scope) { + RateLimitScope s = known(id); + if ((calls == null) != (windowSeconds == null) || (calls != null && (calls < 1 || windowSeconds < 1)) + || (resumeSeconds != null && resumeSeconds < 1)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid correction"); + } + Kind kind; + try { + kind = scope == null || scope.isBlank() ? null : Kind.valueOf(scope.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown scope " + scope); + } + s.correct(calls == null ? null : new Estimate(calls, Duration.ofSeconds(windowSeconds)), + resumeSeconds == null ? null : Duration.ofSeconds(resumeSeconds), kind); + save(s); + return s.toDto(clock.instant()); + } + + /** Forgets what was learned and corrected of a scope (404 if unknown). */ + public LearnedRateLimitDto reset(String id) { + RateLimitScope s = known(id); + s.reset(); + save(s); + return s.toDto(clock.instant()); + } + + /** Takes what the other instances learned or an admin corrected meanwhile. */ + @Scheduled(fixedDelayString = "${rate-limit.learning.sync-every:60s}") + public void sync() { + try { + for (LearnedRateLimit e : repository.findAll()) { + RateLimitScope scope = scopes.get(e.getId()); + if (scope != null) scope.apply(e); + } + } catch (RuntimeException ex) { + log.warn("Learned rate limits: could not sync with the stored ones: {}", ex.getMessage()); + } + } + + private RateLimitScope known(String id) { + RateLimitScope s = scopes.get(id); + if (s != null) return s; + return repository.findById(id).map(e -> scopes.computeIfAbsent(id, k -> fromEntity(e))) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Unknown rate limit " + id)); + } + + private RateLimitScope host(String host) { + return scope(RateLimitScope.hostId(host), () -> new RateLimitScope(host, null, props)); + } + + private RateLimitScope route(RouteKey key) { + return scope(key.id(), () -> new RateLimitScope(key.host(), key, props)); + } + + /** The scope, loaded from what was stored the first time it is needed, stored when new. */ + private RateLimitScope scope(String id, java.util.function.Supplier fresh) { + RateLimitScope existing = scopes.get(id); + if (existing != null) return existing; + Optional stored; + try { + stored = repository.findById(id); + } catch (RuntimeException ex) { + log.warn("Learned rate limits: {} could not be read: {}", id, ex.getMessage()); + stored = Optional.empty(); + } + RateLimitScope created = fresh.get(); + stored.ifPresent(created::apply); + RateLimitScope winner = scopes.putIfAbsent(id, created); + if (winner != null) return winner; + if (stored.isEmpty()) save(created); + return created; + } + + private RateLimitScope fromEntity(LearnedRateLimit e) { + RateLimitScope s = e.getPath() == null + ? new RateLimitScope(e.getHost(), null, props) + : new RateLimitScope(e.getHost(), new RouteKey(e.getHost(), e.getMethod(), e.getPath()), props); + s.apply(e); + return s; + } + + private void save(RateLimitScope scope) { + try { + repository.save(scope.toEntity(clock.instant())); + } catch (RuntimeException ex) { + log.warn("Learned rate limits: {} could not be stored: {}", scope.id(), ex.getMessage()); + } + } + + private static HttpClientErrorException refusal(Instant now, Instant until) { + HttpHeaders headers = new HttpHeaders(); + headers.set(LOCAL_REFUSAL_HEADER, "true"); + headers.set(HttpHeaders.RETRY_AFTER, String.valueOf(Math.max(1, Duration.between(now, until).toSeconds()))); + return HttpClientErrorException.create("Learned rate limit reached (no call sent)", + HttpStatus.TOO_MANY_REQUESTS, "Too Many Requests", headers, new byte[0], StandardCharsets.UTF_8); + } + + /** A Retry-After header: seconds or an HTTP date. */ + public static Optional retryAfter(String header, Instant now) { + if (header == null || header.isBlank()) return Optional.empty(); + String value = header.trim(); + try { + return Optional.of(Duration.ofSeconds(Math.max(1, Long.parseLong(value)))); + } catch (NumberFormatException notSeconds) { + try { + Instant at = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant(); + return Optional.of(at.isAfter(now) ? Duration.between(now, at) : Duration.ofSeconds(1)); + } catch (DateTimeParseException notADate) { + return Optional.empty(); + } + } + } +} diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LimitEstimator.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LimitEstimator.java index eea023d16..c47cf640c 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LimitEstimator.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LimitEstimator.java @@ -61,23 +61,26 @@ Optional estimate() { /** * A 429: the window is the shortest one at least as long as the wait the platform asks or the one learned - * ({@code resumeHint}), the limit the calls sent in it before the refused one. Never loosened by a 429. + * ({@code resumeHint}), the limit the calls sent in it before the refused one. Never loosened by a 429, and at + * most halved by one in the same window: a 429 after few calls tells more of a longer window than of a tiny limit. */ void on429(Instant now, Duration resumeHint) { last429 = now; Duration window = WINDOWS.stream().filter(w -> w.compareTo(resumeHint) >= 0).findFirst().orElse(KEPT); int limit = Math.max(1, countWithin(window, now) - 1); - if (estimate == null || !estimate.window().equals(window) || limit < estimate.calls()) { - estimate = estimate != null && estimate.window().equals(window) - ? new Estimate(Math.min(limit, estimate.calls()), window) - : new Estimate(limit, window); + if (estimate != null && estimate.window().equals(window)) { + int halved = (estimate.calls() + 1) / 2; + estimate = new Estimate(Math.min(estimate.calls(), Math.max(limit, halved)), window); + } else { + estimate = new Estimate(limit, window); } } /** Raises the limit by a tenth (at least one call) when it was nearly reached for long without a 429. */ void maybeRaise(Instant now) { if (estimate == null) return; - Instant since = last429 == null ? lastRaise : (lastRaise == null || last429.isAfter(lastRaise) ? last429 : lastRaise); + Instant since = last429 == null ? lastRaise + : lastRaise == null || last429.isAfter(lastRaise) ? last429 : lastRaise; if (since != null && since.plus(raiseAfter).isAfter(now)) return; if (countWithin(estimate.window(), now) * 10 < estimate.calls() * 8) return; estimate = new Estimate(estimate.calls() + Math.max(1, estimate.calls() / 10), estimate.window()); diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitScope.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitScope.java new file mode 100644 index 000000000..9083fa0a2 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitScope.java @@ -0,0 +1,239 @@ +package fr.enimaloc.catapult.ratelimit; + +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitDto; +import fr.enimaloc.catapult.domain.ratelimit.LearnedRateLimit; +import fr.enimaloc.catapult.ratelimit.LimitEstimator.Estimate; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * One scope's learning and state, guarded by itself: a host (the budget its {@link Kind#HOST} routes share) or a + * route. Only the scope a call is counted in (its route when {@link Kind#ROUTE}, else its host) blocks and learns; + * a route in its host's budget keeps its own 429 count and calls for the admin tab. + */ +final class RateLimitScope { + + /** A route's budget: its host's, or its own. */ + enum Kind { HOST, ROUTE } + + private final String id; + private final String host; + private final RouteKey route; + private final LimitEstimator estimator; + private final ResumeDelay resume; + private Kind kind = Kind.HOST; + private boolean kindPinned; + private Estimate overrideLimit; + private Duration overrideResume; + private Instant blockedUntil; + /** Blocked after a 429: the next answer tells whether the wait was long enough. */ + private boolean probing; + /** The block's wait was the learned one (no Retry-After): its outcome teaches the resume delay. */ + private boolean learningResume; + private int rateLimitedCount; + private Instant lastRateLimitedAt; + /** A host's: when each of its routes was last answered (no 429), by route ID. */ + private final Map lastAnswerByRoute = new ConcurrentHashMap<>(); + + RateLimitScope(String host, RouteKey route, LearnedRateLimitProperties props) { + this.host = host; + this.route = route; + this.id = route == null ? hostId(host) : route.id(); + this.estimator = new LimitEstimator(props.getRaiseAfter()); + this.resume = new ResumeDelay(props.getForgetFailAfter()); + } + + static String hostId(String host) { + return "HOST " + host; + } + + String id() { + return id; + } + + synchronized Kind kind() { + return kind; + } + + /** The limit that applies: the admin's correction, else the one learned. */ + synchronized Optional limit() { + return overrideLimit != null ? Optional.of(overrideLimit) : estimator.estimate(); + } + + synchronized Duration resumeDelay() { + return overrideResume != null ? overrideResume : resume.current(); + } + + synchronized Optional blockedUntil(Instant now) { + return blockedUntil != null && blockedUntil.isAfter(now) ? Optional.of(blockedUntil) : Optional.empty(); + } + + /** + * Counts a call in if the scope lets it go now ({@code bulk}: under {@code margin} of the limit); else when to + * try again. Returns whether the learned limit was raised, through {@code raised}. + */ + synchronized Optional admit(Instant now, boolean bulk, double margin, boolean[] raised) { + if (blockedUntil != null && blockedUntil.isAfter(now)) return Optional.of(blockedUntil); + if (overrideLimit == null) { + Optional before = estimator.estimate(); + estimator.maybeRaise(now); + raised[0] = !before.equals(estimator.estimate()); + } + Optional limit = limit(); + if (limit.isPresent()) { + Estimate l = limit.get(); + int cap = bulk ? Math.max(1, (int) Math.floor(l.calls() * margin)) : l.calls(); + if (estimator.countWithin(l.window(), now) >= cap) { + Instant freed = estimator.oldestWithin(l.window(), now).map(at -> at.plus(l.window())).orElse(now); + return Optional.of(freed.isAfter(now) ? freed : now.plusMillis(10)); + } + } + estimator.record(now); + return Optional.empty(); + } + + /** Counts a call made through another scope's budget (a route in its host's). */ + synchronized void record(Instant now) { + estimator.record(now); + } + + /** The calls counted here in the limit's window (a minute without a limit). */ + synchronized int used(Instant now) { + return estimator.countWithin(limit().map(Estimate::window).orElse(Duration.ofMinutes(1)), now); + } + + /** + * A 429 for this budget: blocked for {@code retryAfter} when the platform says, else for the learned delay (made + * longer first when the last one wasn't enough); the limit tightened from the calls before it, unless the 429 + * answers the first call after a wait (the wait was too short, not the limit too high). + */ + synchronized void on429(Instant now, Optional retryAfter) { + countRateLimited(now); + boolean afterWait = probing; + Duration wait; + if (retryAfter.isPresent()) { + wait = retryAfter.get(); + learningResume = false; + } else { + if (probing && learningResume && overrideResume == null) resume.onFailure(now); + wait = resumeDelay(); + learningResume = true; + } + if (overrideLimit == null && !afterWait) estimator.on429(now, wait); + Instant until = now.plus(wait); + if (blockedUntil == null || until.isAfter(blockedUntil)) blockedUntil = until; + probing = true; + } + + /** A route in its host's budget got a 429: counted for the admin tab only. */ + synchronized void countRateLimited(Instant now) { + rateLimitedCount++; + lastRateLimitedAt = now; + } + + /** An answer that is no 429: ends a probe, teaching the resume delay. Returns whether anything was learned. */ + synchronized boolean onAnswer(Instant now) { + if (!probing) return false; + if (learningResume && overrideResume == null) resume.onSuccess(now); + probing = false; + learningResume = false; + return true; + } + + void answered(String routeId, Instant now) { + lastAnswerByRoute.put(routeId, now); + } + + /** Whether a route of this host other than {@code routeId} was answered since {@code since}. */ + boolean otherRouteAnsweredSince(String routeId, Instant since) { + return lastAnswerByRoute.entrySet().stream() + .anyMatch(e -> !e.getKey().equals(routeId) && !e.getValue().isBefore(since)); + } + + /** The learning splits the route off its host's budget; never while an admin chose its scope. */ + synchronized boolean splitOff() { + if (kindPinned || kind == Kind.ROUTE) return false; + kind = Kind.ROUTE; + return true; + } + + /** An admin's correction: null values leave that part to the learning. */ + synchronized void correct(Estimate limit, Duration resumeDelay, Kind pinnedKind) { + overrideLimit = limit; + overrideResume = resumeDelay; + if (pinnedKind != null && route != null) { + kind = pinnedKind; + kindPinned = true; + } + } + + /** Forgets everything learned and corrected: the scope starts again without a known limit. */ + synchronized void reset() { + estimator.reset(); + resume.reset(); + overrideLimit = null; + overrideResume = null; + blockedUntil = null; + probing = false; + learningResume = false; + rateLimitedCount = 0; + lastRateLimitedAt = null; + if (route != null) { + kind = Kind.HOST; + kindPinned = false; + } + } + + synchronized LearnedRateLimit toEntity(Instant now) { + Optional learned = estimator.estimate(); + ResumeDelay.State r = resume.state(); + return LearnedRateLimit.builder() + .id(id).host(host) + .method(route == null ? null : route.method()) + .path(route == null ? null : route.path()) + .scope(route == null ? null : kind.name()) + .scopePinned(kindPinned) + .limitCalls(learned.map(Estimate::calls).orElse(null)) + .limitWindowSeconds(learned.map(e -> (int) e.window().toSeconds()).orElse(null)) + .resumeMs(r.current().toMillis()) + .resumeFailMs(r.fail() == null ? null : r.fail().toMillis()) + .resumeOkMs(r.ok() == null ? null : r.ok().toMillis()) + .lastFailAt(r.lastFail()) + .rateLimitedCount(rateLimitedCount) + .lastRateLimitedAt(lastRateLimitedAt) + .overrideCalls(overrideLimit == null ? null : overrideLimit.calls()) + .overrideWindowSeconds(overrideLimit == null ? null : (int) overrideLimit.window().toSeconds()) + .overrideResumeMs(overrideResume == null ? null : overrideResume.toMillis()) + .updatedAt(now) + .build(); + } + + /** Takes what was stored (learned, corrected, the scope), keeping the calls counted here. */ + synchronized void apply(LearnedRateLimit e) { + estimator.set(e.getLimitCalls() == null || e.getLimitWindowSeconds() == null ? null + : new Estimate(e.getLimitCalls(), Duration.ofSeconds(e.getLimitWindowSeconds())), e.getLastRateLimitedAt()); + resume.restore(new ResumeDelay.State(Duration.ofMillis(e.getResumeMs()), + e.getResumeFailMs() == null ? null : Duration.ofMillis(e.getResumeFailMs()), + e.getResumeOkMs() == null ? null : Duration.ofMillis(e.getResumeOkMs()), e.getLastFailAt())); + overrideLimit = e.getOverrideCalls() == null || e.getOverrideWindowSeconds() == null ? null + : new Estimate(e.getOverrideCalls(), Duration.ofSeconds(e.getOverrideWindowSeconds())); + overrideResume = e.getOverrideResumeMs() == null ? null : Duration.ofMillis(e.getOverrideResumeMs()); + rateLimitedCount = e.getRateLimitedCount(); + lastRateLimitedAt = e.getLastRateLimitedAt(); + if (route != null && e.getScope() != null) kind = Kind.valueOf(e.getScope()); + kindPinned = e.isScopePinned(); + } + + synchronized LearnedRateLimitDto toDto(Instant now) { + Optional limit = limit(); + return new LearnedRateLimitDto(id, host, route == null ? null : route.method(), + route == null ? null : route.path(), route == null ? null : kind.name(), kindPinned, + limit.map(Estimate::calls).orElse(null), limit.map(l -> (int) l.window().toSeconds()).orElse(null), + overrideLimit != null, resumeDelay().toMillis(), overrideResume != null, rateLimitedCount, + lastRateLimitedAt, used(now), blockedUntil(now).orElse(null)); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java new file mode 100644 index 000000000..305b63afb --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java @@ -0,0 +1,320 @@ +package fr.enimaloc.catapult.ratelimit; + +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitDto; +import fr.enimaloc.catapult.domain.ratelimit.LearnedRateLimit; +import fr.enimaloc.catapult.repository.ratelimit.LearnedRateLimitRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.server.ResponseStatusException; + +import java.time.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class LearnedRateLimiterTest { + + private static final String STORE = "store.steampowered.com"; + private static final RouteKey APPDETAILS = new RouteKey(STORE, "GET", "/api/appdetails"); + private static final RouteKey PAGE = new RouteKey(STORE, "GET", "/app/{id}"); + + /** A clock the test (and the limiter's waits) move. */ + static final class MovingClock extends Clock { + Instant now = Instant.parse("2026-11-12T10:00:00Z"); + + @Override public ZoneId getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(ZoneId zone) { return this; } + @Override public Instant instant() { return now; } + void advance(Duration d) { now = now.plus(d); } + } + + private final MovingClock clock = new MovingClock(); + private final LearnedRateLimitProperties props = new LearnedRateLimitProperties(); + private final Map stored = new HashMap<>(); + private LearnedRateLimitRepository repository; + private LearnedRateLimiter limiter; + private final AtomicInteger slept = new AtomicInteger(); + + @BeforeEach + void setUp() { + repository = mock(LearnedRateLimitRepository.class); + when(repository.findById(anyString())).thenAnswer(i -> Optional.ofNullable(stored.get(i.getArgument(0)))); + when(repository.findAll()).thenAnswer(i -> new ArrayList<>(stored.values())); + when(repository.save(any())).thenAnswer(i -> { + LearnedRateLimit e = i.getArgument(0); + stored.put(e.getId(), e); + return e; + }); + limiter = new LearnedRateLimiter(props, repository, clock); + limiter.setSleeper(ms -> { + slept.addAndGet((int) ms); + clock.advance(Duration.ofMillis(ms)); + }); + } + + private void calls(RouteKey key, int n) { + for (int i = 0; i < n; i++) { + limiter.acquire(key); + limiter.onResponse(key, HttpStatus.OK, new HttpHeaders()); + clock.advance(Duration.ofSeconds(1)); + } + } + + private void tooMany(RouteKey key, String retryAfter) { + limiter.acquire(key); + HttpHeaders h = new HttpHeaders(); + if (retryAfter != null) h.set(HttpHeaders.RETRY_AFTER, retryAfter); + limiter.onResponse(key, HttpStatus.TOO_MANY_REQUESTS, h); + } + + private LearnedRateLimitDto row(String id) { + return limiter.list().stream().filter(d -> d.id().equals(id)).findFirst().orElseThrow(); + } + + @Test + void documentedHosts_areLeftAlone() { + assertThat(limiter.learns("api.steampowered.com")).isFalse(); + assertThat(limiter.learns("eu.api.blizzard.com")).isFalse(); + assertThat(limiter.learns("")).isFalse(); + assertThat(limiter.learns(null)).isFalse(); + assertThat(limiter.learns(STORE)).isTrue(); + + RouteKey twitch = new RouteKey("api.twitch.tv", "GET", "/helix/users"); + limiter.acquire(twitch); + limiter.onResponse(twitch, HttpStatus.TOO_MANY_REQUESTS, new HttpHeaders()); + assertThat(stored).isEmpty(); + assertThat(limiter.blockedUntil("api.twitch.tv", "/helix/users")).isEmpty(); + + props.setEnabled(false); + assertThat(limiter.learns(STORE)).isFalse(); + } + + @Test + void a429_blocksTheWholeHost_andALiveCallElsewhereIsRefusedWithoutWaiting() { + calls(APPDETAILS, 20); + tooMany(APPDETAILS, null); + + assertThat(limiter.blockedUntil(STORE, "/api/appdetails")).contains(clock.now.plusSeconds(60)); + assertThat(limiter.blockedUntil(STORE, "/app/{id}")).contains(clock.now.plusSeconds(60)); + assertThatThrownBy(() -> limiter.acquire(PAGE)) + .isInstanceOfSatisfying(HttpClientErrorException.TooManyRequests.class, e -> { + assertThat(e.getResponseHeaders().getFirst(LearnedRateLimiter.LOCAL_REFUSAL_HEADER)).isEqualTo("true"); + assertThat(e.getResponseHeaders().getFirst(HttpHeaders.RETRY_AFTER)).isEqualTo("60"); + }); + assertThat(slept.get()).isZero(); + + LearnedRateLimitDto host = row("HOST " + STORE); + assertThat(host.limitCalls()).isEqualTo(20); + assertThat(host.limitWindowSeconds()).isEqualTo(60); + assertThat(host.rateLimitedCount()).isEqualTo(1); + assertThat(row(APPDETAILS.id()).rateLimitedCount()).isEqualTo(1); + assertThat(row(APPDETAILS.id()).scope()).isEqualTo("HOST"); + } + + @Test + void aLocalRefusal_isNeverLearned() { + HttpHeaders local = new HttpHeaders(); + local.set(LearnedRateLimiter.LOCAL_REFUSAL_HEADER, "true"); + limiter.onResponse(APPDETAILS, HttpStatus.TOO_MANY_REQUESTS, local); + assertThat(stored).isEmpty(); + } + + @Test + void theRetryAfter_isFollowed_andTeachesNoResumeDelay() { + calls(APPDETAILS, 5); + tooMany(APPDETAILS, "7"); + assertThat(limiter.blockedUntil(STORE, "/api/appdetails")).contains(clock.now.plusSeconds(7)); + assertThat(row("HOST " + STORE).limitWindowSeconds()).isEqualTo(10); + + clock.advance(Duration.ofSeconds(8)); + calls(APPDETAILS, 1); + assertThat(row("HOST " + STORE).resumeMs()).isEqualTo(60_000); + } + + @Test + void theResumeDelay_isLearnedFromTheFirstCallAfterEachWait() { + calls(APPDETAILS, 3); + tooMany(APPDETAILS, null); + clock.advance(Duration.ofSeconds(60)); + calls(APPDETAILS, 1); + assertThat(row("HOST " + STORE).resumeMs()).isEqualTo(48_000); + + clock.advance(Duration.ofMinutes(5)); + tooMany(APPDETAILS, null); + clock.advance(Duration.ofSeconds(48)); + tooMany(APPDETAILS, null); + assertThat(row("HOST " + STORE).resumeMs()).isEqualTo(96_000); + assertThat(limiter.blockedUntil(STORE, "/api/appdetails")).contains(clock.now.plusSeconds(96)); + } + + @Test + void bulkWork_keepsAMarginForLiveCalls_andWaitsItsTurn() { + assertThat(limiter.blockedUntil(STORE, "/api/appdetails")).isEmpty(); + limiter.correct("HOST " + STORE, 10, 60, null, null); + RateLimitPriority.run(RateLimitPriority.BULK, () -> false, () -> { + for (int i = 0; i < 8; i++) limiter.acquire(APPDETAILS); + }); + assertThat(slept.get()).isZero(); + + limiter.acquire(APPDETAILS); + limiter.acquire(APPDETAILS); + assertThatThrownBy(() -> limiter.acquire(PAGE)).isInstanceOf(HttpClientErrorException.TooManyRequests.class); + + RateLimitPriority.run(RateLimitPriority.BULK, () -> false, () -> limiter.acquire(APPDETAILS)); + assertThat(Duration.ofMillis(slept.get())).isEqualTo(Duration.ofSeconds(60)); + } + + @Test + void cancelledBulkWork_stopsWaiting() { + calls(APPDETAILS, 2); + tooMany(APPDETAILS, "600"); + AtomicInteger checks = new AtomicInteger(); + + assertThatThrownBy(() -> RateLimitPriority.run(RateLimitPriority.BULK, () -> checks.incrementAndGet() > 2, + () -> limiter.acquire(APPDETAILS))).isInstanceOf(HttpClientErrorException.TooManyRequests.class); + assertThat(slept.get()).isEqualTo(2_000); + } + + @Test + void anInterruptedWait_isRefused() { + calls(APPDETAILS, 2); + tooMany(APPDETAILS, "600"); + limiter.setSleeper(ms -> { throw new InterruptedException(); }); + + assertThatThrownBy(() -> RateLimitPriority.run(RateLimitPriority.BULK, () -> false, + () -> limiter.acquire(APPDETAILS))).isInstanceOf(HttpClientErrorException.TooManyRequests.class); + assertThat(Thread.interrupted()).isTrue(); + } + + @Test + void aCorrection_isKeptByTheLearning_untilReset() { + limiter.acquire(APPDETAILS); + limiter.correct("HOST " + STORE, 50, 60, 30, null); + calls(APPDETAILS, 10); + tooMany(APPDETAILS, null); + + LearnedRateLimitDto host = row("HOST " + STORE); + assertThat(host.limitCalls()).isEqualTo(50); + assertThat(host.limitCorrected()).isTrue(); + assertThat(host.resumeMs()).isEqualTo(30_000); + assertThat(limiter.blockedUntil(STORE, "/api/appdetails")).contains(clock.now.plusSeconds(30)); + + LearnedRateLimitDto reset = limiter.reset("HOST " + STORE); + assertThat(reset.limitCalls()).isNull(); + assertThat(reset.limitCorrected()).isFalse(); + assertThat(reset.rateLimitedCount()).isZero(); + assertThat(reset.resumeMs()).isEqualTo(60_000); + assertThat(limiter.blockedUntil(STORE, "/api/appdetails")).isEmpty(); + } + + @Test + void aRouteWithALimitOfItsOwn_isSplitOffItsHost() { + calls(APPDETAILS, 30); + tooMany(APPDETAILS, null); + clock.advance(Duration.ofMinutes(10)); + calls(PAGE, 2); + calls(APPDETAILS, 2); + + tooMany(APPDETAILS, null); + + assertThat(row(APPDETAILS.id()).scope()).isEqualTo("ROUTE"); + assertThat(limiter.blockedUntil(STORE, "/api/appdetails")).isPresent(); + assertThat(limiter.blockedUntil(STORE, "/app/{id}")).isEmpty(); + limiter.acquire(PAGE); + } + + @Test + void anAdminsScope_isNeverChangedByTheLearning() { + limiter.acquire(APPDETAILS); + limiter.correct(APPDETAILS.id(), null, null, null, "route"); + assertThat(row(APPDETAILS.id()).scopePinned()).isTrue(); + tooMany(APPDETAILS, null); + assertThat(limiter.blockedUntil(STORE, "/app/{id}")).isEmpty(); + + limiter.correct(APPDETAILS.id(), null, null, null, "HOST"); + calls(PAGE, 30); + clock.advance(Duration.ofMinutes(5)); + tooMany(APPDETAILS, null); + assertThat(row(APPDETAILS.id()).scope()).isEqualTo("HOST"); + + limiter.reset(APPDETAILS.id()); + assertThat(row(APPDETAILS.id()).scopePinned()).isFalse(); + } + + @Test + void aBadCorrection_isRefused() { + limiter.acquire(APPDETAILS); + String id = "HOST " + STORE; + assertThatThrownBy(() -> limiter.correct(id, 5, null, null, null)).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(() -> limiter.correct(id, 0, 60, null, null)).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(() -> limiter.correct(id, 5, 0, null, null)).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(() -> limiter.correct(id, null, null, 0, null)).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(() -> limiter.correct(id, null, null, null, "nope")).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(() -> limiter.reset("HOST unknown")).isInstanceOf(ResponseStatusException.class); + assertThat(limiter.correct(id, null, null, null, " ").limitCorrected()).isFalse(); + } + + @Test + void whatWasStored_isTakenBack_andSyncedFromTheOtherInstances() { + calls(APPDETAILS, 10); + tooMany(APPDETAILS, null); + LearnedRateLimiter other = new LearnedRateLimiter(props, repository, clock); + + assertThat(other.blockedUntil(STORE, "/api/appdetails")).isEmpty(); + assertThat(other.list()).extracting(LearnedRateLimitDto::id) + .containsExactly("HOST " + STORE, APPDETAILS.id()); + assertThat(other.list().getFirst().limitCalls()).isEqualTo(10); + assertThat(other.list().getFirst().isHost()).isTrue(); + + other.correct(APPDETAILS.id(), 3, 10, 5, "ROUTE"); + limiter.sync(); + assertThat(row(APPDETAILS.id()).limitCalls()).isEqualTo(3); + assertThat(row(APPDETAILS.id()).scope()).isEqualTo("ROUTE"); + } + + @Test + void aFailingStore_neverFailsTheCalls() { + doThrow(new IllegalStateException("db down")).when(repository).findById(anyString()); + doThrow(new IllegalStateException("db down")).when(repository).save(any()); + doThrow(new IllegalStateException("db down")).when(repository).findAll(); + + calls(APPDETAILS, 2); + tooMany(APPDETAILS, null); + limiter.sync(); + + assertThat(limiter.list()).hasSize(2); + assertThat(limiter.blockedUntil(STORE, "/api/appdetails")).isPresent(); + } + + @Test + void longNearTheLimit_raisesIt() { + limiter.acquire(APPDETAILS); + tooMany(APPDETAILS, null); + assertThat(row("HOST " + STORE).limitCalls()).isEqualTo(1); + clock.advance(Duration.ofHours(2)); + calls(APPDETAILS, 1); + calls(APPDETAILS, 1); + assertThat(row("HOST " + STORE).limitCalls()).isEqualTo(2); + verify(repository, atLeast(3)).save(any()); + } + + @Test + void retryAfter_readsSecondsOrADate() { + Instant now = Instant.parse("2026-11-12T10:00:00Z"); + assertThat(LearnedRateLimiter.retryAfter("30", now)).contains(Duration.ofSeconds(30)); + assertThat(LearnedRateLimiter.retryAfter("0", now)).contains(Duration.ofSeconds(1)); + assertThat(LearnedRateLimiter.retryAfter("Thu, 12 Nov 2026 10:01:00 GMT", now)).contains(Duration.ofMinutes(1)); + assertThat(LearnedRateLimiter.retryAfter("Thu, 12 Nov 2026 09:00:00 GMT", now)).contains(Duration.ofSeconds(1)); + assertThat(LearnedRateLimiter.retryAfter("soon", now)).isEmpty(); + assertThat(LearnedRateLimiter.retryAfter(" ", now)).isEmpty(); + assertThat(LearnedRateLimiter.retryAfter(null, now)).isEmpty(); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java index a9282507d..9f9050587 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java @@ -44,6 +44,17 @@ void a429_neverLoosensTheLimit_butAnotherWindowReplacesIt() { assertThat(e.estimate()).contains(new Estimate(49, Duration.ofHours(1))); } + @Test + void oneFewCalls429_atMostHalvesTheLimit() { + LimitEstimator e = calls(30, Duration.ofSeconds(50)); + e.on429(T0.plusSeconds(50), Duration.ofSeconds(60)); + Instant later = T0.plus(Duration.ofMinutes(10)); + e.record(later); + + e.on429(later, Duration.ofSeconds(60)); + assertThat(e.estimate()).contains(new Estimate(15, Duration.ofMinutes(1))); + } + @Test void theLimit_isNeverUnderOneCall() { LimitEstimator e = new LimitEstimator(Duration.ofHours(1)); diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/LearnedRateLimitCorrectionDto.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/LearnedRateLimitCorrectionDto.java new file mode 100644 index 000000000..4e15caf38 --- /dev/null +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/LearnedRateLimitCorrectionDto.java @@ -0,0 +1,9 @@ +package fr.enimaloc.catapult.common.dto.admin; + +/** + * An admin's correction of a learned rate limit: {@code limitCalls} per {@code limitWindowSeconds} (both or neither), + * {@code resumeSeconds}, and for a route its {@code scope} (HOST or ROUTE). A null value leaves that part to the + * learning; what is set stays until the scope is reset. + */ +public record LearnedRateLimitCorrectionDto(Integer limitCalls, Integer limitWindowSeconds, Integer resumeSeconds, + String scope) {} diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/LearnedRateLimitDto.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/LearnedRateLimitDto.java new file mode 100644 index 000000000..0782c7e6e --- /dev/null +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/LearnedRateLimitDto.java @@ -0,0 +1,23 @@ +package fr.enimaloc.catapult.common.dto.admin; + +import java.time.Instant; + +/** + * What Catapult learned of one scope's rate limit from its 429s: a host ({@code method} and {@code path} null: the + * budget its {@code HOST} routes share) or one of its routes ({@code scope} HOST: in its host's budget, ROUTE: its + * own limit; {@code scopePinned} when an admin chose it). {@code limitCalls} per {@code limitWindowSeconds} and + * {@code resumeMs} are what applies (an admin's correction when {@code limitCorrected}/{@code resumeCorrected}, else + * what was learned; no limit while none was learned). {@code usedCalls}: the calls in that window (a minute without a + * limit) on the instance that answered; {@code blockedUntil} while it waits after a 429. + */ +public record LearnedRateLimitDto(String id, String host, String method, String path, String scope, + boolean scopePinned, Integer limitCalls, Integer limitWindowSeconds, + boolean limitCorrected, long resumeMs, boolean resumeCorrected, + int rateLimitedCount, Instant lastRateLimitedAt, int usedCalls, + Instant blockedUntil) { + + /** A host's row rather than a route's. */ + public boolean isHost() { + return path == null; + } +} From 6694be6947341f2e746ca18efc82a81d66a1f847 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:42:09 +0100 Subject: [PATCH 42/53] feat(ratelimit): apply learned limits to outbound HTTP calls --- .../catapult/config/WebClientConfig.java | 9 ++- .../LearnedRateLimitInterceptor.java | 35 ++++++++ .../src/main/resources/application.properties | 17 ++++ .../LearnedRateLimitInterceptorTest.java | 79 +++++++++++++++++++ 4 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitInterceptor.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitInterceptorTest.java diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/config/WebClientConfig.java b/catapult-api/src/main/java/fr/enimaloc/catapult/config/WebClientConfig.java index 59b03eb09..464ec5f50 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/config/WebClientConfig.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/config/WebClientConfig.java @@ -1,6 +1,7 @@ package fr.enimaloc.catapult.config; import com.fasterxml.jackson.databind.ObjectMapper; +import fr.enimaloc.catapult.ratelimit.LearnedRateLimitInterceptor; import org.springframework.boot.restclient.RestClientCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -24,16 +25,18 @@ public class WebClientConfig { * Spring Boot hands out. Wraps the request factory in a * {@link BufferingClientHttpRequestFactory} so the interceptor can read * the response body without exhausting the underlying stream for the - * actual caller. + * actual caller. The learned rate limits apply to every call too. */ @Bean - public RestClientCustomizer traceLoggingCustomizer(HttpTraceLoggingInterceptor interceptor) { + public RestClientCustomizer traceLoggingCustomizer(HttpTraceLoggingInterceptor interceptor, + LearnedRateLimitInterceptor rateLimits) { JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory( HttpClient.newBuilder().connectTimeout(CONNECT_TIMEOUT).build()); requestFactory.setReadTimeout(READ_TIMEOUT); return builder -> builder .requestFactory(new BufferingClientHttpRequestFactory(requestFactory)) - .requestInterceptor(interceptor); + .requestInterceptor(interceptor) + .requestInterceptor(rateLimits); } /** diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitInterceptor.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitInterceptor.java new file mode 100644 index 000000000..78f39c706 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitInterceptor.java @@ -0,0 +1,35 @@ +package fr.enimaloc.catapult.ratelimit; + +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * Keeps every outbound call to a host without a documented limit to what {@link LearnedRateLimiter} learned: waits + * (or refuses with a local 429) before sending, then tells it the answer. Hosts left to their documented limits pass + * untouched. + */ +@Component +public class LearnedRateLimitInterceptor implements ClientHttpRequestInterceptor { + + private final LearnedRateLimiter limiter; + + public LearnedRateLimitInterceptor(LearnedRateLimiter limiter) { + this.limiter = limiter; + } + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) + throws IOException { + RouteKey key = RouteKey.of(request); + if (!limiter.learns(key.host())) return execution.execute(request, body); + limiter.acquire(key); + ClientHttpResponse response = execution.execute(request, body); + limiter.onResponse(key, response.getStatusCode(), response.getHeaders()); + return response; + } +} diff --git a/catapult-api/src/main/resources/application.properties b/catapult-api/src/main/resources/application.properties index eae7cc111..ce4b50464 100644 --- a/catapult-api/src/main/resources/application.properties +++ b/catapult-api/src/main/resources/application.properties @@ -167,6 +167,23 @@ tw.enabled=${TW_ENABLED:true} tw.backfill.batch-size=50 tw.backfill.throttle-per-second=2 +# ============================================================ +# Learned rate limits (platforms without a documented one, guessed from their 429s) +# ============================================================ +rate-limit.learning.enabled=${RATE_LIMIT_LEARNING_ENABLED:true} +# Left to their documented limits (*.host: every subdomain) +rate-limit.learning.excluded-hosts=api.steampowered.com,api.twitch.tv,id.twitch.tv,api.igdb.com,\ + www.doesthedogdie.com,git.enimaloc.fr,oauth.battle.net,*.battle.net,*.api.blizzard.com +# The share of a limit bulk work (TW recalculation) uses, leaving the rest to live calls +rate-limit.learning.bulk-margin=0.8 +# How long a live call waits for the limit before giving up (a local 429) +rate-limit.learning.live-max-wait=2s +# Near a limit this long without a 429, it is raised by a tenth; a resume delay that failed is forgotten after +rate-limit.learning.raise-after=1h +rate-limit.learning.forget-fail-after=24h +# How often an instance takes what the others learned and the admins corrected +rate-limit.learning.sync-every=60s + # ============================================================ # Feedback form (GitLab issues) # ============================================================ diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitInterceptorTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitInterceptorTest.java new file mode 100644 index 000000000..93d0f81c0 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitInterceptorTest.java @@ -0,0 +1,79 @@ +package fr.enimaloc.catapult.ratelimit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class LearnedRateLimitInterceptorTest { + + private static final RouteKey PAGE = new RouteKey("store.steampowered.com", "GET", "/app/{id}"); + + private LearnedRateLimiter limiter; + private MockRestServiceServer server; + private RestClient client; + + @BeforeEach + void setUp() { + limiter = mock(LearnedRateLimiter.class); + when(limiter.learns("store.steampowered.com")).thenReturn(true); + RestClient.Builder builder = RestClient.builder().requestInterceptor(new LearnedRateLimitInterceptor(limiter)); + server = MockRestServiceServer.bindTo(builder).build(); + client = builder.build(); + } + + @Test + void aCall_waitsForTheLimit_byItsTemplate_thenTellsTheAnswer() { + server.expect(requestTo("https://store.steampowered.com/app/620?l=french")).andRespond(withSuccess()); + + client.get().uri("https://store.steampowered.com/app/{id}?l={l}", 620, "french").retrieve().toBodilessEntity(); + + verify(limiter).acquire(PAGE); + verify(limiter).onResponse(eq(PAGE), eq(HttpStatus.OK), any(HttpHeaders.class)); + } + + @Test + void a429_isTold_beforeTheCallerSeesIt() { + server.expect(requestTo("https://store.steampowered.com/app/620")).andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS)); + + assertThatThrownBy(() -> client.get().uri("https://store.steampowered.com/app/{id}", 620).retrieve() + .toBodilessEntity()).isInstanceOf(HttpClientErrorException.TooManyRequests.class); + + verify(limiter).onResponse(eq(PAGE), eq(HttpStatus.TOO_MANY_REQUESTS), any(HttpHeaders.class)); + } + + @Test + void aLocalRefusal_sendsNothing_andIsNotLearned() { + HttpHeaders local = new HttpHeaders(); + local.set(LearnedRateLimiter.LOCAL_REFUSAL_HEADER, "true"); + doThrow(HttpClientErrorException.create(HttpStatus.TOO_MANY_REQUESTS, "local", local, new byte[0], null)) + .when(limiter).acquire(PAGE); + + assertThatThrownBy(() -> client.get().uri("https://store.steampowered.com/app/{id}", 620).retrieve() + .toBodilessEntity()).isInstanceOf(HttpClientErrorException.TooManyRequests.class); + + verify(limiter, never()).onResponse(any(), any(), any()); + server.verify(); + } + + @Test + void aDocumentedHost_passesUntouched() { + server.expect(requestTo("https://api.steampowered.com/ISteamUser/x")).andRespond(withSuccess()); + + client.get().uri("https://api.steampowered.com/ISteamUser/x").retrieve().toBodilessEntity(); + + verify(limiter, never()).acquire(any()); + verify(limiter, never()).onResponse(any(), any(), any()); + } +} From e4227c5241fffcf1e01436f8143f3ba02e139128 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:48:46 +0100 Subject: [PATCH 43/53] feat(ratelimit): bound the routes per host and learn only from windows counted whole --- .../ratelimit/LearnedRateLimitProperties.java | 2 ++ .../ratelimit/LearnedRateLimiter.java | 18 ++++++++++--- .../catapult/ratelimit/LimitEstimator.java | 14 +++++++++- .../catapult/ratelimit/RateLimitScope.java | 6 +++-- .../enimaloc/catapult/ratelimit/RouteKey.java | 6 ++--- .../ratelimit/LearnedRateLimiterTest.java | 27 ++++++++++++++++++- .../catapult/ratelimit/LearningTest.java | 16 +++++++++-- .../catapult/ratelimit/RouteKeyTest.java | 10 +++++++ 8 files changed, 86 insertions(+), 13 deletions(-) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitProperties.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitProperties.java index 435ff9f9c..318071f77 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitProperties.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimitProperties.java @@ -28,4 +28,6 @@ public class LearnedRateLimitProperties { private Duration liveMaxWait = Duration.ofSeconds(2); private Duration raiseAfter = Duration.ofHours(1); private Duration forgetFailAfter = Duration.ofHours(24); + /** Past this many routes, a host's new ones share a single route ({@code /{other}}): what is stored stays bounded. */ + private int maxRoutesPerHost = 50; } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java index f6b46e88c..8ba1e42fd 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java @@ -39,6 +39,7 @@ public class LearnedRateLimiter { /** The header a refusal made here (no call sent) carries: never learned from. */ public static final String LOCAL_REFUSAL_HEADER = "X-Catapult-Local-Limit"; private static final long SLICE_MS = 1_000L; + static final String OTHER_PATH = "/{other}"; interface Sleeper { void sleep(long millis) throws InterruptedException; @@ -221,11 +222,19 @@ private RateLimitScope known(String id) { } private RateLimitScope host(String host) { - return scope(RateLimitScope.hostId(host), () -> new RateLimitScope(host, null, props)); + return scope(RateLimitScope.hostId(host), () -> new RateLimitScope(host, null, props, clock.instant())); } + /** The route's scope; past the host's {@code maxRoutesPerHost}, a new route shares {@code /{other}}. */ private RateLimitScope route(RouteKey key) { - return scope(key.id(), () -> new RateLimitScope(key.host(), key, props)); + RouteKey k = key; + if (!scopes.containsKey(k.id())) { + String prefix = k.method() + " " + k.host() + "/"; + long known = scopes.keySet().stream().filter(id -> id.startsWith(prefix)).count(); + if (known >= props.getMaxRoutesPerHost()) k = new RouteKey(k.host(), k.method(), OTHER_PATH); + } + RouteKey routeKey = k; + return scope(routeKey.id(), () -> new RateLimitScope(routeKey.host(), routeKey, props, clock.instant())); } /** The scope, loaded from what was stored the first time it is needed, stored when new. */ @@ -249,8 +258,9 @@ private RateLimitScope scope(String id, java.util.function.Supplier estimate() { * A 429: the window is the shortest one at least as long as the wait the platform asks or the one learned * ({@code resumeHint}), the limit the calls sent in it before the refused one. Never loosened by a 429, and at * most halved by one in the same window: a 429 after few calls tells more of a longer window than of a tiny limit. + * Nothing is learned when the window wasn't counted whole (just started, or a wait ended within it): the calls + * the platform counted aren't all known. */ void on429(Instant now, Duration resumeHint) { last429 = now; Duration window = WINDOWS.stream().filter(w -> w.compareTo(resumeHint) >= 0).findFirst().orElse(KEPT); + if (countingSince.isAfter(now.minus(window))) return; int limit = Math.max(1, countWithin(window, now) - 1); if (estimate != null && estimate.window().equals(window)) { int halved = (estimate.calls() + 1) / 2; @@ -87,6 +93,12 @@ void maybeRaise(Instant now) { lastRaise = now; } + /** Forgets the calls counted: after a 429's wait (ending {@code from}), the platform counts afresh. */ + void forgetCalls(Instant from) { + calls.clear(); + countingSince = from; + } + void set(Estimate estimate, Instant last429) { this.estimate = estimate; this.last429 = last429; diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitScope.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitScope.java index 9083fa0a2..b7645b2c8 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitScope.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitScope.java @@ -39,11 +39,11 @@ enum Kind { HOST, ROUTE } /** A host's: when each of its routes was last answered (no 429), by route ID. */ private final Map lastAnswerByRoute = new ConcurrentHashMap<>(); - RateLimitScope(String host, RouteKey route, LearnedRateLimitProperties props) { + RateLimitScope(String host, RouteKey route, LearnedRateLimitProperties props, Instant now) { this.host = host; this.route = route; this.id = route == null ? hostId(host) : route.id(); - this.estimator = new LimitEstimator(props.getRaiseAfter()); + this.estimator = new LimitEstimator(props.getRaiseAfter(), now); this.resume = new ResumeDelay(props.getForgetFailAfter()); } @@ -126,6 +126,8 @@ synchronized void on429(Instant now, Optional retryAfter) { if (overrideLimit == null && !afterWait) estimator.on429(now, wait); Instant until = now.plus(wait); if (blockedUntil == null || until.isAfter(blockedUntil)) blockedUntil = until; + // When the wait ends the platform counts afresh: the calls before it no longer fill the window + estimator.forgetCalls(blockedUntil); probing = true; } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RouteKey.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RouteKey.java index 238fbd515..326a57d7f 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RouteKey.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RouteKey.java @@ -8,8 +8,8 @@ /** * One route of a platform: the method, the host and the path as written by the caller ({@code /app/{id}}), never the - * resolved URL, so every app's page counts as the same route. Without the caller's URI template, the path's - * identifier-looking segments become {@code {id}}. + * resolved URL, so every app's page counts as the same route. Identifier-looking segments become {@code {id}}, also + * in a template a caller built by concatenating them. */ public record RouteKey(String host, String method, String path) { @@ -22,7 +22,7 @@ public static RouteKey of(HttpRequest request) { URI uri = request.getURI(); String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT); Object template = request.getAttributes().get(URI_TEMPLATE_ATTRIBUTE); - String path = template instanceof String t ? templatePath(t) : normalizedPath(uri.getRawPath()); + String path = normalizedPath(template instanceof String t ? templatePath(t) : uri.getRawPath()); return new RouteKey(host, request.getMethod().name(), path); } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java index 305b63afb..cf17ecbc0 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java @@ -75,6 +75,13 @@ private void tooMany(RouteKey key, String retryAfter) { limiter.onResponse(key, HttpStatus.TOO_MANY_REQUESTS, h); } + /** The store's scopes known for an hour: a 429 then tells a limit. */ + private void known() { + limiter.blockedUntil(STORE, "/api/appdetails"); + limiter.blockedUntil(STORE, "/app/{id}"); + clock.advance(Duration.ofHours(1)); + } + private LearnedRateLimitDto row(String id) { return limiter.list().stream().filter(d -> d.id().equals(id)).findFirst().orElseThrow(); } @@ -99,6 +106,7 @@ void documentedHosts_areLeftAlone() { @Test void a429_blocksTheWholeHost_andALiveCallElsewhereIsRefusedWithoutWaiting() { + known(); calls(APPDETAILS, 20); tooMany(APPDETAILS, null); @@ -129,6 +137,7 @@ void aLocalRefusal_isNeverLearned() { @Test void theRetryAfter_isFollowed_andTeachesNoResumeDelay() { + known(); calls(APPDETAILS, 5); tooMany(APPDETAILS, "7"); assertThat(limiter.blockedUntil(STORE, "/api/appdetails")).contains(clock.now.plusSeconds(7)); @@ -217,6 +226,7 @@ void aCorrection_isKeptByTheLearning_untilReset() { @Test void aRouteWithALimitOfItsOwn_isSplitOffItsHost() { + known(); calls(APPDETAILS, 30); tooMany(APPDETAILS, null); clock.advance(Duration.ofMinutes(10)); @@ -264,13 +274,14 @@ void aBadCorrection_isRefused() { @Test void whatWasStored_isTakenBack_andSyncedFromTheOtherInstances() { + known(); calls(APPDETAILS, 10); tooMany(APPDETAILS, null); LearnedRateLimiter other = new LearnedRateLimiter(props, repository, clock); assertThat(other.blockedUntil(STORE, "/api/appdetails")).isEmpty(); assertThat(other.list()).extracting(LearnedRateLimitDto::id) - .containsExactly("HOST " + STORE, APPDETAILS.id()); + .containsExactly("HOST " + STORE, APPDETAILS.id(), PAGE.id()); assertThat(other.list().getFirst().limitCalls()).isEqualTo(10); assertThat(other.list().getFirst().isHost()).isTrue(); @@ -296,6 +307,7 @@ void aFailingStore_neverFailsTheCalls() { @Test void longNearTheLimit_raisesIt() { + known(); limiter.acquire(APPDETAILS); tooMany(APPDETAILS, null); assertThat(row("HOST " + STORE).limitCalls()).isEqualTo(1); @@ -306,6 +318,19 @@ void longNearTheLimit_raisesIt() { verify(repository, atLeast(3)).save(any()); } + @Test + void pastTheRoutesAHostMayHave_newOnesShareOne() { + props.setMaxRoutesPerHost(2); + limiter.acquire(APPDETAILS); + limiter.acquire(PAGE); + limiter.acquire(new RouteKey(STORE, "GET", "/a")); + limiter.acquire(new RouteKey(STORE, "GET", "/b")); + limiter.acquire(APPDETAILS); + + assertThat(limiter.list()).extracting(LearnedRateLimitDto::path) + .containsExactly(null, "/api/appdetails", "/app/{id}", LearnedRateLimiter.OTHER_PATH); + } + @Test void retryAfter_readsSecondsOrADate() { Instant now = Instant.parse("2026-11-12T10:00:00Z"); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java index 9f9050587..78bd44410 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java @@ -14,7 +14,7 @@ class LearningTest { private static final Instant T0 = Instant.parse("2026-11-12T10:00:00Z"); private static LimitEstimator calls(int n, Duration spread) { - LimitEstimator e = new LimitEstimator(Duration.ofHours(1)); + LimitEstimator e = new LimitEstimator(Duration.ofHours(1), T0.minus(Duration.ofHours(1))); for (int i = 0; i < n; i++) e.record(T0.plusMillis(spread.toMillis() * i / n)); return e; } @@ -55,9 +55,17 @@ void oneFewCalls429_atMostHalvesTheLimit() { assertThat(e.estimate()).contains(new Estimate(15, Duration.ofMinutes(1))); } + @Test + void aWindowNotCountedWhole_teachesNoLimit() { + LimitEstimator e = new LimitEstimator(Duration.ofHours(1), T0); + e.record(T0.plusSeconds(1)); + e.on429(T0.plusSeconds(2), Duration.ofSeconds(60)); + assertThat(e.estimate()).isEmpty(); + } + @Test void theLimit_isNeverUnderOneCall() { - LimitEstimator e = new LimitEstimator(Duration.ofHours(1)); + LimitEstimator e = new LimitEstimator(Duration.ofHours(1), T0.minus(Duration.ofHours(1))); e.on429(T0, Duration.ofSeconds(1)); assertThat(e.estimate()).contains(new Estimate(1, Duration.ofSeconds(10))); assertThat(e.oldestWithin(Duration.ofSeconds(10), T0)).isEmpty(); @@ -80,6 +88,10 @@ void longNearTheLimitWithoutA429_raisesItByATenth() { assertThat(e.estimate()).contains(new Estimate(31, Duration.ofMinutes(1))); assertThat(e.oldestWithin(Duration.ofMinutes(1), later)).contains(later.minusSeconds(24)); + e.forgetCalls(later); + assertThat(e.countWithin(Duration.ofHours(1), later)).isZero(); + e.on429(later.plusSeconds(30), Duration.ofSeconds(60)); + assertThat(e.estimate()).contains(new Estimate(31, Duration.ofMinutes(1))); e.reset(); e.maybeRaise(later); assertThat(e.estimate()).isEmpty(); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/RouteKeyTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/RouteKeyTest.java index 87c352222..650b88092 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/RouteKeyTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/RouteKeyTest.java @@ -23,6 +23,16 @@ void theCallersTemplate_isTheRoute() { assertThat(key.id()).isEqualTo("GET store.steampowered.com/app/{id}"); } + @Test + void aConcatenatedTemplate_isNormalizedToo() { + MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, + URI.create("https://displaycatalog.mp.microsoft.com/v7.0/products/9NBLGGH4R315ABCD")); + request.getAttributes().put(RouteKey.URI_TEMPLATE_ATTRIBUTE, + "https://displaycatalog.mp.microsoft.com/v7.0/products/9NBLGGH4R315ABCD?market=FR"); + + assertThat(RouteKey.of(request).path()).isEqualTo("/v7.0/products/{id}"); + } + @Test void withoutATemplate_identifiersBecomeId() { MockClientHttpRequest request = new MockClientHttpRequest(HttpMethod.GET, From da95d94ee2a64c75fad5cd27dfbdbe37f574fd29 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:48:46 +0100 Subject: [PATCH 44/53] refactor(steam): rely on learned rate limits for the store --- .../service/steam/SteamStoreServiceImpl.java | 73 +++++++------------ .../service/steam/SteamStoreServiceTest.java | 64 ++++++---------- 2 files changed, 52 insertions(+), 85 deletions(-) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceImpl.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceImpl.java index f3874c9e2..dce9e9685 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceImpl.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceImpl.java @@ -2,6 +2,7 @@ import fr.enimaloc.catapult.domain.steam.SteamAppParentEntry; import fr.enimaloc.catapult.getter.steam.SteamPlaytestRedirectResolver; +import fr.enimaloc.catapult.ratelimit.LearnedRateLimiter; import fr.enimaloc.catapult.repository.steam.SteamAppParentRepository; import fr.enimaloc.catapult.service.metrics.ExternalApiObservations; import lombok.AccessLevel; @@ -15,11 +16,7 @@ import org.springframework.web.client.RestClient; import java.time.Clock; -import java.time.Duration; import java.time.Instant; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; import java.util.*; @Slf4j @@ -27,7 +24,9 @@ @RequiredArgsConstructor public class SteamStoreServiceImpl implements SteamStoreService { - private static final String APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"; + private static final String STORE_HOST = "store.steampowered.com"; + private static final String APP_DETAILS_PATH = "/api/appdetails"; + private static final String APP_DETAILS_URL = "https://" + STORE_HOST + APP_DETAILS_PATH; private static final Map> KEYWORDS = Map.of( "ViolentGraphic", Set.of("blood", "gore", "violence", "violent", "killing", "combat", "death", "injury"), @@ -41,53 +40,38 @@ public class SteamStoreServiceImpl implements SteamStoreService { private final ExternalApiObservations apiObservations; private final SteamAppParentRepository steamAppParentRepository; private final SteamPlaytestRedirectResolver redirectResolver; + private final LearnedRateLimiter rateLimiter; private static final java.time.Duration PARENT_CACHE_TTL = java.time.Duration.ofDays(30); - /** Without a Retry-After, the first wait after a 429; it doubles while they follow each other, up to the max. */ - private static final Duration RATE_LIMIT_FIRST = Duration.ofSeconds(60); - private static final Duration RATE_LIMIT_MAX = Duration.ofMinutes(10); - @Setter(AccessLevel.PACKAGE) private Clock clock = Clock.systemUTC(); - /** Until when the store asked to be left alone; null: it may be called. */ - private volatile Instant rateLimitedUntil; - /** The wait the next 429 without a Retry-After gets: doubled by each, reset by an answer. */ - private volatile Duration nextWait = RATE_LIMIT_FIRST; + /** Until when the learned rate limit refused the store's calls without sending them (live calls don't wait). */ + private volatile Instant refusedUntil; + /** + * Until when the store's {@code appdetails} isn't called: its learned rate limit blocks it after a 429, or + * refused a live call without sending it. + */ @Override public Optional rateLimitedUntil() { - Instant until = rateLimitedUntil; - return until != null && until.isAfter(clock.instant()) ? Optional.of(until) : Optional.empty(); - } - - /** The store answered 429: left alone for its Retry-After (seconds or an HTTP date), else the next wait. */ - private void onRateLimited(HttpHeaders headers) { Instant now = clock.instant(); - Duration wait = retryAfter(headers == null ? null : headers.getFirst("Retry-After"), now) - .orElse(nextWait); - if (wait.compareTo(RATE_LIMIT_MAX) > 0) wait = RATE_LIMIT_MAX; - Instant until = now.plus(wait); - if (rateLimitedUntil == null || until.isAfter(rateLimitedUntil)) rateLimitedUntil = until; - Duration doubled = nextWait.multipliedBy(2); - nextWait = doubled.compareTo(RATE_LIMIT_MAX) > 0 ? RATE_LIMIT_MAX : doubled; - log.warn("Steam store rate limited (429): left alone until {}", rateLimitedUntil); + Instant refused = refusedUntil; + Optional blocked = rateLimiter.blockedUntil(STORE_HOST, APP_DETAILS_PATH); + if (refused == null || !refused.isAfter(now)) return blocked; + return blocked.filter(b -> b.isAfter(refused)).or(() -> Optional.of(refused)); } - static Optional retryAfter(String header, Instant now) { - if (header == null || header.isBlank()) return Optional.empty(); - String value = header.trim(); - try { - return Optional.of(Duration.ofSeconds(Math.max(1, Long.parseLong(value)))); - } catch (NumberFormatException notSeconds) { - try { - Instant at = ZonedDateTime.parse(value, - DateTimeFormatter.RFC_1123_DATE_TIME).toInstant(); - return Optional.of(at.isAfter(now) ? Duration.between(now, at) : Duration.ofSeconds(1)); - } catch (DateTimeParseException notADate) { - return Optional.empty(); - } + /** A 429: the learned rate limit learned it already; one it made itself (no call sent) tells until when. */ + private void onRateLimited(HttpClientErrorException.TooManyRequests e) { + HttpHeaders headers = e.getResponseHeaders(); + if (headers == null || !headers.containsHeader(LearnedRateLimiter.LOCAL_REFUSAL_HEADER)) { + log.warn("Steam store rate limited (429): left alone until {}", rateLimitedUntil().orElse(null)); + return; } + Instant now = clock.instant(); + LearnedRateLimiter.retryAfter(headers.getFirst(HttpHeaders.RETRY_AFTER), now) + .ifPresent(wait -> refusedUntil = now.plus(wait)); } /** One app's entry in an {@code appdetails} response, keyed by appId. */ @@ -268,15 +252,14 @@ public Optional fetchData(String appId, Locale locale, boolean r } try { Map response = restClient.get() - .uri(APP_DETAILS_URL + "?appids=" + effectiveAppId + "&l=" + lang) + .uri(APP_DETAILS_URL + "?appids={appId}&l={lang}", effectiveAppId, lang) .retrieve() .body(new ParameterizedTypeReference<>() {}); - nextWait = RATE_LIMIT_FIRST; AppDetailsEntry entry = response == null ? null : response.get(effectiveAppId); if (entry == null || !entry.success()) return Optional.empty(); return Optional.ofNullable(entry.data()); } catch (HttpClientErrorException.TooManyRequests e) { - onRateLimited(e.getResponseHeaders()); + onRateLimited(e); return Optional.empty(); } catch (Exception e) { log.warn("Steam fetchData failed for appId={}: {}", appId, e.getMessage(), e); @@ -295,13 +278,13 @@ public Optional fetchIADisclosure(String appId, Locale locale) { if (rateLimitedUntil().isPresent()) return Optional.empty(); try { String response = restClient.get() - .uri("https://store.steampowered.com/app/" + effectiveAppId + "?l=" + lang) + .uri("https://" + STORE_HOST + "/app/{appId}?l={lang}", effectiveAppId, lang) .retrieve() .body(String.class); if (response == null || response.isBlank()) return Optional.empty(); return Optional.of(response); } catch (HttpClientErrorException.TooManyRequests e) { - onRateLimited(e.getResponseHeaders()); + onRateLimited(e); return Optional.empty(); } catch (Exception e) { log.warn("Steam fetchIADisclosure failed for appId={}: {}", appId, e.getMessage(), e); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java index dcb454095..e94dd6d33 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java @@ -8,6 +8,10 @@ import fr.enimaloc.catapult.service.steam.SteamStoreService.SteamTwSignals; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import io.micrometer.observation.ObservationRegistry; +import fr.enimaloc.catapult.ratelimit.LearnedRateLimitInterceptor; +import fr.enimaloc.catapult.ratelimit.LearnedRateLimitProperties; +import fr.enimaloc.catapult.ratelimit.LearnedRateLimiter; +import fr.enimaloc.catapult.repository.ratelimit.LearnedRateLimitRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -56,13 +60,21 @@ class SteamStoreServiceTest { private MockRestServiceServer steam; private SteamStoreServiceImpl service; + private final MovingClock clock = new MovingClock(); + private LearnedRateLimiter rateLimiter; + @BeforeEach void setUp() { - RestClient.Builder builder = RestClient.builder(); + LearnedRateLimitRepository limits = org.mockito.Mockito.mock(LearnedRateLimitRepository.class); + doReturn(Optional.empty()).when(limits).findById(any()); + rateLimiter = new LearnedRateLimiter(new LearnedRateLimitProperties(), limits, clock); + RestClient.Builder builder = RestClient.builder().requestInterceptor(new LearnedRateLimitInterceptor(rateLimiter)); steam = MockRestServiceServer.bindTo(builder).ignoreExpectOrder(true).build(); ExternalApiObservations observations = new ExternalApiObservations(ObservationRegistry.create(), new SimpleMeterRegistry()); - service = new SteamStoreServiceImpl(builder.build(), observations, steamAppParentRepository, redirectResolver); + service = new SteamStoreServiceImpl(builder.build(), observations, steamAppParentRepository, redirectResolver, + rateLimiter); + service.setClock(clock); doReturn(Optional.empty()).when(steamAppParentRepository).findById(any()); } @@ -466,14 +478,13 @@ private void given429(String appId, String retryAfter) { @Test void a429_leavesTheStoreAloneForItsRetryAfter_thenAsksAgain() { - MovingClock clock = new MovingClock(); - service.setClock(clock); given429("100", "120"); assertThat(service.fetchTwSignals(List.of("100"))).isEmpty(); assertThat(service.rateLimitedUntil()).contains(clock.now.plusSeconds(120)); // Not asked meanwhile (a second request would fail the mock) assertThat(service.fetchTwSignals(List.of("100"))).isEmpty(); + assertThat(service.fetchIADisclosure("100", Locale.ENGLISH)).isEmpty(); steam.verify(); clock.now = clock.now.plusSeconds(121); @@ -484,44 +495,17 @@ void a429_leavesTheStoreAloneForItsRetryAfter_thenAsksAgain() { } @Test - void a429_withoutRetryAfter_waits60s_doublingWhileTheyFollow_upTo10Minutes() { - MovingClock clock = new MovingClock(); - service.setClock(clock); - long[] expected = {60, 120, 240, 480, 600, 600}; - for (long seconds : expected) { - steam.reset(); - given429("100", null); - service.fetchTwSignals(List.of("100")); - assertThat(service.rateLimitedUntil()).contains(clock.now.plusSeconds(seconds)); - clock.now = clock.now.plusSeconds(seconds + 1); - } - } - - @Test - void anAnswer_resetsTheWait() { - MovingClock clock = new MovingClock(); - service.setClock(clock); - given429("100", null); - service.fetchTwSignals(List.of("100")); - clock.now = clock.now.plusSeconds(61); - - steam.reset(); + void aCallTheLearnedLimitRefuses_tellsUntilWhen() { + assertThat(service.rateLimitedUntil()).isEmpty(); + rateLimiter.correct("HOST store.steampowered.com", 1, 10, null, null); givenAppDetails("100", success("100", "{\"type\": \"game\", \"name\": \"Some Game\"}")); - service.fetchTwSignals(List.of("100")); + assertThat(service.fetchData("100", Locale.ENGLISH, false)).isPresent(); steam.reset(); - given429("100", null); - service.fetchTwSignals(List.of("100")); - - assertThat(service.rateLimitedUntil()).contains(clock.now.plusSeconds(60)); - } - @Test - void retryAfter_readsSecondsOrAnHttpDate() { - Instant now = Instant.parse("2026-11-12T10:00:00Z"); - assertThat(SteamStoreServiceImpl.retryAfter("30", now)).contains(java.time.Duration.ofSeconds(30)); - assertThat(SteamStoreServiceImpl.retryAfter("Thu, 12 Nov 2026 10:05:00 GMT", now)) - .contains(java.time.Duration.ofMinutes(5)); - assertThat(SteamStoreServiceImpl.retryAfter("soon", now)).isEmpty(); - assertThat(SteamStoreServiceImpl.retryAfter(null, now)).isEmpty(); + // A call per 10 s: the next is refused without being sent + assertThat(service.fetchData("100", Locale.ENGLISH, false)).isEmpty(); + assertThat(service.rateLimitedUntil()).contains(clock.now.plusSeconds(10)); + clock.now = clock.now.plusSeconds(11); + assertThat(service.rateLimitedUntil()).isEmpty(); } } From 8f4041ae34cb3aa9b29a19c23d6254602c2fb6f6 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:49:09 +0100 Subject: [PATCH 45/53] fix(xbox): use a URI template for the store's product calls --- .../enimaloc/catapult/service/xbox/XboxStoreServiceImpl.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/xbox/XboxStoreServiceImpl.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/xbox/XboxStoreServiceImpl.java index bc66c7016..e4f81f342 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/xbox/XboxStoreServiceImpl.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/xbox/XboxStoreServiceImpl.java @@ -35,8 +35,8 @@ public Optional fetchProduct(String productId, Locale locale) { ? "en-US" : locale.toLanguageTag(); String market = locale != null && !locale.getCountry().isBlank() ? locale.getCountry() : "US"; DisplayCatalogResponse response = restClient.get() - .uri(DISPLAY_CATALOG_URL + productId + "?market=" + market - + "&languages=" + languageTag + "&fieldsTemplate=Details") + .uri(DISPLAY_CATALOG_URL + "{productId}?market={market}&languages={languages}" + + "&fieldsTemplate=Details", productId, market, languageTag) .retrieve() .body(DisplayCatalogResponse.class); if (response == null || response.product() == null) { From ca73b99d740fdaa8a735ba7e6e27579dbe429391 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:49:44 +0100 Subject: [PATCH 46/53] feat(tw): run the TW recalculation as bulk traffic under the learned rate limits --- .../catapult/service/tw/TwBackfillService.java | 5 ++++- .../catapult/service/tw/TwBackfillServiceTest.java | 11 ++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwBackfillService.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwBackfillService.java index 9d5e369b0..1e9f8c79d 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwBackfillService.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/tw/TwBackfillService.java @@ -4,6 +4,7 @@ import fr.enimaloc.catapult.domain.binding.GameBinding; import fr.enimaloc.catapult.domain.binding.GameMapping; import fr.enimaloc.catapult.domain.config.AppState; +import fr.enimaloc.catapult.ratelimit.RateLimitPriority; import fr.enimaloc.catapult.repository.binding.GameBindingRepository; import fr.enimaloc.catapult.repository.binding.GameMappingRepository; import fr.enimaloc.catapult.repository.config.AppStateRepository; @@ -260,7 +261,9 @@ private boolean remap(GameBinding b, String metric, Counts c, long sleepMs) { try { for (int waits = 0; ; waits++) { AtomicReference limited = new AtomicReference<>(); - DebugContext.runAs(b.getUser().getId(), () -> limited.set(resolveTws(b, metric, c))); + // Bulk traffic: kept under the learned rate limits with a margin for live calls, waiting its turn + RateLimitPriority.run(RateLimitPriority.BULK, () -> cancelled, + () -> DebugContext.runAs(b.getUser().getId(), () -> limited.set(resolveTws(b, metric, c)))); if (limited.get() == null) break; if (waits >= MAX_STEAM_WAITS) { log.warn("TW {} gave up on binding {}: the Steam store stayed rate limited", metric, b.getId()); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/tw/TwBackfillServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/tw/TwBackfillServiceTest.java index f6ca3cb1c..ff5ea24b0 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/tw/TwBackfillServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/tw/TwBackfillServiceTest.java @@ -77,11 +77,20 @@ void processesEligibleBindings_skipsOverride_persistsFlag() { when(igdb.resolveIgdbIdForBinding(b1)).thenReturn(Optional.of("igdb42")); when(igdb.fetchDescriptorIds("igdb42")).thenReturn(Set.of(10L)); TwResolverService res = mock(TwResolverService.class); - when(res.suggestReporting(any())).thenReturn(new TwResolverService.Suggestion(Set.of("violence_graphic"), Optional.empty())); + java.util.List priorities = new java.util.ArrayList<>(); + when(res.suggestReporting(any())).thenAnswer(i -> { + priorities.add(fr.enimaloc.catapult.ratelimit.RateLimitPriority.current()); + return new TwResolverService.Suggestion(Set.of("violence_graphic"), Optional.empty()); + }); TwBackfillService svc = build(appRepo, bRepo, igdb, res); svc.run(); + // Resolved as bulk traffic: under the learned rate limits' margin, leaving room for live calls + assertThat(priorities).containsOnly(fr.enimaloc.catapult.ratelimit.RateLimitPriority.BULK); + assertThat(fr.enimaloc.catapult.ratelimit.RateLimitPriority.current()) + .isEqualTo(fr.enimaloc.catapult.ratelimit.RateLimitPriority.LIVE); + verify(bRepo, atLeastOnce()).save(b1); assertThat(b1.getTws()).containsExactly("violence_graphic"); verify(appRepo).save(any(AppState.class)); From 1f3f8dbc923dc2d39d20854edec25dd01603be0e Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:51:18 +0100 Subject: [PATCH 47/53] feat(admin): expose the learned rate limits in the admin API --- .../admin/ApiAdminRateLimitsController.java | 38 +++++++++++++++++++ .../ApiAdminRateLimitsControllerTest.java | 33 ++++++++++++++++ .../src/test/resources/access-matrix.csv | 3 ++ 3 files changed, 74 insertions(+) create mode 100644 catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminRateLimitsController.java create mode 100644 catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminRateLimitsControllerTest.java diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminRateLimitsController.java b/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminRateLimitsController.java new file mode 100644 index 000000000..0b126f886 --- /dev/null +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/api/admin/ApiAdminRateLimitsController.java @@ -0,0 +1,38 @@ +package fr.enimaloc.catapult.api.admin; + +import fr.enimaloc.catapult.api.ApiPaths; +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitCorrectionDto; +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitDto; +import fr.enimaloc.catapult.ratelimit.LearnedRateLimiter; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * The rate limits Catapult learned from the platforms' 429s (hosts and their routes), an admin's correction of one + * and its reset. {@code id}: the scope's ID ({@code HOST store.steampowered.com}, + * {@code GET store.steampowered.com/app/{id}}). + */ +@RestController +@RequestMapping(ApiPaths.ADMIN + "/rate-limits") +@RequiredArgsConstructor +public class ApiAdminRateLimitsController { + + private final LearnedRateLimiter limiter; + + @GetMapping + public List list() { + return limiter.list(); + } + + @PutMapping("/entry") + public LearnedRateLimitDto correct(@RequestParam String id, @RequestBody LearnedRateLimitCorrectionDto body) { + return limiter.correct(id, body.limitCalls(), body.limitWindowSeconds(), body.resumeSeconds(), body.scope()); + } + + @DeleteMapping("/entry") + public LearnedRateLimitDto reset(@RequestParam String id) { + return limiter.reset(id); + } +} diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminRateLimitsControllerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminRateLimitsControllerTest.java new file mode 100644 index 000000000..c585185f5 --- /dev/null +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminRateLimitsControllerTest.java @@ -0,0 +1,33 @@ +package fr.enimaloc.catapult.api.admin; + +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitCorrectionDto; +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitDto; +import fr.enimaloc.catapult.ratelimit.LearnedRateLimiter; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class ApiAdminRateLimitsControllerTest { + + private static final String HOST = "HOST store.steampowered.com"; + private static final LearnedRateLimitDto ROW = new LearnedRateLimitDto(HOST, "store.steampowered.com", null, null, + null, false, 20, 60, true, 30_000, false, 1, null, 0, null); + + private final LearnedRateLimiter limiter = mock(LearnedRateLimiter.class); + private final ApiAdminRateLimitsController controller = new ApiAdminRateLimitsController(limiter); + + @Test + void theLearnedLimits_areListedCorrectedAndReset() { + when(limiter.list()).thenReturn(List.of(ROW)); + when(limiter.correct(HOST, 20, 60, null, "ROUTE")).thenReturn(ROW); + when(limiter.reset(HOST)).thenReturn(ROW); + + assertThat(controller.list()).containsExactly(ROW); + assertThat(controller.correct(HOST, new LearnedRateLimitCorrectionDto(20, 60, null, "ROUTE"))).isEqualTo(ROW); + assertThat(controller.reset(HOST)).isEqualTo(ROW); + assertThat(ROW.isHost()).isTrue(); + } +} diff --git a/catapult-api/src/test/resources/access-matrix.csv b/catapult-api/src/test/resources/access-matrix.csv index 62e51d29d..bb1dad6a5 100644 --- a/catapult-api/src/test/resources/access-matrix.csv +++ b/catapult-api/src/test/resources/access-matrix.csv @@ -226,6 +226,9 @@ DELETE,/api/internal/admin/dtdd-caches/refetch-all,,,401,PASS,403,403,403,PASS,4 GET,/api/internal/admin/dtdd-caches/{kind},kind=topics,,401,PASS,403,403,403,PASS,401 GET,/api/internal/admin/dtdd-caches/{kind}/entry,kind=topics;?key=matrix,,401,PASS,403,403,403,PASS,401 POST,/api/internal/admin/dtdd-caches/{kind}/refetch,kind=topics;?key=matrix,,401,PASS,403,403,403,PASS,401 +GET,/api/internal/admin/rate-limits,,,401,PASS,403,403,403,PASS,401 +PUT,/api/internal/admin/rate-limits/entry,?id=matrix,,401,PASS,403,403,403,PASS,401 +DELETE,/api/internal/admin/rate-limits/entry,?id=matrix,,401,PASS,403,403,403,PASS,401 GET,/api/internal/admin/dtdd-mapping-proposals,,,401,PASS,403,403,403,PASS,401 # handler reads the JWT itself: 401 without one POST,/api/internal/admin/dtdd-mapping-proposals/{id}/approve,,,401,401,403,403,403,PASS,401 From 48cac62814b5a53473b828fd8033e3ec78618774 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:57:22 +0100 Subject: [PATCH 48/53] refactor(ratelimit): name the host row check hostScope so JSON keeps the host field --- .../java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java | 2 +- .../catapult/api/admin/ApiAdminRateLimitsControllerTest.java | 2 +- .../fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java | 2 +- .../enimaloc/catapult/common/dto/admin/LearnedRateLimitDto.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java index 8ba1e42fd..0484c7275 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java @@ -166,7 +166,7 @@ public List list() { scopes.values().forEach(s -> out.putIfAbsent(s.id(), s.toDto(now))); return out.values().stream() .sorted(Comparator.comparing(LearnedRateLimitDto::host) - .thenComparing(d -> d.isHost() ? "" : d.path() + " " + d.method())) + .thenComparing(d -> d.hostScope() ? "" : d.path() + " " + d.method())) .toList(); } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminRateLimitsControllerTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminRateLimitsControllerTest.java index c585185f5..98a08776b 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminRateLimitsControllerTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/api/admin/ApiAdminRateLimitsControllerTest.java @@ -28,6 +28,6 @@ void theLearnedLimits_areListedCorrectedAndReset() { assertThat(controller.list()).containsExactly(ROW); assertThat(controller.correct(HOST, new LearnedRateLimitCorrectionDto(20, 60, null, "ROUTE"))).isEqualTo(ROW); assertThat(controller.reset(HOST)).isEqualTo(ROW); - assertThat(ROW.isHost()).isTrue(); + assertThat(ROW.hostScope()).isTrue(); } } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java index cf17ecbc0..73e50f48c 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java @@ -283,7 +283,7 @@ void whatWasStored_isTakenBack_andSyncedFromTheOtherInstances() { assertThat(other.list()).extracting(LearnedRateLimitDto::id) .containsExactly("HOST " + STORE, APPDETAILS.id(), PAGE.id()); assertThat(other.list().getFirst().limitCalls()).isEqualTo(10); - assertThat(other.list().getFirst().isHost()).isTrue(); + assertThat(other.list().getFirst().hostScope()).isTrue(); other.correct(APPDETAILS.id(), 3, 10, 5, "ROUTE"); limiter.sync(); diff --git a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/LearnedRateLimitDto.java b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/LearnedRateLimitDto.java index 0782c7e6e..26f3273b7 100644 --- a/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/LearnedRateLimitDto.java +++ b/catapult-common/src/main/java/fr/enimaloc/catapult/common/dto/admin/LearnedRateLimitDto.java @@ -17,7 +17,7 @@ public record LearnedRateLimitDto(String id, String host, String method, String Instant blockedUntil) { /** A host's row rather than a route's. */ - public boolean isHost() { + public boolean hostScope() { return path == null; } } From e55fe646e5d6a06a9722ee9378c4e1d75748e17a Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 07:57:22 +0100 Subject: [PATCH 49/53] feat(admin): a tab to see, correct and reset the learned rate limits --- .../controller/AdminRateLimitsController.java | 35 +++++ .../AdminTabsFragmentController.java | 9 ++ .../enimaloc/catapult/service/ApiService.java | 12 ++ .../catapult/service/mock/MockApiService.java | 20 +++ .../service/mock/MockRateLimitsStore.java | 76 +++++++++++ .../catapult/service/real/RealApiService.java | 20 +++ .../main/resources/lang/messages.properties | 35 +++++ .../resources/lang/messages_en.properties | 35 +++++ .../resources/lang/messages_fr.properties | 35 +++++ .../resources/static/js/admin-rate-limits.js | 86 ++++++++++++ .../src/main/resources/templates/index.html | 1 + .../pages/admin-tab/rate-limits.html | 93 +++++++++++++ .../main/resources/templates/pages/admin.html | 4 +- .../AdminRateLimitsControllerTest.java | 68 ++++++++++ .../AdminRateLimitsRenderingTest.java | 77 +++++++++++ .../service/mock/MockApiServiceTest.java | 9 ++ .../service/mock/MockRateLimitsStoreTest.java | 57 ++++++++ .../service/real/RealApiServiceTest.java | 21 +++ .../src/test/js/admin-rate-limits.test.js | 127 ++++++++++++++++++ 19 files changed, 818 insertions(+), 2 deletions(-) create mode 100644 catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminRateLimitsController.java create mode 100644 catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStore.java create mode 100644 catapult-web/src/main/resources/static/js/admin-rate-limits.js create mode 100644 catapult-web/src/main/resources/templates/pages/admin-tab/rate-limits.html create mode 100644 catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminRateLimitsControllerTest.java create mode 100644 catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminRateLimitsRenderingTest.java create mode 100644 catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStoreTest.java create mode 100644 catapult-web/src/test/js/admin-rate-limits.test.js diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminRateLimitsController.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminRateLimitsController.java new file mode 100644 index 000000000..ce7a4fa63 --- /dev/null +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminRateLimitsController.java @@ -0,0 +1,35 @@ +package fr.enimaloc.catapult.controller; + +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitCorrectionDto; +import fr.enimaloc.catapult.service.ApiService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * The admin rate limits tab's actions, forwarded to catapult-api with the session's JWT: a learned limit corrected + * ({@code id}: the scope's), or reset. They answer catapult-api's status; 502 when it is unreachable. + */ +@RestController +@RequestMapping("/admin/rate-limits") +@RequiredArgsConstructor +public class AdminRateLimitsController { + private final ApiService apiService; + private final ModelFiller filler; + + @PostMapping("/correct") + public ResponseEntity correct(@RequestParam String id, @RequestBody LearnedRateLimitCorrectionDto body) { + AdminAccess.require(filler); + return ResponseEntity.status(apiService.adminCorrectRateLimit(id, body)).build(); + } + + @PostMapping("/reset") + public ResponseEntity reset(@RequestParam String id) { + AdminAccess.require(filler); + return ResponseEntity.status(apiService.adminResetRateLimit(id)).build(); + } +} diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java index 7e9035ef5..6dd38f9fc 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/controller/AdminTabsFragmentController.java @@ -412,6 +412,15 @@ String dtddCacheEntry(Model model, HttpServletResponse response, @PathVariable S }); } + /** The rate limits learned from the platforms' 429s: each host, then its routes. */ + @GetMapping("/rate-limits") + String rateLimits(Model model, HttpServletResponse response) { + return guarded(model, response, () -> { + model.addAttribute("limits", apiService.adminRateLimits()); + return "pages/admin-tab/rate-limits :: list"; + }); + } + @GetMapping("/groups") String groups(Model model, HttpServletResponse response) { return guarded(model, response, () -> { diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java index d8f28e62a..867aaa2e5 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/ApiService.java @@ -8,6 +8,8 @@ import fr.enimaloc.catapult.service.http.ApiClient; import fr.enimaloc.catapult.service.http.ApiError; import fr.enimaloc.catapult.common.dto.*; +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitCorrectionDto; +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitDto; import fr.enimaloc.catapult.common.dto.admin.AdminMemberDto; import fr.enimaloc.catapult.common.dto.admin.AdminMembersPage; import fr.enimaloc.catapult.common.dto.admin.AdminCclPage; @@ -377,6 +379,16 @@ default int adminCreateNotification(AdminNotificationCreateBody body) { /** catapult-api's status (200) and the refetch asked to stop, 502 when unreachable. */ ApiClient.Result adminCancelDtddRefetch(); + /** The rate limits catapult-api learned from the platforms' 429s, hosts then their routes; null when it fails. */ + List adminRateLimits(); + /** + * Corrects a learned rate limit ({@code id}: the scope's): catapult-api's status (200; 400 an invalid correction; + * 404 unknown), 502 when unreachable. + */ + int adminCorrectRateLimit(String id, LearnedRateLimitCorrectionDto correction); + /** Forgets what was learned and corrected of a rate limit: catapult-api's status (200; 404 unknown), 502 when unreachable. */ + int adminResetRateLimit(String id); + /** The member groups, with their member counts; null when catapult-api fails. */ List adminGroups(); /** catapult-api's status (201; 400 blank key; 409 key already used), 502 when unreachable. */ diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockApiService.java index 301be37d7..db51486fe 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockApiService.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockApiService.java @@ -177,6 +177,7 @@ public class MockApiService implements ApiService { /** Application-wide: the game mappings an admin edits are the same for every mock session. */ private final MockGameMappingsStore gameMappings = new MockGameMappingsStore(); private final MockDtddCachesStore dtddCaches = new MockDtddCachesStore(); + private final MockRateLimitsStore rateLimits = new MockRateLimitsStore(Clock.systemUTC()); /** Application-wide: the groups an admin creates are the same for every mock session. */ private final MockGroupsStore groups = new MockGroupsStore(); /** Application-wide: the experiments an admin edits are the same for every mock session. */ @@ -872,6 +873,25 @@ public List adminDtddProposals(String status) { return dtddProposals.list(status); } + @Override + public List adminRateLimits() { + log.trace("adminRateLimits()"); + return rateLimits.list(); + } + + @Override + public int adminCorrectRateLimit(String id, + fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitCorrectionDto correction) { + log.trace("adminCorrectRateLimit({}, {})", id, correction); + return rateLimits.correct(id, correction); + } + + @Override + public int adminResetRateLimit(String id) { + log.trace("adminResetRateLimit({})", id); + return rateLimits.reset(id); + } + @Override public List adminGroups() { log.trace("adminGroups()"); diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStore.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStore.java new file mode 100644 index 000000000..9fa6b8fbd --- /dev/null +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStore.java @@ -0,0 +1,76 @@ +package fr.enimaloc.catapult.service.mock; + +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitCorrectionDto; +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitDto; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * The mock's learned rate limits: the Steam store's host with its routes (the page split off with a limit of its + * own) and an Xbox host never limited. Corrected and reset like catapult-api: 400 for an invalid correction, 404 for + * an unknown scope. + */ +class MockRateLimitsStore { + + private static final String STORE = "store.steampowered.com"; + private final List limits = new ArrayList<>(); + + MockRateLimitsStore(Clock clock) { + Instant now = clock.instant(); + limits.add(new LearnedRateLimitDto("HOST " + STORE, STORE, null, null, null, false, 190, 300, false, 76_800, + false, 4, now.minus(Duration.ofMinutes(42)), 23, null)); + limits.add(new LearnedRateLimitDto("GET " + STORE + "/api/appdetails", STORE, "GET", "/api/appdetails", "HOST", + false, null, null, false, 60_000, false, 3, now.minus(Duration.ofMinutes(42)), 21, null)); + limits.add(new LearnedRateLimitDto("GET " + STORE + "/app/{id}", STORE, "GET", "/app/{id}", "ROUTE", false, 12, + 60, false, 30_000, false, 1, now.minus(Duration.ofMinutes(3)), 12, now.plusSeconds(25))); + limits.add(new LearnedRateLimitDto("HOST titlehub.xboxlive.com", "titlehub.xboxlive.com", null, null, null, + false, null, null, false, 60_000, false, 0, null, 2, null)); + } + + List list() { + return List.copyOf(limits); + } + + int correct(String id, LearnedRateLimitCorrectionDto c) { + int i = indexOf(id); + if (i < 0) return 404; + if (c == null || (c.limitCalls() == null) != (c.limitWindowSeconds() == null) + || (c.limitCalls() != null && (c.limitCalls() < 1 || c.limitWindowSeconds() < 1)) + || (c.resumeSeconds() != null && c.resumeSeconds() < 1)) { + return 400; + } + LearnedRateLimitDto d = limits.get(i); + String scope = c.scope() == null || c.scope().isBlank() || d.hostScope() ? d.scope() + : c.scope().trim().toUpperCase(Locale.ROOT); + if (scope != null && !scope.equals("HOST") && !scope.equals("ROUTE")) return 400; + boolean limitSet = c.limitCalls() != null; + boolean resumeSet = c.resumeSeconds() != null; + limits.set(i, new LearnedRateLimitDto(d.id(), d.host(), d.method(), d.path(), scope, + d.scopePinned() || !d.hostScope() && c.scope() != null && !c.scope().isBlank(), + limitSet ? c.limitCalls() : d.limitCalls(), limitSet ? c.limitWindowSeconds() : d.limitWindowSeconds(), + limitSet, resumeSet ? c.resumeSeconds() * 1000L : d.resumeMs(), resumeSet, d.rateLimitedCount(), + d.lastRateLimitedAt(), d.usedCalls(), d.blockedUntil())); + return 200; + } + + int reset(String id) { + int i = indexOf(id); + if (i < 0) return 404; + LearnedRateLimitDto d = limits.get(i); + limits.set(i, new LearnedRateLimitDto(d.id(), d.host(), d.method(), d.path(), d.hostScope() ? null : "HOST", false, + null, null, false, 60_000, false, 0, null, d.usedCalls(), null)); + return 200; + } + + private int indexOf(String id) { + for (int i = 0; i < limits.size(); i++) { + if (limits.get(i).id().equals(id)) return i; + } + return -1; + } +} diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java index e62db2b12..162e4ac90 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/real/RealApiService.java @@ -8,6 +8,8 @@ import fr.enimaloc.catapult.common.dto.premium.PremiumStatusDto; import fr.enimaloc.catapult.common.dto.channel.DeleteAccountRequest; import fr.enimaloc.catapult.common.dto.TokenResponse; +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitCorrectionDto; +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitDto; import fr.enimaloc.catapult.common.dto.admin.AdminMemberDto; import fr.enimaloc.catapult.common.dto.admin.AdminMembersPage; import fr.enimaloc.catapult.common.dto.admin.AdminCclPage; @@ -173,6 +175,7 @@ public class RealApiService implements ApiService { private static final ParameterizedTypeReference> TWS = new ParameterizedTypeReference<>() {}; private static final ParameterizedTypeReference> ENABLED_COMMANDS = new ParameterizedTypeReference<>() {}; private static final ParameterizedTypeReference> GROUPS = new ParameterizedTypeReference<>() {}; + private static final ParameterizedTypeReference> RATE_LIMITS = new ParameterizedTypeReference<>() {}; private static final ParameterizedTypeReference> EXPERIMENTS = new ParameterizedTypeReference<>() {}; private static final ParameterizedTypeReference> FLAG_KEYS = new ParameterizedTypeReference<>() {}; private static final ParameterizedTypeReference> ADMIN_NOTIFICATIONS = new ParameterizedTypeReference<>() {}; @@ -1052,6 +1055,23 @@ public ApiClient.Result adminCancelDtddRefetch() { DtddRefetchJobDto.class); } + @Override + public List adminRateLimits() { + return client.get("/api/internal/admin/rate-limits", RATE_LIMITS); + } + + @Override + public int adminCorrectRateLimit(String id, LearnedRateLimitCorrectionDto correction) { + return client.call(HttpMethod.PUT, "/api/internal/admin/rate-limits/entry?id={id}", correction, + LearnedRateLimitDto.class, id).status(); + } + + @Override + public int adminResetRateLimit(String id) { + return client.call(HttpMethod.DELETE, "/api/internal/admin/rate-limits/entry?id={id}", null, + LearnedRateLimitDto.class, id).status(); + } + @Override public List adminGroups() { return client.get("/api/internal/admin/groups", GROUPS); diff --git a/catapult-web/src/main/resources/lang/messages.properties b/catapult-web/src/main/resources/lang/messages.properties index acc53bab4..bf4e4d307 100644 --- a/catapult-web/src/main/resources/lang/messages.properties +++ b/catapult-web/src/main/resources/lang/messages.properties @@ -731,6 +731,41 @@ admin.dtdd-mapping.unknown=This proposal no longer exists. admin.dtdd-mapping.unavailable=The proposals are unavailable right now. admin.tab.game-mappings=Game mappings admin.tab.dtdd-caches=DTDD caches +admin.tab.rate-limits=Rate limits +admin.rate-limits.hint=What Catapult guessed from the 429s of the platforms that don't document their limit: each host's budget, shared by its routes unless one shows a limit of its own, and how long to wait before calling again. Live calls pass first; bulk work (TW recalculation) keeps a margin under the limit. +admin.rate-limits.none=No platform limited Catapult yet. +admin.rate-limits.column.scope=Host / route +admin.rate-limits.column.limit=Limit +admin.rate-limits.column.resume=Resume after +admin.rate-limits.column.rate_limited=429s +admin.rate-limits.column.used=Calls in the window +admin.rate-limits.scope.HOST=Host's budget +admin.rate-limits.scope.ROUTE=Its own limit +admin.rate-limits.scope.pinned=chosen by an admin +admin.rate-limits.scope.learned=Learned +admin.rate-limits.limit.none=none known +admin.rate-limits.limit={0} per {1} +admin.rate-limits.window.10=10 s +admin.rate-limits.window.60=minute +admin.rate-limits.window.300=5 min +admin.rate-limits.window.3600=hour +admin.rate-limits.corrected=corrected +admin.rate-limits.seconds={0} s +admin.rate-limits.last=last on {0} +admin.rate-limits.blocked=waiting until {0} +admin.rate-limits.edit=Correct +admin.rate-limits.edit.hint=An empty field is left to the learning; what you set stays until the limit is reset. +admin.rate-limits.field.calls=Calls +admin.rate-limits.field.window=Per +admin.rate-limits.field.resume=Resume after (seconds) +admin.rate-limits.field.scope=Scope +admin.rate-limits.save=Save +admin.rate-limits.saved=Correction saved. +admin.rate-limits.reset=Reset +admin.rate-limits.reset.confirm=Reset this limit? +admin.rate-limits.reset.description=What was learned and corrected is forgotten: Catapult starts again without a known limit. +admin.rate-limits.error.400=Invalid correction: the calls and their window go together, and every value is at least 1. +admin.rate-limits.error.404=This limit is no longer known. admin.game-mappings.hint=Sets the Twitch category of a detected game for every streamer, or ignores it by default (a wallpaper app such as Wallpaper Engine). A streamer's own choice still comes first; the games without a mapping keep IGDB's. admin.game-mappings.new=New mapping admin.game-mappings.empty=No game mapping yet. diff --git a/catapult-web/src/main/resources/lang/messages_en.properties b/catapult-web/src/main/resources/lang/messages_en.properties index 911f97536..21c611b9a 100644 --- a/catapult-web/src/main/resources/lang/messages_en.properties +++ b/catapult-web/src/main/resources/lang/messages_en.properties @@ -731,6 +731,41 @@ admin.dtdd-mapping.unknown=This proposal no longer exists. admin.dtdd-mapping.unavailable=The proposals are unavailable right now. admin.tab.game-mappings=Game mappings admin.tab.dtdd-caches=DTDD caches +admin.tab.rate-limits=Rate limits +admin.rate-limits.hint=What Catapult guessed from the 429s of the platforms that don't document their limit: each host's budget, shared by its routes unless one shows a limit of its own, and how long to wait before calling again. Live calls pass first; bulk work (TW recalculation) keeps a margin under the limit. +admin.rate-limits.none=No platform limited Catapult yet. +admin.rate-limits.column.scope=Host / route +admin.rate-limits.column.limit=Limit +admin.rate-limits.column.resume=Resume after +admin.rate-limits.column.rate_limited=429s +admin.rate-limits.column.used=Calls in the window +admin.rate-limits.scope.HOST=Host's budget +admin.rate-limits.scope.ROUTE=Its own limit +admin.rate-limits.scope.pinned=chosen by an admin +admin.rate-limits.scope.learned=Learned +admin.rate-limits.limit.none=none known +admin.rate-limits.limit={0} per {1} +admin.rate-limits.window.10=10 s +admin.rate-limits.window.60=minute +admin.rate-limits.window.300=5 min +admin.rate-limits.window.3600=hour +admin.rate-limits.corrected=corrected +admin.rate-limits.seconds={0} s +admin.rate-limits.last=last on {0} +admin.rate-limits.blocked=waiting until {0} +admin.rate-limits.edit=Correct +admin.rate-limits.edit.hint=An empty field is left to the learning; what you set stays until the limit is reset. +admin.rate-limits.field.calls=Calls +admin.rate-limits.field.window=Per +admin.rate-limits.field.resume=Resume after (seconds) +admin.rate-limits.field.scope=Scope +admin.rate-limits.save=Save +admin.rate-limits.saved=Correction saved. +admin.rate-limits.reset=Reset +admin.rate-limits.reset.confirm=Reset this limit? +admin.rate-limits.reset.description=What was learned and corrected is forgotten: Catapult starts again without a known limit. +admin.rate-limits.error.400=Invalid correction: the calls and their window go together, and every value is at least 1. +admin.rate-limits.error.404=This limit is no longer known. admin.game-mappings.hint=Sets the Twitch category of a detected game for every streamer, or ignores it by default (a wallpaper app such as Wallpaper Engine). A streamer's own choice still comes first; the games without a mapping keep IGDB's. admin.game-mappings.new=New mapping admin.game-mappings.empty=No game mapping yet. diff --git a/catapult-web/src/main/resources/lang/messages_fr.properties b/catapult-web/src/main/resources/lang/messages_fr.properties index 9b4cb76a1..0e48e67af 100644 --- a/catapult-web/src/main/resources/lang/messages_fr.properties +++ b/catapult-web/src/main/resources/lang/messages_fr.properties @@ -731,6 +731,41 @@ admin.dtdd-mapping.unknown=Cette proposition n'existe plus. admin.dtdd-mapping.unavailable=Les propositions sont indisponibles pour le moment. admin.tab.game-mappings=Mappings de jeu admin.tab.dtdd-caches=Caches DTDD +admin.tab.rate-limits=Limites de débit +admin.rate-limits.hint=Ce que Catapult a deviné des 429 des plateformes qui ne documentent pas leur limite : le budget de chaque hôte, partagé par ses routes sauf celles qui montrent une limite à elles, et l'attente avant de rappeler. Les appels en live passent d'abord ; le travail de masse (recalcul des TW) garde une marge sous la limite. +admin.rate-limits.none=Aucune plateforme n'a encore limité Catapult. +admin.rate-limits.column.scope=Hôte / route +admin.rate-limits.column.limit=Limite +admin.rate-limits.column.resume=Reprise après +admin.rate-limits.column.rate_limited=429 +admin.rate-limits.column.used=Appels dans la fenêtre +admin.rate-limits.scope.HOST=Budget de l'hôte +admin.rate-limits.scope.ROUTE=Limite propre +admin.rate-limits.scope.pinned=choisi par un admin +admin.rate-limits.scope.learned=Appris +admin.rate-limits.limit.none=aucune connue +admin.rate-limits.limit={0} par {1} +admin.rate-limits.window.10=10 s +admin.rate-limits.window.60=minute +admin.rate-limits.window.300=5 min +admin.rate-limits.window.3600=heure +admin.rate-limits.corrected=corrigé +admin.rate-limits.seconds={0} s +admin.rate-limits.last=dernier le {0} +admin.rate-limits.blocked=en attente jusqu''à {0} +admin.rate-limits.edit=Corriger +admin.rate-limits.edit.hint=Un champ vide est laissé à l'apprentissage ; ce que tu fixes reste jusqu'à la remise à zéro. +admin.rate-limits.field.calls=Appels +admin.rate-limits.field.window=Par +admin.rate-limits.field.resume=Reprise après (secondes) +admin.rate-limits.field.scope=Portée +admin.rate-limits.save=Enregistrer +admin.rate-limits.saved=Correction enregistrée. +admin.rate-limits.reset=Remettre à zéro +admin.rate-limits.reset.confirm=Remettre cette limite à zéro ? +admin.rate-limits.reset.description=Ce qui a été appris et corrigé est oublié : Catapult repart sans limite connue. +admin.rate-limits.error.400=Correction invalide : les appels vont avec leur fenêtre, et chaque valeur vaut au moins 1. +admin.rate-limits.error.404=Cette limite n'est plus connue. admin.game-mappings.hint=Définit la catégorie Twitch d'un jeu détecté pour tous les streamers, ou l'ignore par défaut (un fond d'écran comme Wallpaper Engine). Le choix d'un streamer reste prioritaire ; les jeux sans mapping gardent celle d'IGDB. admin.game-mappings.new=Nouveau mapping admin.game-mappings.empty=Aucun mapping de jeu. diff --git a/catapult-web/src/main/resources/static/js/admin-rate-limits.js b/catapult-web/src/main/resources/static/js/admin-rate-limits.js new file mode 100644 index 000000000..c16812138 --- /dev/null +++ b/catapult-web/src/main/resources/static/js/admin-rate-limits.js @@ -0,0 +1,86 @@ +/** + * The admin rate limits tab. A learned limit is corrected in the view's dialog, filled from its row (an empty field + * leaves that part to the learning; a host has no scope to choose), or reset once confirmed; the list is then patched + * (admin-tabs.js). A refusal is told with the view's data-error-{status}, else its data-error ({0}: the status). + * Listeners are delegated to the panel: its content comes and goes. + */ +window.CatapultAdminRateLimits = (function () { + const TAB = "rate-limits"; + const BASE = "/admin/rate-limits"; + + const panelOf = () => document.querySelector(`mdui-tab-panel[value="${TAB}"]`); + + /** A number typed in a field, null when left empty. */ + function numberOf(field) { + const value = String(field?.value ?? "").trim(); + return value === "" ? null : Number(value); + } + + /** The dialog filled from the row's correction (what an admin set; empty: learned). */ + function edit(root, row) { + const dialog = root.querySelector("#admin-rl-dialog"); + if (!dialog) return; + dialog.dataset.id = row.dataset.id; + dialog.querySelector(".admin-rl-dialog-id code").textContent = row.dataset.id; + dialog.querySelector('[name="calls"]').value = row.dataset.calls ?? ""; + dialog.querySelector('[name="window"]').value = row.dataset.window || "60"; + dialog.querySelector('[name="resume"]').value = row.dataset.resume ?? ""; + const scope = dialog.querySelector('[name="scope"]'); + scope.hidden = row.dataset.host === "true"; + scope.value = row.dataset.scopePinned === "true" ? row.dataset.scope : ""; + dialog.open = true; + } + + /** The correction the dialog holds: a limit only with its calls, a scope only for a route. */ + function correctionOf(dialog) { + const calls = numberOf(dialog.querySelector('[name="calls"]')); + const scope = dialog.querySelector('[name="scope"]'); + return { + limitCalls: calls, + limitWindowSeconds: calls === null ? null : numberOf(dialog.querySelector('[name="window"]')), + resumeSeconds: numberOf(dialog.querySelector('[name="resume"]')), + scope: scope.hidden || !scope.value ? null : scope.value + }; + } + + async function save(panel, root, button) { + const dialog = button.closest("mdui-dialog"); + const query = new URLSearchParams({ id: dialog.dataset.id }); + if (await CatapultAdminTabs.post(root, button, `${BASE}/correct?${query}`, correctionOf(dialog)) !== "ok") return; + button.disabled = false; + dialog.open = false; + window.mdui?.snackbar?.({ message: root.dataset.saved }); + await CatapultAdminTabs.patch(panel); + } + + async function reset(panel, root, button) { + if (!await CatapultAdminTabs.confirmed(root, root.dataset.resetConfirm, root.dataset.resetDescription)) return; + const query = new URLSearchParams({ id: button.closest("[data-id]").dataset.id }); + const sent = await CatapultAdminTabs.post(root, button, `${BASE}/reset?${query}`, {}); + // gone meanwhile: the list as it is now + if (sent !== "ok" && sent !== 404) return; + button.disabled = false; + await CatapultAdminTabs.patch(panel); + } + + function attach() { + const panel = panelOf(); + if (!panel || panel.dataset.rateLimitsWired) return; + panel.dataset.rateLimitsWired = "true"; + + panel.addEventListener("click", event => { + const button = event.target.closest(".admin-rl-edit, .admin-rl-reset, .admin-rl-save, .admin-dialog-close"); + if (!button || button.disabled) return; + const root = button.closest(".admin-view"); + if (!root) return; + if (button.matches(".admin-dialog-close")) button.closest("mdui-dialog").open = false; + else if (button.matches(".admin-rl-edit")) edit(root, button.closest("[data-id]")); + else if (button.matches(".admin-rl-save")) save(panel, root, button); + else reset(panel, root, button); + }); + } + + document.addEventListener("catapult:render", attach); + + return { attach, correctionOf }; +})(); diff --git a/catapult-web/src/main/resources/templates/index.html b/catapult-web/src/main/resources/templates/index.html index 129dd768a..18e0e84b6 100644 --- a/catapult-web/src/main/resources/templates/index.html +++ b/catapult-web/src/main/resources/templates/index.html @@ -229,6 +229,7 @@ + diff --git a/catapult-web/src/main/resources/templates/pages/admin-tab/rate-limits.html b/catapult-web/src/main/resources/templates/pages/admin-tab/rate-limits.html new file mode 100644 index 000000000..31de46de4 --- /dev/null +++ b/catapult-web/src/main/resources/templates/pages/admin-tab/rate-limits.html @@ -0,0 +1,93 @@ + + + +
    +
    +

    Rate limits

    +
    +

    hint

    + + + + + + +
    + + store.steampowered.com + + GET /app/{id} + host's budget + chosen + + + + none known + + 20 / min + corrected + + + + 60 s + corrected + + + 0 + last + + + 0 + waiting + + + + + +
    +
    + + + +

    hint

    +

    +
    + + + min + + + + learned + host + route + +
    + Cancel + Save +
    +
    diff --git a/catapult-web/src/main/resources/templates/pages/admin.html b/catapult-web/src/main/resources/templates/pages/admin.html index d9d4365c5..55eb136ce 100644 --- a/catapult-web/src/main/resources/templates/pages/admin.html +++ b/catapult-web/src/main/resources/templates/pages/admin.html @@ -9,14 +9,14 @@

    admin.title

    - + admin.tab -
    diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminRateLimitsControllerTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminRateLimitsControllerTest.java new file mode 100644 index 000000000..b46652a44 --- /dev/null +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminRateLimitsControllerTest.java @@ -0,0 +1,68 @@ +package fr.enimaloc.catapult.controller; + +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitCorrectionDto; +import fr.enimaloc.catapult.security.WebSecurityConfig; +import fr.enimaloc.catapult.service.ApiService; +import fr.enimaloc.catapult.service.http.ApiClient; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = AdminRateLimitsController.class) +@Import({WebSecurityConfig.class, ModelFiller.class}) +class AdminRateLimitsControllerTest { + static final String BASE = "/admin/rate-limits"; + static final String HOST = "HOST store.steampowered.com"; + + @Autowired MockMvc mvc; + @MockitoBean ApiService apiService; + @MockitoBean fr.enimaloc.catapult.controller.twitchat.TwitchatEventForms twitchatEventForms; + + private MockHttpServletRequestBuilder admin(MockHttpServletRequestBuilder b) { + return b.with(csrf()).sessionAttr(ApiClient.SESSION_JWT_KEY, "jwt"); + } + + @Test + void aCorrection_isForwardedAsSent_andItsStatusRelayed() throws Exception { + when(apiService.isAdmin()).thenReturn(true); + when(apiService.adminCorrectRateLimit(HOST, new LearnedRateLimitCorrectionDto(20, 60, null, "ROUTE"))) + .thenReturn(200); + when(apiService.adminCorrectRateLimit(HOST, new LearnedRateLimitCorrectionDto(5, null, null, null))) + .thenReturn(400); + + mvc.perform(admin(post(BASE + "/correct").param("id", HOST).contentType(MediaType.APPLICATION_JSON) + .content("{\"limitCalls\":20,\"limitWindowSeconds\":60,\"resumeSeconds\":null,\"scope\":\"ROUTE\"}"))) + .andExpect(status().isOk()); + mvc.perform(admin(post(BASE + "/correct").param("id", HOST).contentType(MediaType.APPLICATION_JSON) + .content("{\"limitCalls\":5}"))).andExpect(status().isBadRequest()); + } + + @Test + void aReset_relaysTheStatus() throws Exception { + when(apiService.isAdmin()).thenReturn(true); + when(apiService.adminResetRateLimit(HOST)).thenReturn(404); + + mvc.perform(admin(post(BASE + "/reset").param("id", HOST))).andExpect(status().isNotFound()); + } + + @Test + void aMember_isRefused() throws Exception { + when(apiService.isAdmin()).thenReturn(false); + + mvc.perform(admin(post(BASE + "/reset").param("id", HOST))).andExpect(status().isForbidden()); + verify(apiService, never()).adminResetRateLimit(any()); + } +} diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminRateLimitsRenderingTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminRateLimitsRenderingTest.java new file mode 100644 index 000000000..57f1b2d79 --- /dev/null +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/controller/AdminRateLimitsRenderingTest.java @@ -0,0 +1,77 @@ +package fr.enimaloc.catapult.controller; + +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitDto; +import fr.enimaloc.catapult.config.I18nConfiguration; +import fr.enimaloc.catapult.config.TwitchatCatalogConfig; +import fr.enimaloc.catapult.security.WebSecurityConfig; +import fr.enimaloc.catapult.service.ApiService; +import fr.enimaloc.catapult.service.http.ApiClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.List; +import java.util.Locale; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** The admin rate limits tab as it renders: each host, then its routes, with what was learned or corrected. */ +@WebMvcTest(controllers = AdminTabsFragmentController.class) +@Import({ModelFiller.class, WebSecurityConfig.class, TwitchatCatalogConfig.class, I18nConfiguration.class}) +class AdminRateLimitsRenderingTest { + + private static final Instant AT = Instant.parse("2026-11-12T10:00:00Z"); + private static final String STORE = "store.steampowered.com"; + + @Autowired MockMvc mvc; + @MockitoBean ApiService apiService; + @MockitoBean BroadcastState broadcastState; + + @BeforeEach + void anAdmin() { + when(apiService.isAdmin()).thenReturn(true); + } + + private String render(Locale locale) throws Exception { + return mvc.perform(get("/spa/admin/rate-limits").locale(locale).sessionAttr(ApiClient.SESSION_JWT_KEY, "jwt")) + .andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); + } + + @Test + void eachHost_thenItsRoutes_withWhatWasLearnedOrCorrected() throws Exception { + when(apiService.adminRateLimits()).thenReturn(List.of( + new LearnedRateLimitDto("HOST " + STORE, STORE, null, null, null, false, 190, 300, false, 76_800, + false, 4, AT, 23, null), + new LearnedRateLimitDto("GET " + STORE + "/app/{id}", STORE, "GET", "/app/{id}", "ROUTE", true, 12, + 60, true, 30_000, true, 1, AT, 12, AT.plusSeconds(25)), + new LearnedRateLimitDto("GET " + STORE + "/api/appdetails", STORE, "GET", "/api/appdetails", "HOST", + false, null, null, false, 60_000, false, 0, null, 3, null), + new LearnedRateLimitDto("HOST odd.example", "odd.example", null, null, null, false, 7, 42, true, + 60_000, false, 0, null, 0, null))); + + String html = render(Locale.FRENCH); + + assertThat(html).contains("data-admin-key=\"HOST store.steampowered.com\"", "admin-rl-host", "admin-rl-route", + "190 par 5 min", "76,8 s", "12 par minute", "Limite propre", "choisi par un admin", "corrigé", + "en attente jusqu", "Budget de l'hôte", "aucune connue", "7 par 42 s", "id=\"admin-rl-dialog\"", + "data-calls=\"12\"", "data-window=\"60\"", "data-resume=\"30\"", "data-scope-pinned=\"true\""); + assertThat(render(Locale.ENGLISH)).contains("190 per 5 min", "Its own limit"); + } + + @Test + void nothingLearned_orCatapultApiDown_isTold() throws Exception { + when(apiService.adminRateLimits()).thenReturn(List.of()); + assertThat(render(Locale.FRENCH)).contains("Aucune plateforme n'a encore limité Catapult."); + + when(apiService.adminRateLimits()).thenReturn(null); + assertThat(render(Locale.FRENCH)).doesNotContain("admin-rl-row\""); + } +} diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java index b3ca013d6..648a9e478 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockApiServiceTest.java @@ -1284,6 +1284,15 @@ void dtddCaches_areTheMocksStore() { assertThat(service.adminCancelDtddRefetch().body().state()).isEqualTo("CANCELLED"); } + @Test + void rateLimits_areTheMocksStore() { + var host = "HOST store.steampowered.com"; + assertThat(service.adminRateLimits()).hasSize(4); + assertThat(service.adminCorrectRateLimit(host, + new fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitCorrectionDto(25, 60, 30, null))).isEqualTo(200); + assertThat(service.adminResetRateLimit(host)).isEqualTo(200); + } + @Test void twDetailsAndDtddTopics_areTheMocks() { String bindingId = service.channelPage("enimaloc", 0, null, null).bindings().content().getFirst().id(); diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStoreTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStoreTest.java new file mode 100644 index 000000000..6d0a3e5ac --- /dev/null +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStoreTest.java @@ -0,0 +1,57 @@ +package fr.enimaloc.catapult.service.mock; + +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitCorrectionDto; +import fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitDto; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThat; + +class MockRateLimitsStoreTest { + + private static final String HOST = "HOST store.steampowered.com"; + private static final String PAGE = "GET store.steampowered.com/app/{id}"; + + private final MockRateLimitsStore store = + new MockRateLimitsStore(Clock.fixed(Instant.parse("2026-11-12T10:00:00Z"), ZoneOffset.UTC)); + + private LearnedRateLimitDto row(String id) { + return store.list().stream().filter(d -> d.id().equals(id)).findFirst().orElseThrow(); + } + + @Test + void aCorrection_isKept_andAResetForgetsIt() { + assertThat(store.correct(HOST, new LearnedRateLimitCorrectionDto(25, 60, 30, "ROUTE"))).isEqualTo(200); + LearnedRateLimitDto host = row(HOST); + assertThat(host.limitCalls()).isEqualTo(25); + assertThat(host.limitCorrected()).isTrue(); + assertThat(host.resumeMs()).isEqualTo(30_000); + assertThat(host.scope()).isNull(); + + assertThat(store.correct(PAGE, new LearnedRateLimitCorrectionDto(null, null, null, "host"))).isEqualTo(200); + assertThat(row(PAGE).scope()).isEqualTo("HOST"); + assertThat(row(PAGE).scopePinned()).isTrue(); + assertThat(row(PAGE).limitCorrected()).isFalse(); + + assertThat(store.reset(PAGE)).isEqualTo(200); + assertThat(row(PAGE).scopePinned()).isFalse(); + assertThat(row(PAGE).limitCalls()).isNull(); + assertThat(store.reset(HOST)).isEqualTo(200); + assertThat(row(HOST).scope()).isNull(); + } + + @Test + void aBadCorrection_orAnUnknownScope_isRefused() { + assertThat(store.correct(HOST, null)).isEqualTo(400); + assertThat(store.correct(HOST, new LearnedRateLimitCorrectionDto(5, null, null, null))).isEqualTo(400); + assertThat(store.correct(HOST, new LearnedRateLimitCorrectionDto(0, 60, null, null))).isEqualTo(400); + assertThat(store.correct(HOST, new LearnedRateLimitCorrectionDto(5, 0, null, null))).isEqualTo(400); + assertThat(store.correct(HOST, new LearnedRateLimitCorrectionDto(null, null, 0, null))).isEqualTo(400); + assertThat(store.correct(PAGE, new LearnedRateLimitCorrectionDto(null, null, null, "nope"))).isEqualTo(400); + assertThat(store.correct("HOST unknown", new LearnedRateLimitCorrectionDto(null, null, null, null))).isEqualTo(404); + assertThat(store.reset("HOST unknown")).isEqualTo(404); + } +} diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java index 1f184db33..fddaf193c 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/real/RealApiServiceTest.java @@ -1135,6 +1135,27 @@ void adminDtddCaches_refetchAnEntry_andStartOrStopTheFullRefetch() { assertThat(service.adminDtddCacheEntry("topics", "7").dtddId()).isEqualTo(7L); } + @Test + void adminRateLimits_areListedCorrectedAndReset() { + respond(HttpMethod.GET, "/api/internal/admin/rate-limits", + "[{\"id\":\"HOST store.steampowered.com\",\"host\":\"store.steampowered.com\",\"scopePinned\":false," + + "\"limitCalls\":20,\"limitWindowSeconds\":60,\"limitCorrected\":false,\"resumeMs\":60000," + + "\"resumeCorrected\":false,\"rateLimitedCount\":1,\"usedCalls\":3}]"); + expect(HttpMethod.PUT, "/api/internal/admin/rate-limits/entry?id=HOST%20store.steampowered.com") + .andRespond(withSuccess("{\"id\":\"HOST store.steampowered.com\",\"host\":\"store.steampowered.com\"}", + MediaType.APPLICATION_JSON)); + expect(HttpMethod.DELETE, "/api/internal/admin/rate-limits/entry?id=HOST%20store.steampowered.com") + .andRespond(withStatus(HttpStatus.NOT_FOUND)); + + var limits = service.adminRateLimits(); + assertThat(limits).hasSize(1); + assertThat(limits.getFirst().limitCalls()).isEqualTo(20); + assertThat(limits.getFirst().hostScope()).isTrue(); + assertThat(service.adminCorrectRateLimit("HOST store.steampowered.com", + new fr.enimaloc.catapult.common.dto.admin.LearnedRateLimitCorrectionDto(20, 60, null, null))).isEqualTo(200); + assertThat(service.adminResetRateLimit("HOST store.steampowered.com")).isEqualTo(404); + } + @Test void adminDeleteCacheEntry_relaysTheStatus() { expect(HttpMethod.DELETE, "/api/internal/admin/caches/igdb-game-cache?key=1001").andRespond(withNoContent()); diff --git a/catapult-web/src/test/js/admin-rate-limits.test.js b/catapult-web/src/test/js/admin-rate-limits.test.js new file mode 100644 index 000000000..9519abc1d --- /dev/null +++ b/catapult-web/src/test/js/admin-rate-limits.test.js @@ -0,0 +1,127 @@ +/* + * The admin rate limits tab: a learned limit corrected in the dialog filled from its row (an empty field left to the + * learning, no scope for a host), or reset once confirmed; the list patched after. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { html, load, render } from "./helpers.js"; + +let tabs; +let sent; +let confirmAnswer; + +function list() { + html(` + +
    +
    + + +
    +
    + +
    + +

    + + + + + + +
    +
    +
    `); + render(); +} + +const panel = () => document.querySelector('mdui-tab-panel[value="rate-limits"]'); +const dialog = () => document.getElementById("admin-rl-dialog"); +const field = name => dialog().querySelector(`[name="${name}"]`); +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +async function click(selector) { + document.querySelector(selector).click(); + await tick(); +} + +beforeEach(async () => { + confirmAnswer = true; + sent = "ok"; + tabs = { + post: vi.fn(async () => sent), + confirmed: vi.fn(async () => confirmAnswer), + patch: vi.fn(async () => {}) + }; + await load("channel-page", "admin-rate-limits"); + window.CatapultAdminTabs = tabs; + window.mdui = { ...(window.mdui ?? {}), snackbar: vi.fn() }; +}); + +describe("admin-rate-limits", () => { + it("corrects a host's limit from its row, without a scope", async () => { + list(); + await click("#edit-host"); + + expect(dialog().open).toBe(true); + expect(dialog().querySelector("code").textContent).toBe("HOST store.steampowered.com"); + expect(field("calls").value).toBe("20"); + expect(field("scope").hidden).toBe(true); + + field("calls").value = "25"; + field("window").value = "300"; + field("resume").value = "45"; + await click(".admin-rl-save"); + + const [, , url, body] = tabs.post.mock.calls[0]; + expect(url).toBe("/admin/rate-limits/correct?id=HOST+store.steampowered.com"); + expect(body).toEqual({ limitCalls: 25, limitWindowSeconds: 300, resumeSeconds: 45, scope: null }); + expect(dialog().open).toBe(false); + expect(window.mdui.snackbar).toHaveBeenCalledWith({ message: "Saved." }); + expect(tabs.patch).toHaveBeenCalledWith(panel()); + }); + + it("leaves an empty limit to the learning, and keeps a route's chosen scope", async () => { + list(); + await click("#edit-route"); + + expect(field("window").value).toBe("60"); + expect(field("scope").hidden).toBe(false); + expect(field("scope").value).toBe("ROUTE"); + await click(".admin-rl-save"); + + expect(tabs.post.mock.calls[0][3]).toEqual({ limitCalls: null, limitWindowSeconds: null, resumeSeconds: 30, scope: "ROUTE" }); + }); + + it("keeps the dialog open when the correction is refused, and closes it on demand", async () => { + list(); + await click("#edit-host"); + sent = 400; + await click(".admin-rl-save"); + expect(dialog().open).toBe(true); + expect(tabs.patch).not.toHaveBeenCalled(); + + await click(".admin-dialog-close"); + expect(dialog().open).toBe(false); + }); + + it("resets a limit once confirmed, then patches the list", async () => { + list(); + confirmAnswer = false; + await click("#reset-host"); + expect(tabs.post).not.toHaveBeenCalled(); + + confirmAnswer = true; + await click("#reset-host"); + expect(tabs.confirmed).toHaveBeenCalledWith(expect.anything(), "Reset?", "Forgets"); + expect(tabs.post.mock.calls[0][2]).toBe("/admin/rate-limits/reset?id=HOST+store.steampowered.com"); + expect(tabs.patch).toHaveBeenCalledTimes(1); + + sent = 404; + await click("#reset-host"); + expect(tabs.patch).toHaveBeenCalledTimes(2); + sent = 502; + await click("#reset-host"); + expect(tabs.patch).toHaveBeenCalledTimes(2); + }); +}); From 4a7cd4258c08368212da9eb458cfd809584d7067 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 08:04:54 +0100 Subject: [PATCH 50/53] fix(ratelimit): fall back to the system clock in profiles without a Clock bean --- .../fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java | 6 ++++-- .../enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java | 4 ++-- .../catapult/service/steam/SteamStoreServiceTest.java | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java index 0484c7275..e3cc2f660 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java @@ -53,10 +53,12 @@ interface Sleeper { @Setter(AccessLevel.PACKAGE) private Sleeper sleeper = Thread::sleep; - public LearnedRateLimiter(LearnedRateLimitProperties props, LearnedRateLimitRepository repository, Clock clock) { + /** {@code clock}: the application's when there is one (not every profile defines it), else the system's. */ + public LearnedRateLimiter(LearnedRateLimitProperties props, LearnedRateLimitRepository repository, + Optional clock) { this.props = props; this.repository = repository; - this.clock = clock; + this.clock = clock.orElseGet(Clock::systemUTC); } /** Whether calls to {@code host} are learned from (not one of the hosts left to their documented limits). */ diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java index 73e50f48c..700460355 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java @@ -53,7 +53,7 @@ void setUp() { stored.put(e.getId(), e); return e; }); - limiter = new LearnedRateLimiter(props, repository, clock); + limiter = new LearnedRateLimiter(props, repository, Optional.of(clock)); limiter.setSleeper(ms -> { slept.addAndGet((int) ms); clock.advance(Duration.ofMillis(ms)); @@ -277,7 +277,7 @@ void whatWasStored_isTakenBack_andSyncedFromTheOtherInstances() { known(); calls(APPDETAILS, 10); tooMany(APPDETAILS, null); - LearnedRateLimiter other = new LearnedRateLimiter(props, repository, clock); + LearnedRateLimiter other = new LearnedRateLimiter(props, repository, Optional.of(clock)); assertThat(other.blockedUntil(STORE, "/api/appdetails")).isEmpty(); assertThat(other.list()).extracting(LearnedRateLimitDto::id) diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java index e94dd6d33..41bda86ed 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java @@ -67,7 +67,7 @@ class SteamStoreServiceTest { void setUp() { LearnedRateLimitRepository limits = org.mockito.Mockito.mock(LearnedRateLimitRepository.class); doReturn(Optional.empty()).when(limits).findById(any()); - rateLimiter = new LearnedRateLimiter(new LearnedRateLimitProperties(), limits, clock); + rateLimiter = new LearnedRateLimiter(new LearnedRateLimitProperties(), limits, Optional.of(clock)); RestClient.Builder builder = RestClient.builder().requestInterceptor(new LearnedRateLimitInterceptor(rateLimiter)); steam = MockRestServiceServer.bindTo(builder).ignoreExpectOrder(true).build(); ExternalApiObservations observations = From 3add1c0beeb5e70d06452c364ce4c46397fc289d Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 08:13:06 +0100 Subject: [PATCH 51/53] fix(ratelimit): keep admin changes across instances and learn no tiny first limit Each write runs in its own transaction, apart from the caller's. --- .../domain/ratelimit/LearnedRateLimit.java | 4 ++ .../ratelimit/LearnedRateLimiter.java | 54 +++++++++++++++---- .../catapult/ratelimit/LimitEstimator.java | 6 ++- .../catapult/ratelimit/RateLimitScope.java | 25 +++++++-- .../enimaloc/catapult/ratelimit/RouteKey.java | 3 ++ .../migration/V107__learned_rate_limits.sql | 4 +- .../ratelimit/LearnedRateLimiterTest.java | 39 +++++++++++--- .../catapult/ratelimit/LearningTest.java | 8 ++- 8 files changed, 118 insertions(+), 25 deletions(-) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/domain/ratelimit/LearnedRateLimit.java b/catapult-api/src/main/java/fr/enimaloc/catapult/domain/ratelimit/LearnedRateLimit.java index 2b0ea0505..3b97c4542 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/domain/ratelimit/LearnedRateLimit.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/domain/ratelimit/LearnedRateLimit.java @@ -70,6 +70,10 @@ public class LearnedRateLimit { @Column(name = "override_resume_ms") private Long overrideResumeMs; + /** The last admin correction or reset: an instance that hasn't taken it yet doesn't write over it. */ + @Column(name = "admin_changed_at") + private Instant adminChangedAt; + @Column(name = "updated_at", nullable = false) private Instant updatedAt; } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java index e3cc2f660..28db6b966 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiter.java @@ -13,6 +13,9 @@ import org.springframework.http.HttpStatusCode; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.server.ResponseStatusException; @@ -40,6 +43,8 @@ public class LearnedRateLimiter { public static final String LOCAL_REFUSAL_HEADER = "X-Catapult-Local-Limit"; private static final long SLICE_MS = 1_000L; static final String OTHER_PATH = "/{other}"; + /** The scope an admin gives a route back to the learning with. */ + static final String LEARNED_SCOPE = "LEARNED"; interface Sleeper { void sleep(long millis) throws InterruptedException; @@ -48,17 +53,24 @@ interface Sleeper { private final LearnedRateLimitProperties props; private final LearnedRateLimitRepository repository; private final Clock clock; + /** Its own transaction for each write: never part of (nor failing) the caller's. */ + private final TransactionTemplate writes; private final Map scopes = new ConcurrentHashMap<>(); @Setter(AccessLevel.PACKAGE) private Sleeper sleeper = Thread::sleep; - /** {@code clock}: the application's when there is one (not every profile defines it), else the system's. */ + /** + * {@code clock}: the application's when there is one (not every profile defines it), else the system's; + * {@code transactions}: each write's own transaction (without one, the repository's). + */ public LearnedRateLimiter(LearnedRateLimitProperties props, LearnedRateLimitRepository repository, - Optional clock) { + Optional clock, Optional transactions) { this.props = props; this.repository = repository; this.clock = clock.orElseGet(Clock::systemUTC); + this.writes = transactions.map(TransactionTemplate::new).orElse(null); + if (writes != null) writes.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); } /** Whether calls to {@code host} are learned from (not one of the hosts left to their documented limits). */ @@ -174,7 +186,8 @@ public List list() { /** * An admin's correction of a scope (404 if unknown): the limit ({@code calls} per {@code windowSeconds}, both or - * neither), the resume delay, a route's scope; null leaves that part to the learning. + * neither), the resume delay, a route's scope (HOST, ROUTE, or LEARNED: given back to the learning); null leaves + * that part to the learning. */ public LearnedRateLimitDto correct(String id, Integer calls, Integer windowSeconds, Integer resumeSeconds, String scope) { @@ -183,23 +196,25 @@ public LearnedRateLimitDto correct(String id, Integer calls, Integer windowSecon || (resumeSeconds != null && resumeSeconds < 1)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid correction"); } + String named = scope == null ? "" : scope.trim().toUpperCase(Locale.ROOT); + boolean unpin = named.equals(LEARNED_SCOPE); Kind kind; try { - kind = scope == null || scope.isBlank() ? null : Kind.valueOf(scope.trim().toUpperCase(Locale.ROOT)); + kind = named.isEmpty() || unpin ? null : Kind.valueOf(named); } catch (IllegalArgumentException e) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown scope " + scope); } s.correct(calls == null ? null : new Estimate(calls, Duration.ofSeconds(windowSeconds)), - resumeSeconds == null ? null : Duration.ofSeconds(resumeSeconds), kind); - save(s); + resumeSeconds == null ? null : Duration.ofSeconds(resumeSeconds), kind, unpin, clock.instant()); + store(s); return s.toDto(clock.instant()); } /** Forgets what was learned and corrected of a scope (404 if unknown). */ public LearnedRateLimitDto reset(String id) { RateLimitScope s = known(id); - s.reset(); - save(s); + s.reset(clock.instant()); + store(s); return s.toDto(clock.instant()); } @@ -267,9 +282,30 @@ private RateLimitScope fromEntity(LearnedRateLimit e) { return s; } + /** + * Stores what the learning changed, unless an admin corrected or reset the scope meanwhile (on another instance): + * then the scope takes that instead, rather than writing over it. + */ private void save(RateLimitScope scope) { - try { + write(scope, () -> { + Optional stored = repository.findById(scope.id()); + if (stored.isPresent() && scope.behind(stored.get())) { + scope.apply(stored.get()); + return; + } repository.save(scope.toEntity(clock.instant())); + }); + } + + /** Stores an admin's correction or reset as it is. */ + private void store(RateLimitScope scope) { + write(scope, () -> repository.save(scope.toEntity(clock.instant()))); + } + + private void write(RateLimitScope scope, Runnable body) { + try { + if (writes == null) body.run(); + else writes.executeWithoutResult(status -> body.run()); } catch (RuntimeException ex) { log.warn("Learned rate limits: {} could not be stored: {}", scope.id(), ex.getMessage()); } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LimitEstimator.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LimitEstimator.java index 06033b8b7..017b17dd4 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LimitEstimator.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/LimitEstimator.java @@ -20,6 +20,8 @@ final class LimitEstimator { Duration.ofMinutes(5), Duration.ofHours(1)); private static final Duration KEPT = WINDOWS.getLast(); private static final int MAX_KEPT = 10_000; + /** The fewest calls a first limit is learned from: a 429 after fewer tells too little (other instances' calls). */ + static final int MIN_FIRST_LIMIT = 5; /** A limit: at most {@code calls} in any {@code window}. */ record Estimate(int calls, Duration window) {} @@ -67,7 +69,7 @@ Optional estimate() { * ({@code resumeHint}), the limit the calls sent in it before the refused one. Never loosened by a 429, and at * most halved by one in the same window: a 429 after few calls tells more of a longer window than of a tiny limit. * Nothing is learned when the window wasn't counted whole (just started, or a wait ended within it): the calls - * the platform counted aren't all known. + * the platform counted aren't all known; nor a first limit from fewer than {@link #MIN_FIRST_LIMIT} calls. */ void on429(Instant now, Duration resumeHint) { last429 = now; @@ -77,7 +79,7 @@ void on429(Instant now, Duration resumeHint) { if (estimate != null && estimate.window().equals(window)) { int halved = (estimate.calls() + 1) / 2; estimate = new Estimate(Math.min(estimate.calls(), Math.max(limit, halved)), window); - } else { + } else if (limit >= MIN_FIRST_LIMIT) { estimate = new Estimate(limit, window); } } diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitScope.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitScope.java index b7645b2c8..27c1bb8cc 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitScope.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RateLimitScope.java @@ -36,6 +36,8 @@ enum Kind { HOST, ROUTE } private boolean learningResume; private int rateLimitedCount; private Instant lastRateLimitedAt; + /** The last admin correction or reset taken: a newer one stored is taken before writing over it. */ + private Instant adminSeenAt; /** A host's: when each of its routes was last answered (no 429), by route ID. */ private final Map lastAnswerByRoute = new ConcurrentHashMap<>(); @@ -163,18 +165,31 @@ synchronized boolean splitOff() { return true; } - /** An admin's correction: null values leave that part to the learning. */ - synchronized void correct(Estimate limit, Duration resumeDelay, Kind pinnedKind) { + /** + * An admin's correction: null values leave that part to the learning; a route's scope chosen ({@code pinnedKind}), + * or given back to the learning ({@code unpin}), else left as it is. + */ + synchronized void correct(Estimate limit, Duration resumeDelay, Kind pinnedKind, boolean unpin, Instant now) { overrideLimit = limit; overrideResume = resumeDelay; - if (pinnedKind != null && route != null) { + if (route != null && pinnedKind != null) { kind = pinnedKind; kindPinned = true; + } else if (route != null && unpin) { + kindPinned = false; } + adminSeenAt = now; + } + + /** Whether {@code stored} carries an admin's correction or reset this scope hasn't taken yet. */ + synchronized boolean behind(LearnedRateLimit stored) { + Instant changed = stored.getAdminChangedAt(); + return changed != null && (adminSeenAt == null || changed.isAfter(adminSeenAt)); } /** Forgets everything learned and corrected: the scope starts again without a known limit. */ - synchronized void reset() { + synchronized void reset(Instant now) { + adminSeenAt = now; estimator.reset(); resume.reset(); overrideLimit = null; @@ -210,6 +225,7 @@ synchronized LearnedRateLimit toEntity(Instant now) { .overrideCalls(overrideLimit == null ? null : overrideLimit.calls()) .overrideWindowSeconds(overrideLimit == null ? null : (int) overrideLimit.window().toSeconds()) .overrideResumeMs(overrideResume == null ? null : overrideResume.toMillis()) + .adminChangedAt(adminSeenAt) .updatedAt(now) .build(); } @@ -228,6 +244,7 @@ synchronized void apply(LearnedRateLimit e) { lastRateLimitedAt = e.getLastRateLimitedAt(); if (route != null && e.getScope() != null) kind = Kind.valueOf(e.getScope()); kindPinned = e.isScopePinned(); + adminSeenAt = e.getAdminChangedAt(); } synchronized LearnedRateLimitDto toDto(Instant now) { diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RouteKey.java b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RouteKey.java index 326a57d7f..a408269f8 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RouteKey.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/ratelimit/RouteKey.java @@ -16,6 +16,8 @@ public record RouteKey(String host, String method, String path) { /** The request attribute {@code RestClient} keeps the URI template in. */ static final String URI_TEMPLATE_ATTRIBUTE = "org.springframework.web.client.DefaultRestClient.uriTemplate"; + /** The longest path kept (the rest cut): what is stored stays bounded. */ + static final int MAX_PATH = 300; private static final Pattern ID_SEGMENT = Pattern.compile("\\d+|[A-Za-z0-9_-]{16,}|.*\\(.*\\).*"); public static RouteKey of(HttpRequest request) { @@ -53,6 +55,7 @@ static String normalizedPath(String rawPath) { out.append('/').append(ID_SEGMENT.matcher(segment).matches() ? "{id}" : segment); } if (rawPath.endsWith("/") && !out.isEmpty()) out.append('/'); + if (out.length() > MAX_PATH) return out.substring(0, MAX_PATH) + "…"; return out.isEmpty() ? "/" : out.toString(); } } diff --git a/catapult-api/src/main/resources/db/migration/V107__learned_rate_limits.sql b/catapult-api/src/main/resources/db/migration/V107__learned_rate_limits.sql index 0360ff5aa..46bf5b688 100644 --- a/catapult-api/src/main/resources/db/migration/V107__learned_rate_limits.sql +++ b/catapult-api/src/main/resources/db/migration/V107__learned_rate_limits.sql @@ -1,6 +1,7 @@ -- What Catapult learned of the rate limits platforms don't document, from their 429s: one row per host (its shared -- budget, method and path NULL) and one per route seen (scope HOST: counted in its host's budget; ROUTE: its own). --- override_*: an admin's correction, kept until reset; the learning leaves it alone. +-- override_*: an admin's correction, kept until reset; the learning leaves it alone. admin_changed_at: the last +-- correction or reset, which an instance that hasn't taken it yet must not write over. CREATE TABLE learned_rate_limits ( id VARCHAR(800) PRIMARY KEY, host VARCHAR(255) NOT NULL, @@ -19,5 +20,6 @@ CREATE TABLE learned_rate_limits ( override_calls INTEGER, override_window_seconds INTEGER, override_resume_ms BIGINT, + admin_changed_at TIMESTAMPTZ, updated_at TIMESTAMPTZ NOT NULL ); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java index 700460355..2c9ac5e25 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearnedRateLimiterTest.java @@ -53,7 +53,7 @@ void setUp() { stored.put(e.getId(), e); return e; }); - limiter = new LearnedRateLimiter(props, repository, Optional.of(clock)); + limiter = new LearnedRateLimiter(props, repository, Optional.of(clock), Optional.empty()); limiter.setSleeper(ms -> { slept.addAndGet((int) ms); clock.advance(Duration.ofMillis(ms)); @@ -255,10 +255,35 @@ void anAdminsScope_isNeverChangedByTheLearning() { tooMany(APPDETAILS, null); assertThat(row(APPDETAILS.id()).scope()).isEqualTo("HOST"); + limiter.correct(APPDETAILS.id(), null, null, null, "learned"); + assertThat(row(APPDETAILS.id()).scopePinned()).isFalse(); + assertThat(row(APPDETAILS.id()).scope()).isEqualTo("HOST"); + + limiter.correct(APPDETAILS.id(), null, null, null, "ROUTE"); limiter.reset(APPDETAILS.id()); assertThat(row(APPDETAILS.id()).scopePinned()).isFalse(); } + @Test + void anAdminsCorrectionOrReset_onAnotherInstance_isNeverWrittenOver() { + known(); + calls(APPDETAILS, 10); + LearnedRateLimiter other = new LearnedRateLimiter(props, repository, Optional.of(clock), Optional.empty()); + other.correct("HOST " + STORE, 50, 60, null, null); + + // This instance learns before syncing: it takes the correction rather than writing over it + tooMany(APPDETAILS, null); + assertThat(stored.get("HOST " + STORE).getOverrideCalls()).isEqualTo(50); + assertThat(row("HOST " + STORE).limitCalls()).isEqualTo(50); + + clock.advance(Duration.ofMinutes(5)); + other.reset("HOST " + STORE); + calls(APPDETAILS, 1); + clock.advance(Duration.ofHours(2)); + tooMany(APPDETAILS, null); + assertThat(stored.get("HOST " + STORE).getOverrideCalls()).isNull(); + } + @Test void aBadCorrection_isRefused() { limiter.acquire(APPDETAILS); @@ -277,7 +302,8 @@ void whatWasStored_isTakenBack_andSyncedFromTheOtherInstances() { known(); calls(APPDETAILS, 10); tooMany(APPDETAILS, null); - LearnedRateLimiter other = new LearnedRateLimiter(props, repository, Optional.of(clock)); + LearnedRateLimiter other = new LearnedRateLimiter(props, repository, Optional.of(clock), + Optional.of(mock(org.springframework.transaction.PlatformTransactionManager.class))); assertThat(other.blockedUntil(STORE, "/api/appdetails")).isEmpty(); assertThat(other.list()).extracting(LearnedRateLimitDto::id) @@ -308,13 +334,12 @@ void aFailingStore_neverFailsTheCalls() { @Test void longNearTheLimit_raisesIt() { known(); - limiter.acquire(APPDETAILS); + calls(APPDETAILS, 6); tooMany(APPDETAILS, null); - assertThat(row("HOST " + STORE).limitCalls()).isEqualTo(1); + assertThat(row("HOST " + STORE).limitCalls()).isEqualTo(6); clock.advance(Duration.ofHours(2)); - calls(APPDETAILS, 1); - calls(APPDETAILS, 1); - assertThat(row("HOST " + STORE).limitCalls()).isEqualTo(2); + calls(APPDETAILS, 6); + assertThat(row("HOST " + STORE).limitCalls()).isEqualTo(7); verify(repository, atLeast(3)).save(any()); } diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java index 78bd44410..f61ea6514 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/ratelimit/LearningTest.java @@ -64,11 +64,15 @@ void aWindowNotCountedWhole_teachesNoLimit() { } @Test - void theLimit_isNeverUnderOneCall() { + void aFirstLimit_needsEnoughCalls() { LimitEstimator e = new LimitEstimator(Duration.ofHours(1), T0.minus(Duration.ofHours(1))); e.on429(T0, Duration.ofSeconds(1)); - assertThat(e.estimate()).contains(new Estimate(1, Duration.ofSeconds(10))); + assertThat(e.estimate()).isEmpty(); assertThat(e.oldestWithin(Duration.ofSeconds(10), T0)).isEmpty(); + + for (int i = 0; i < 6; i++) e.record(T0.plusSeconds(1)); + e.on429(T0.plusSeconds(2), Duration.ofSeconds(1)); + assertThat(e.estimate()).contains(new Estimate(5, Duration.ofSeconds(10))); } @Test From 1f6de35533c8f784613c11c21ef719c5e8c6eae6 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 08:13:08 +0100 Subject: [PATCH 52/53] fix(steam): let only live appdetails refusals stop the store, back off when nothing learned --- .../service/steam/SteamStoreServiceImpl.java | 32 +++++++++++------ .../service/steam/SteamStoreServiceTest.java | 34 ++++++++++++++++++- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceImpl.java b/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceImpl.java index dce9e9685..1d5e582ed 100644 --- a/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceImpl.java +++ b/catapult-api/src/main/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceImpl.java @@ -3,6 +3,7 @@ import fr.enimaloc.catapult.domain.steam.SteamAppParentEntry; import fr.enimaloc.catapult.getter.steam.SteamPlaytestRedirectResolver; import fr.enimaloc.catapult.ratelimit.LearnedRateLimiter; +import fr.enimaloc.catapult.ratelimit.RateLimitPriority; import fr.enimaloc.catapult.repository.steam.SteamAppParentRepository; import fr.enimaloc.catapult.service.metrics.ExternalApiObservations; import lombok.AccessLevel; @@ -48,6 +49,8 @@ public class SteamStoreServiceImpl implements SteamStoreService { private Clock clock = Clock.systemUTC(); /** Until when the learned rate limit refused the store's calls without sending them (live calls don't wait). */ private volatile Instant refusedUntil; + /** How long a 429 nothing learned from (the learning off) leaves the store alone without a Retry-After. */ + private static final java.time.Duration UNBLOCKED_429_WAIT = java.time.Duration.ofMinutes(1); /** * Until when the store's {@code appdetails} isn't called: its learned rate limit blocks it after a 429, or @@ -62,16 +65,25 @@ public Optional rateLimitedUntil() { return blocked.filter(b -> b.isAfter(refused)).or(() -> Optional.of(refused)); } - /** A 429: the learned rate limit learned it already; one it made itself (no call sent) tells until when. */ - private void onRateLimited(HttpClientErrorException.TooManyRequests e) { + /** + * A 429 on {@code appdetails} ({@code appDetails}; another route's never stops it): a live call the learned rate + * limit refused (no call sent) tells until when, bulk work it gave up on (cancelled) doesn't. A real one is + * already learned; while nothing blocks the route (the learning off), it is left alone for its Retry-After, else + * a minute, so the TWs resolved meanwhile know Steam's signals were missed. + */ + private void onRateLimited(HttpClientErrorException.TooManyRequests e, boolean appDetails) { HttpHeaders headers = e.getResponseHeaders(); - if (headers == null || !headers.containsHeader(LearnedRateLimiter.LOCAL_REFUSAL_HEADER)) { - log.warn("Steam store rate limited (429): left alone until {}", rateLimitedUntil().orElse(null)); - return; - } + boolean local = headers != null && headers.containsHeader(LearnedRateLimiter.LOCAL_REFUSAL_HEADER); + if (!local) log.warn("Steam store rate limited (429): left alone until {}", rateLimitedUntil().orElse(null)); + if (!appDetails) return; + if (local && RateLimitPriority.current() != RateLimitPriority.LIVE) return; + if (!local && rateLimiter.blockedUntil(STORE_HOST, APP_DETAILS_PATH).isPresent()) return; Instant now = clock.instant(); - LearnedRateLimiter.retryAfter(headers.getFirst(HttpHeaders.RETRY_AFTER), now) - .ifPresent(wait -> refusedUntil = now.plus(wait)); + java.time.Duration wait = LearnedRateLimiter.retryAfter(headers == null ? null + : headers.getFirst(HttpHeaders.RETRY_AFTER), now).orElse(UNBLOCKED_429_WAIT); + Instant until = now.plus(wait); + Instant refused = refusedUntil; + if (refused == null || until.isAfter(refused)) refusedUntil = until; } /** One app's entry in an {@code appdetails} response, keyed by appId. */ @@ -259,7 +271,7 @@ public Optional fetchData(String appId, Locale locale, boolean r if (entry == null || !entry.success()) return Optional.empty(); return Optional.ofNullable(entry.data()); } catch (HttpClientErrorException.TooManyRequests e) { - onRateLimited(e); + onRateLimited(e, true); return Optional.empty(); } catch (Exception e) { log.warn("Steam fetchData failed for appId={}: {}", appId, e.getMessage(), e); @@ -284,7 +296,7 @@ public Optional fetchIADisclosure(String appId, Locale locale) { if (response == null || response.isBlank()) return Optional.empty(); return Optional.of(response); } catch (HttpClientErrorException.TooManyRequests e) { - onRateLimited(e); + onRateLimited(e, false); return Optional.empty(); } catch (Exception e) { log.warn("Steam fetchIADisclosure failed for appId={}: {}", appId, e.getMessage(), e); diff --git a/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java b/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java index 41bda86ed..cc4e97cfb 100644 --- a/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java +++ b/catapult-api/src/test/java/fr/enimaloc/catapult/service/steam/SteamStoreServiceTest.java @@ -67,7 +67,7 @@ class SteamStoreServiceTest { void setUp() { LearnedRateLimitRepository limits = org.mockito.Mockito.mock(LearnedRateLimitRepository.class); doReturn(Optional.empty()).when(limits).findById(any()); - rateLimiter = new LearnedRateLimiter(new LearnedRateLimitProperties(), limits, Optional.of(clock)); + rateLimiter = new LearnedRateLimiter(new LearnedRateLimitProperties(), limits, Optional.of(clock), Optional.empty()); RestClient.Builder builder = RestClient.builder().requestInterceptor(new LearnedRateLimitInterceptor(rateLimiter)); steam = MockRestServiceServer.bindTo(builder).ignoreExpectOrder(true).build(); ExternalApiObservations observations = @@ -508,4 +508,36 @@ void aCallTheLearnedLimitRefuses_tellsUntilWhen() { clock.now = clock.now.plusSeconds(11); assertThat(service.rateLimitedUntil()).isEmpty(); } + + @Test + void cancelledBulkWork_neverStopsTheLiveCalls() { + assertThat(service.rateLimitedUntil()).isEmpty(); + rateLimiter.correct("HOST store.steampowered.com", 1, 10, null, null); + givenAppDetails("100", success("100", "{\"type\": \"game\", \"name\": \"Some Game\"}")); + assertThat(service.fetchData("100", Locale.ENGLISH, false)).isPresent(); + steam.reset(); + + assertThat(fr.enimaloc.catapult.ratelimit.RateLimitPriority.call(fr.enimaloc.catapult.ratelimit.RateLimitPriority.BULK, + () -> true, () -> service.fetchData("100", Locale.ENGLISH, false))).isEmpty(); + assertThat(service.rateLimitedUntil()).isEmpty(); + } + + @Test + void withTheLearningOff_a429_stillLeavesTheStoreAlone() { + rateLimiter = null; + LearnedRateLimitRepository limits = org.mockito.Mockito.mock(LearnedRateLimitRepository.class); + LearnedRateLimitProperties off = new LearnedRateLimitProperties(); + off.setEnabled(false); + LearnedRateLimiter disabled = new LearnedRateLimiter(off, limits, Optional.of(clock), Optional.empty()); + RestClient.Builder builder = RestClient.builder().requestInterceptor(new LearnedRateLimitInterceptor(disabled)); + steam = MockRestServiceServer.bindTo(builder).ignoreExpectOrder(true).build(); + service = new SteamStoreServiceImpl(builder.build(), + new ExternalApiObservations(ObservationRegistry.create(), new SimpleMeterRegistry()), + steamAppParentRepository, redirectResolver, disabled); + service.setClock(clock); + given429("100", null); + + assertThat(service.fetchTwSignals(List.of("100"))).isEmpty(); + assertThat(service.rateLimitedUntil()).contains(clock.now.plusSeconds(60)); + } } From e83f87bc9689d2a7314abc032550cb69f37bd812 Mon Sep 17 00:00:00 2001 From: enimaloc Date: Thu, 12 Nov 2026 08:13:08 +0100 Subject: [PATCH 53/53] fix(admin): give a route's rate limit scope back to the learning --- .../catapult/service/mock/MockRateLimitsStore.java | 8 +++++--- .../src/main/resources/static/js/admin-rate-limits.js | 4 ++-- .../resources/templates/pages/admin-tab/rate-limits.html | 2 +- .../catapult/service/mock/MockRateLimitsStoreTest.java | 3 +++ catapult-web/src/test/js/admin-rate-limits.test.js | 9 +++++++++ 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStore.java b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStore.java index 9fa6b8fbd..a61043e0b 100644 --- a/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStore.java +++ b/catapult-web/src/main/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStore.java @@ -45,13 +45,15 @@ int correct(String id, LearnedRateLimitCorrectionDto c) { return 400; } LearnedRateLimitDto d = limits.get(i); - String scope = c.scope() == null || c.scope().isBlank() || d.hostScope() ? d.scope() - : c.scope().trim().toUpperCase(Locale.ROOT); + String named = c.scope() == null ? "" : c.scope().trim().toUpperCase(Locale.ROOT); + boolean unpin = named.equals("LEARNED"); + String scope = named.isEmpty() || unpin || d.hostScope() ? d.scope() : named; if (scope != null && !scope.equals("HOST") && !scope.equals("ROUTE")) return 400; + boolean pinned = !d.hostScope() && !unpin && (d.scopePinned() || !named.isEmpty()); boolean limitSet = c.limitCalls() != null; boolean resumeSet = c.resumeSeconds() != null; limits.set(i, new LearnedRateLimitDto(d.id(), d.host(), d.method(), d.path(), scope, - d.scopePinned() || !d.hostScope() && c.scope() != null && !c.scope().isBlank(), + pinned, limitSet ? c.limitCalls() : d.limitCalls(), limitSet ? c.limitWindowSeconds() : d.limitWindowSeconds(), limitSet, resumeSet ? c.resumeSeconds() * 1000L : d.resumeMs(), resumeSet, d.rateLimitedCount(), d.lastRateLimitedAt(), d.usedCalls(), d.blockedUntil())); diff --git a/catapult-web/src/main/resources/static/js/admin-rate-limits.js b/catapult-web/src/main/resources/static/js/admin-rate-limits.js index c16812138..61ab2e354 100644 --- a/catapult-web/src/main/resources/static/js/admin-rate-limits.js +++ b/catapult-web/src/main/resources/static/js/admin-rate-limits.js @@ -27,11 +27,11 @@ window.CatapultAdminRateLimits = (function () { dialog.querySelector('[name="resume"]').value = row.dataset.resume ?? ""; const scope = dialog.querySelector('[name="scope"]'); scope.hidden = row.dataset.host === "true"; - scope.value = row.dataset.scopePinned === "true" ? row.dataset.scope : ""; + scope.value = row.dataset.scopePinned === "true" ? row.dataset.scope : "LEARNED"; dialog.open = true; } - /** The correction the dialog holds: a limit only with its calls, a scope only for a route. */ + /** The correction the dialog holds: a limit only with its calls, a scope only for a route (LEARNED: unpinned). */ function correctionOf(dialog) { const calls = numberOf(dialog.querySelector('[name="calls"]')); const scope = dialog.querySelector('[name="scope"]'); diff --git a/catapult-web/src/main/resources/templates/pages/admin-tab/rate-limits.html b/catapult-web/src/main/resources/templates/pages/admin-tab/rate-limits.html index 31de46de4..b295a24ca 100644 --- a/catapult-web/src/main/resources/templates/pages/admin-tab/rate-limits.html +++ b/catapult-web/src/main/resources/templates/pages/admin-tab/rate-limits.html @@ -82,7 +82,7 @@

    Rate limits

    - learned + learned host route diff --git a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStoreTest.java b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStoreTest.java index 6d0a3e5ac..9210d85ed 100644 --- a/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStoreTest.java +++ b/catapult-web/src/test/java/fr/enimaloc/catapult/service/mock/MockRateLimitsStoreTest.java @@ -36,6 +36,9 @@ void aCorrection_isKept_andAResetForgetsIt() { assertThat(row(PAGE).scopePinned()).isTrue(); assertThat(row(PAGE).limitCorrected()).isFalse(); + assertThat(store.correct(PAGE, new LearnedRateLimitCorrectionDto(null, null, null, "LEARNED"))).isEqualTo(200); + assertThat(row(PAGE).scopePinned()).isFalse(); + assertThat(store.reset(PAGE)).isEqualTo(200); assertThat(row(PAGE).scopePinned()).isFalse(); assertThat(row(PAGE).limitCalls()).isNull(); diff --git a/catapult-web/src/test/js/admin-rate-limits.test.js b/catapult-web/src/test/js/admin-rate-limits.test.js index 9519abc1d..868df9adf 100644 --- a/catapult-web/src/test/js/admin-rate-limits.test.js +++ b/catapult-web/src/test/js/admin-rate-limits.test.js @@ -93,6 +93,15 @@ describe("admin-rate-limits", () => { expect(tabs.post.mock.calls[0][3]).toEqual({ limitCalls: null, limitWindowSeconds: null, resumeSeconds: 30, scope: "ROUTE" }); }); + it("gives a route's scope back to the learning when none was chosen", async () => { + list(); + document.querySelector('[data-id^="GET"]').dataset.scopePinned = "false"; + await click("#edit-route"); + expect(field("scope").value).toBe("LEARNED"); + await click(".admin-rl-save"); + expect(tabs.post.mock.calls[0][3].scope).toBe("LEARNED"); + }); + it("keeps the dialog open when the correction is refused, and closes it on demand", async () => { list(); await click("#edit-host");