Skip to content

Commit 875b8da

Browse files
authored
fix: rss failures with exponential blacklist (#1362)
* fix: rss failures with exponential blacklist failed rss feed urls are marked as blacklisted upto 24 hours, if it's a dead url it notifies with error log for admin to remove it. * refactor: helper and magic numbers moves numbers used for backing off hours to constants and adds a helper to calculate backing off hours duration * chore: log level for skips to debug * refactor: move failure state record to seperate file
1 parent 72cc22f commit 875b8da

File tree

2 files changed

+61
-6
lines changed

2 files changed

+61
-6
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package org.togetherjava.tjbot.features.rss;
2+
3+
import java.time.ZonedDateTime;
4+
5+
record FailureState(int count, ZonedDateTime lastFailure) {
6+
}

application/src/main/java/org/togetherjava/tjbot/features/rss/RSSHandlerRoutine.java

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import com.apptasticsoftware.rssreader.Item;
44
import com.apptasticsoftware.rssreader.RssReader;
5+
import com.github.benmanes.caffeine.cache.Cache;
6+
import com.github.benmanes.caffeine.cache.Caffeine;
57
import net.dv8tion.jda.api.EmbedBuilder;
68
import net.dv8tion.jda.api.JDA;
79
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
@@ -48,7 +50,7 @@
4850
* <p>
4951
* To include a new RSS feed, simply define an {@link RSSFeed} entry in the {@code "rssFeeds"} array
5052
* within the configuration file, adhering to the format shown below:
51-
*
53+
*
5254
* <pre>
5355
* {@code
5456
* {
@@ -58,7 +60,7 @@
5860
* }
5961
* }
6062
* </pre>
61-
*
63+
* <p>
6264
* Where:
6365
* <ul>
6466
* <li>{@code url} represents the URL of the RSS feed.</li>
@@ -84,6 +86,14 @@ public final class RSSHandlerRoutine implements Routine {
8486
private final int interval;
8587
private final Database database;
8688

89+
private final Cache<String, FailureState> circuitBreaker =
90+
Caffeine.newBuilder().expireAfterWrite(7, TimeUnit.DAYS).maximumSize(500).build();
91+
92+
private static final int DEAD_RSS_FEED_FAILURE_THRESHOLD = 15;
93+
private static final double BACKOFF_BASE = 2.0;
94+
private static final double BACKOFF_EXPONENT_OFFSET = 1.0;
95+
private static final double MAX_BACKOFF_HOURS = 24.0;
96+
8797
/**
8898
* Constructs an RSSHandlerRoutine with the provided configuration and database.
8999
*
@@ -117,7 +127,14 @@ public Schedule createSchedule() {
117127

118128
@Override
119129
public void runRoutine(@Nonnull JDA jda) {
120-
this.config.feeds().forEach(feed -> sendRSS(jda, feed));
130+
this.config.feeds().forEach(feed -> {
131+
if (isBackingOff(feed.url())) {
132+
logger.debug("Skipping RSS feed (Backing off): {}", feed.url());
133+
return;
134+
}
135+
136+
sendRSS(jda, feed);
137+
});
121138
}
122139

123140
/**
@@ -257,7 +274,6 @@ private void postItem(List<TextChannel> textChannels, Item rssItem, RSSFeed feed
257274
* @param rssFeedRecord the record representing the RSS feed, can be null if not found in the
258275
* database
259276
* @param lastPostedDate the last posted date to be updated
260-
*
261277
* @throws DateTimeParseException if the date cannot be parsed
262278
*/
263279
private void updateLastDateToDatabase(RSSFeed feedConfig, @Nullable RssFeedRecord rssFeedRecord,
@@ -400,9 +416,26 @@ private MessageCreateData constructMessage(Item item, RSSFeed feedConfig) {
400416
*/
401417
private List<Item> fetchRSSItemsFromURL(String rssUrl) {
402418
try {
403-
return rssReader.read(rssUrl).toList();
419+
List<Item> items = rssReader.read(rssUrl).toList();
420+
circuitBreaker.invalidate(rssUrl);
421+
return items;
404422
} catch (IOException e) {
405-
logger.error("Could not fetch RSS from URL ({})", rssUrl, e);
423+
FailureState oldState = circuitBreaker.getIfPresent(rssUrl);
424+
int newCount = (oldState == null) ? 1 : oldState.count() + 1;
425+
426+
if (newCount >= DEAD_RSS_FEED_FAILURE_THRESHOLD) {
427+
logger.error(
428+
"Possibly dead RSS feed URL: {} - Failed {} times. Please remove it from config.",
429+
rssUrl, newCount);
430+
}
431+
circuitBreaker.put(rssUrl, new FailureState(newCount, ZonedDateTime.now()));
432+
433+
long blacklistedHours = calculateWaitHours(newCount);
434+
435+
logger.warn(
436+
"RSS fetch failed for {} (Attempt #{}). Backing off for {} hours. Reason: {}",
437+
rssUrl, newCount, blacklistedHours, e.getMessage(), e);
438+
406439
return List.of();
407440
}
408441
}
@@ -424,4 +457,20 @@ private static ZonedDateTime getZonedDateTime(@Nullable String date, String form
424457

425458
return ZonedDateTime.parse(date, DateTimeFormatter.ofPattern(format));
426459
}
460+
461+
private long calculateWaitHours(int failureCount) {
462+
return (long) Math.min(Math.pow(BACKOFF_BASE, failureCount - BACKOFF_EXPONENT_OFFSET),
463+
MAX_BACKOFF_HOURS);
464+
}
465+
466+
private boolean isBackingOff(String url) {
467+
FailureState state = circuitBreaker.getIfPresent(url);
468+
if (state == null)
469+
return false;
470+
471+
long waitHours = calculateWaitHours(state.count());
472+
ZonedDateTime retryAt = state.lastFailure().plusHours(waitHours);
473+
474+
return ZonedDateTime.now().isBefore(retryAt);
475+
}
427476
}

0 commit comments

Comments
 (0)