Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
140 commits
Select commit Hold shift + click to select a range
9d344a8
test: add Filtering cog_load and test scaffold and mocked setup
rippyboii Feb 25, 2026
81681ed
docs: import report template (#7)
carlisaksson Feb 25, 2026
7a66cd4
test: cover cog_load behavior when filter list fetch fails
rippyboii Feb 25, 2026
26d30a8
feat: Add retry error filter and mod alert for filter load failures #6
rippyboii Feb 25, 2026
8c3f009
feat: add retry with exponential backoff for filter list loading #6
rippyboii Feb 25, 2026
9253760
test: add tests for invalid extensions and cogs (#3)
Feb 26, 2026
96e3018
fix: updated untracked unit test
rippyboii Feb 26, 2026
0bfb84e
feat: add centralized exception logging for extensions and cogs (#3)
a-runebou Feb 26, 2026
0995803
feat: add backoff retry loading (#15)
kahoujo1 Feb 26, 2026
2fb991a
fix: added filtering cog retry test and fixed cog_load startup tests #13
rippyboii Feb 26, 2026
2fe59dd
feat: alert mods through discord (#15)
kahoujo1 Feb 26, 2026
6216c17
test: Added filtering cog retry-path tests for success and final-fail…
rippyboii Feb 26, 2026
685788e
feat: add helper for checking if error is retryable (#16)
carlisaksson Feb 26, 2026
7727c30
fix: updated filter_load_max_attempts contant to reuse connect_max_re…
rippyboii Feb 26, 2026
aa9ec7a
fix: added 408: Request Timeout Error, to imply for retryable and it'…
rippyboii Feb 26, 2026
ea30b97
test: add test skeleton and valid load test (#15)
kahoujo1 Feb 26, 2026
b5c99bf
feat: Adds retry logic with time buff to `Filtering.cog_load()`
rippyboii Feb 26, 2026
bcf9cb9
feat: add retry logic to python_news (#16)
carlisaksson Feb 26, 2026
200a4d8
test: test retry functionality (#15)
kahoujo1 Feb 26, 2026
4336867
fix: rewrite test to pass checks (#15)
kahoujo1 Feb 26, 2026
9ce2efa
docs: add comments in the cog_load function (#15)
kahoujo1 Feb 26, 2026
bac8217
fix: check if the error is retriable (#15)
kahoujo1 Feb 26, 2026
4f209a6
feat: alert moderators after max attempts (#16)
carlisaksson Feb 26, 2026
3d0ad6c
feat: add startup failure status message logs (#3 #5)
a-runebou Feb 27, 2026
d73ee44
docs: add brief project description (#7)
kahoujo1 Feb 27, 2026
b1d23d4
docs: add onboarding experience (#7)
kahoujo1 Feb 27, 2026
fe46c4c
docs: write issue overview (#7)
kahoujo1 Feb 27, 2026
b3c9edb
docs: add requirements (#7)
kahoujo1 Feb 27, 2026
9fe59db
docs: rewrite requirements (#7)
kahoujo1 Feb 27, 2026
c989eae
test: add superstarify tests (#14)
crancker96 Feb 27, 2026
81f8d34
feat: add logic to functions (#14)
crancker96 Feb 27, 2026
3b8d7eb
test: more unit tests for superstarify (#14)
crancker96 Feb 27, 2026
f1397a2
merge: merge filters load_cog retries (#13)
a-runebou Feb 27, 2026
c27fb5a
merge: Add retry logic with exponential backoff for `Reminders.cog_lo…
kahoujo1 Feb 28, 2026
5df0dbc
docs: added example in setting up the project #7
rippyboii Feb 28, 2026
442d7d8
docs: Created table for the Effort spent #7
rippyboii Feb 28, 2026
7647464
docs: created dependencies and setup task table #7
rippyboii Feb 28, 2026
214df2b
docs: added code changes and patch #7
rippyboii Feb 28, 2026
6d0f23f
docs: added test results for both after and before implementation #7
rippyboii Feb 28, 2026
0b89bdb
fix: remove duplicate moderator notification in filters extension (#3)
a-runebou Feb 28, 2026
642f699
merge: merge reminder retries (#15)
a-runebou Feb 28, 2026
d582b80
docs: updated overall experience section
rippyboii Feb 28, 2026
8ecd602
fix: remove duplicate moderator notification in reminders extension (#3)
a-runebou Feb 28, 2026
768b2fa
test: mocked setup and pythonNews tests (#16)
carlisaksson Feb 28, 2026
7dd05a0
Merge pull request #19 from rippyboii/issue/3
a-runebou Mar 1, 2026
e534601
refactor: remove local mod alerting and update tests (#16)
carlisaksson Mar 1, 2026
af38322
Merge branch 'dev' into issue/14
crancker96 Mar 1, 2026
4e203c2
refactor: remove _alert_mods_if_loading_failed() (#14)
crancker96 Mar 1, 2026
f802833
doc: add effort spent (#7)
crancker96 Mar 1, 2026
695f0eb
docs: describe design patterns and our modifications (Closes #25)
a-runebou Mar 1, 2026
6e1c8cd
docs: add personal time spent Alexander (#7)
a-runebou Mar 1, 2026
989b257
merge: add retry logic for pythonNews cog (#16)
a-runebou Mar 1, 2026
ae6cd0d
merge: add retry logic for superstarify cogs (Closes #14)
a-runebou Mar 1, 2026
8428c67
test: add tests for invalid extensions and cogs (#3)
Feb 26, 2026
b5782ab
feat: add centralized exception logging for extensions and cogs (#3)
a-runebou Feb 26, 2026
50cdacc
feat: add startup failure status message logs (#3 #5)
a-runebou Feb 27, 2026
f22028b
test: add Filtering cog_load and test scaffold and mocked setup
rippyboii Feb 25, 2026
f4d398b
test: cover cog_load behavior when filter list fetch fails
rippyboii Feb 25, 2026
43f1c5b
feat: Add retry error filter and mod alert for filter load failures #6
rippyboii Feb 25, 2026
8b575f6
feat: add retry with exponential backoff for filter list loading #6
rippyboii Feb 25, 2026
2cbb226
fix: updated untracked unit test
rippyboii Feb 26, 2026
f10d6fa
fix: added filtering cog retry test and fixed cog_load startup tests #13
rippyboii Feb 26, 2026
92fc442
test: Added filtering cog retry-path tests for success and final-fail…
rippyboii Feb 26, 2026
71bf092
fix: updated filter_load_max_attempts contant to reuse connect_max_re…
rippyboii Feb 26, 2026
0f81c17
fix: added 408: Request Timeout Error, to imply for retryable and it'…
rippyboii Feb 26, 2026
feff4b2
fix: remove duplicate moderator notification in filters extension (#3)
a-runebou Feb 28, 2026
e38f365
feat: add backoff retry loading (#15)
kahoujo1 Feb 26, 2026
e098a66
feat: alert mods through discord (#15)
kahoujo1 Feb 26, 2026
4754f19
test: add test skeleton and valid load test (#15)
kahoujo1 Feb 26, 2026
6d9fc0c
test: test retry functionality (#15)
kahoujo1 Feb 26, 2026
e1c3796
fix: rewrite test to pass checks (#15)
kahoujo1 Feb 26, 2026
49e9639
docs: add comments in the cog_load function (#15)
kahoujo1 Feb 26, 2026
abaccdf
fix: check if the error is retriable (#15)
kahoujo1 Feb 26, 2026
673d486
fix: remove duplicate moderator notification in reminders extension (#3)
a-runebou Feb 28, 2026
97cdf38
feat: add helper for checking if error is retryable (#16)
carlisaksson Feb 26, 2026
972d298
feat: add retry logic to python_news (#16)
carlisaksson Feb 26, 2026
015927e
feat: alert moderators after max attempts (#16)
carlisaksson Feb 26, 2026
2159edb
test: mocked setup and pythonNews tests (#16)
carlisaksson Feb 28, 2026
cd46116
refactor: remove local mod alerting and update tests (#16)
carlisaksson Mar 1, 2026
1afa433
test: add superstarify tests (#14)
crancker96 Feb 27, 2026
cc59caf
feat: add logic to functions (#14)
crancker96 Feb 27, 2026
62d115a
test: more unit tests for superstarify (#14)
crancker96 Feb 27, 2026
1e733d5
refactor: remove _alert_mods_if_loading_failed() (#14)
crancker96 Mar 1, 2026
e8c4357
docs: add estimated effort Carl (#7)
carlisaksson Mar 1, 2026
be836c3
docs: argue benefits in the context of SEMAT (closes #27)
kahoujo1 Mar 1, 2026
bd7fc01
Merge branch 'documentation' of github.com:rippyboii/python-bot into …
kahoujo1 Mar 1, 2026
d13351d
docs: add total and individual effort spent (#7)
kahoujo1 Mar 1, 2026
262838c
docs: trace tests to requirements (Closes #26)
kahoujo1 Mar 1, 2026
8631534
docs: decribe architecture and purpose (#24)
a-runebou Mar 1, 2026
7a48446
docs: write the Essence TEAM part (#7)
kahoujo1 Mar 1, 2026
7cc7bfa
doc: Add UML (#17)
crancker96 Mar 1, 2026
bd7389a
docs: added table of the content at the top #7
rippyboii Mar 2, 2026
278569e
merge: Merge remote-tracking branch 'origin' into documentation
rippyboii Mar 2, 2026
ab2adee
Merge branch 'documentation' of github.com:rippyboii/python-bot into …
rippyboii Mar 2, 2026
dc127bd
doc: replace UML bit map with svg (#30)
crancker96 Mar 2, 2026
7f43428
fix: replace bitmap with svg (#30)
a-runebou Mar 2, 2026
363c0a8
merge: Merge branch 'documentation' of github.com:rippyboii/python-bo…
a-runebou Mar 2, 2026
9181201
docs: added the team member names #7
rippyboii Mar 2, 2026
516d5b2
merge: Merge branch 'documentation' of github.com:rippyboii/python-bo…
rippyboii Mar 2, 2026
25f7aaf
fix: fix svg (#30)
a-runebou Mar 2, 2026
47ff624
merge: Merge branch 'dev'
rippyboii Mar 2, 2026
442b2ab
merge: Merge remote-tracking branch 'origin/main' into documentation
rippyboii Mar 2, 2026
8eba6aa
merge: Merge branch 'documentation' of github.com:rippyboii/python-bo…
rippyboii Mar 2, 2026
ffb1905
docs: add expected behavior for requirements (#33)
kahoujo1 Mar 2, 2026
c1325f1
docs: add test structure to requirements (#33)
kahoujo1 Mar 2, 2026
3e87546
refactor: add back-off constant (#40)
kahoujo1 Mar 2, 2026
6805c9a
refactor: update constants in reminders (#40)
kahoujo1 Mar 2, 2026
e64ccf4
refactor: update constants in filtering (#40)
kahoujo1 Mar 2, 2026
1e27dde
refactor: update constants in python news (#40)
kahoujo1 Mar 2, 2026
7bfd50d
refactor: update constants in superstarify (#40)
kahoujo1 Mar 2, 2026
cc0f3a1
fix: remove uncalled method (Closes #36)
a-runebou Mar 2, 2026
3700aee
refactor: rename variables (Closes #38)
a-runebou Mar 2, 2026
4d78a81
refactor: simplyfy merging of lines (Closes #39)
a-runebou Mar 2, 2026
8d4d0a0
refactor: remove explicit context helper function (Closes #45)
a-runebou Mar 2, 2026
7a637c3
refactor: remove dataclass label (Closes #47)
a-runebou Mar 2, 2026
4e40408
refactor: updated the testcases accordingly #36
rippyboii Mar 2, 2026
c0ced04
merge: Unified max number of retries and the back-off time using cons…
kahoujo1 Mar 2, 2026
a1f890f
merge: remove uncalled method
a-runebou Mar 2, 2026
f83a2da
merge: make variables more descriptive
a-runebou Mar 2, 2026
a3a87d6
merge: simplify merging of lines in notification
a-runebou Mar 2, 2026
a453851
merge: remove explicit context helper function
a-runebou Mar 2, 2026
3f3fa8f
merge: remove dataclass label
a-runebou Mar 2, 2026
7e20fee
refactor: add back-off constant (#40)
kahoujo1 Mar 2, 2026
4b196cc
refactor: update constants in reminders (#40)
kahoujo1 Mar 2, 2026
7d0f75d
refactor: update constants in filtering (#40)
kahoujo1 Mar 2, 2026
1704c9f
refactor: update constants in python news (#40)
kahoujo1 Mar 2, 2026
8428cac
refactor: update constants in superstarify (#40)
kahoujo1 Mar 2, 2026
0d14e70
fix: remove uncalled method (Closes #36)
a-runebou Mar 2, 2026
02e89ed
refactor: updated the testcases accordingly #36
rippyboii Mar 2, 2026
955682d
refactor: rename variables (Closes #38)
a-runebou Mar 2, 2026
8310588
refactor: simplyfy merging of lines (Closes #39)
a-runebou Mar 2, 2026
1524aff
refactor: remove explicit context helper function (Closes #45)
a-runebou Mar 2, 2026
b5fc5b2
refactor: remove dataclass label (Closes #47)
a-runebou Mar 2, 2026
4d73aad
refactor: Move the duplicated retry classifier out of the cogs into a…
rippyboii Mar 2, 2026
3642840
merge: Merge branch 'issue/50' into dev
rippyboii Mar 2, 2026
695206e
refactor: Updated superstarify.py to use the shared retry helper from…
rippyboii Mar 2, 2026
791bb8e
refactor: updated _fetch_with_retries to use logic from filterign #55
rippyboii Mar 2, 2026
fe2a8a3
merge: Merge branch 'documentation' of github.com:rippyboii/python-bo…
rippyboii Mar 2, 2026
c2a5cbd
docs: fixed test file paths #7
rippyboii Mar 3, 2026
988666a
merge: add report to main (#7)
kahoujo1 Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions bot/bot.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import asyncio
import contextlib
import types
from sys import exception

import aiohttp
from discord.errors import Forbidden
from discord.ext import commands
from pydis_core import BotBase
from pydis_core.utils import scheduling
from pydis_core.utils._extensions import walk_extensions
from pydis_core.utils.error_handling import handle_forbidden_from_block
from sentry_sdk import new_scope, start_transaction

from bot import constants, exts
from bot.log import get_logger
from bot.utils.startup_reporting import StartupFailureReporter

log = get_logger("bot")


class StartupError(Exception):
"""Exception class for startup errors."""

Expand All @@ -26,9 +30,13 @@ class Bot(BotBase):
"""A subclass of `pydis_core.BotBase` that implements bot-specific functions."""

def __init__(self, *args, **kwargs):

super().__init__(*args, **kwargs)

# Track extension load failures and tasks so we can report them after all attempts have completed
self.extension_load_failures: dict[str, BaseException] = {}
self._extension_load_tasks: dict[str, asyncio.Task] = {}
self._startup_failure_reporter = StartupFailureReporter()

async def load_extension(self, name: str, *args, **kwargs) -> None:
"""Extend D.py's load_extension function to also record sentry performance stats."""
with start_transaction(op="cog-load", name=name):
Expand Down Expand Up @@ -77,3 +85,53 @@ async def on_error(self, event: str, *args, **kwargs) -> None:
scope.set_extra("kwargs", kwargs)

log.exception(f"Unhandled exception in {event}.")

async def add_cog(self, cog: commands.Cog) -> None:
"""
Add a cog to the bot with exception handling.

Override of `BotBase.add_cog` to capture and log any exceptions raised during cog loading,
including the extension name if available.
"""
extension = cog.__module__

try:
await super().add_cog(cog)
log.info(f"Cog successfully loaded: {cog.qualified_name}")

except BaseException as e:
key = extension or f"(unknown)::{cog.qualified_name}"
self.extension_load_failures[key] = e

log.exception(
f"Failed during add_cog (extension={extension}, cog={cog.qualified_name})"
)
# Propagate error
raise

async def _load_extensions(self, module: types.ModuleType) -> None:
"""Load extensions for the bot."""
await self.wait_until_guild_available()

self.all_extensions = walk_extensions(module)

async def _load_one(extension: str) -> None:
try:
await self.load_extension(extension)
log.info(f"Extension successfully loaded: {extension}")

except BaseException as e:
self.extension_load_failures[extension] = e
log.exception(f"Failed to load extension: {extension}")
raise

for extension in self.all_extensions:
task = scheduling.create_task(_load_one(extension))
self._extension_load_tasks[extension] = task

# Wait for all load tasks to complete so we can report any failures together
await asyncio.gather(*self._extension_load_tasks.values(), return_exceptions=True)

# Send a Discord message to moderators if any extensions failed to load
if self.extension_load_failures :
await self._startup_failure_reporter.notify(self, self.extension_load_failures)
3 changes: 3 additions & 0 deletions bot/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,9 @@ class _URLs(_BaseURLs):
connect_max_retries: int = 3
connect_cooldown: int = 5

# Back-off in cog_load
connect_initial_backoff: int = 1

site_logs_view: str = "https://pythondiscord.com/staff/bot/logs"


Expand Down
30 changes: 28 additions & 2 deletions bot/exts/filtering/filtering.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import datetime
import io
import json
Expand All @@ -24,7 +25,7 @@
import bot.exts.filtering._ui.filter as filters_ui
from bot import constants
from bot.bot import Bot
from bot.constants import BaseURLs, Channels, Guild, MODERATION_ROLES, Roles
from bot.constants import BaseURLs, Channels, Guild, MODERATION_ROLES, Roles, URLs
from bot.exts.backend.branding._repository import HEADERS, PARAMS
from bot.exts.filtering._filter_context import Event, FilterContext
from bot.exts.filtering._filter_lists import FilterList, ListType, ListTypeConverter, filter_list_types
Expand Down Expand Up @@ -55,6 +56,7 @@
from bot.utils.channel import is_mod_channel
from bot.utils.lock import lock_arg
from bot.utils.message_cache import MessageCache
from bot.utils.retry import is_retryable_api_error

log = get_logger(__name__)

Expand Down Expand Up @@ -108,7 +110,31 @@ async def cog_load(self) -> None:
await self.bot.wait_until_guild_available()

log.trace("Loading filtering information from the database.")
raw_filter_lists = await self.bot.api_client.get("bot/filter/filter_lists")
for attempt in range(1, URLs.connect_max_retries + 1):
try:
raw_filter_lists = await self.bot.api_client.get("bot/filter/filter_lists")
break
except Exception as error:
is_retryable = is_retryable_api_error(error)
is_last_attempt = attempt == URLs.connect_max_retries

if not is_retryable:
raise

if is_last_attempt:
log.exception("Failed to load filtering data after %d attempts.", URLs.connect_max_retries)
raise

backoff_seconds = URLs.connect_initial_backoff * (2 ** (attempt - 1))
log.warning(
"Failed to load filtering data (attempt %d/%d). Retrying in %d second(s): %s",
attempt,
URLs.connect_max_retries,
backoff_seconds,
error
)
await asyncio.sleep(backoff_seconds)

example_list = None
for raw_filter_list in raw_filter_lists:
loaded_list = self._load_raw_filter_list(raw_filter_list)
Expand Down
55 changes: 42 additions & 13 deletions bot/exts/info/python_news.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import re
import typing as t
from datetime import UTC, datetime, timedelta
Expand All @@ -12,7 +13,9 @@

from bot import constants
from bot.bot import Bot
from bot.constants import URLs
from bot.log import get_logger
from bot.utils.retry import is_retryable_api_error
from bot.utils.webhooks import send_webhook

PEPS_RSS_URL = "https://peps.python.org/peps.rss"
Expand Down Expand Up @@ -46,19 +49,45 @@ def __init__(self, bot: Bot):

async def cog_load(self) -> None:
"""Load all existing seen items from db and create any missing mailing lists."""
with sentry_sdk.start_span(description="Fetch mailing lists from site"):
response = await self.bot.api_client.get("bot/mailing-lists")

for mailing_list in response:
self.seen_items[mailing_list["name"]] = set(mailing_list["seen_items"])

with sentry_sdk.start_span(description="Update site with new mailing lists"):
for mailing_list in ("pep", *constants.PythonNews.mail_lists):
if mailing_list not in self.seen_items:
await self.bot.api_client.post("bot/mailing-lists", json={"name": mailing_list})
self.seen_items[mailing_list] = set()

self.fetch_new_media.start()
for attempt in range(1, URLs.connect_max_retries + 1):
try:
with sentry_sdk.start_span(description="Fetch mailing lists from site"):
response = await self.bot.api_client.get("bot/mailing-lists")

# Rebuild state on each successful fetch (avoid partial state across retries)
self.seen_items = {}
for mailing_list in response:
self.seen_items[mailing_list["name"]] = set(mailing_list["seen_items"])

with sentry_sdk.start_span(description="Update site with new mailing lists"):
for mailing_list in ("pep", *constants.PythonNews.mail_lists):
if mailing_list not in self.seen_items:
await self.bot.api_client.post("bot/mailing-lists", json={"name": mailing_list})
self.seen_items[mailing_list] = set()

self.fetch_new_media.start()
return
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would probably break here instead of return.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi,

Since a successful fetch means cog_load() is finished after self.fetch_new_media.start(), so we thought return would be a good fit to exit the whoel function instead of breaking a loop.

If you think this doesn't fit the patch-style or the convention, we can update it in next commit.


except Exception as error:
if not is_retryable_api_error(error):
raise

if attempt == URLs.connect_max_retries:
log.exception(
"Failed to load PythonNews mailing lists after %d attempt(s).",
URLs.connect_max_retries,
)
raise

backoff_seconds = URLs.connect_initial_backoff * (2 ** (attempt - 1))
log.warning(
"Failed to load PythonNews mailing lists (attempt %d/%d). Retrying in %d second(s). Error: %s",
attempt,
URLs.connect_max_retries,
backoff_seconds,
error,
)
await asyncio.sleep(backoff_seconds)

async def cog_unload(self) -> None:
"""Stop news posting tasks on cog unload."""
Expand Down
27 changes: 21 additions & 6 deletions bot/exts/moderation/infraction/superstarify.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import json
import random
import textwrap
Expand All @@ -10,13 +11,15 @@

from bot import constants
from bot.bot import Bot
from bot.constants import URLs
from bot.converters import Duration, DurationOrExpiry
from bot.decorators import ensure_future_timestamp
from bot.exts.moderation.infraction import _utils
from bot.exts.moderation.infraction._scheduler import InfractionScheduler
from bot.log import get_logger
from bot.utils import time
from bot.utils.messages import format_user
from bot.utils.retry import is_retryable_api_error

log = get_logger(__name__)
NICKNAME_POLICY_URL = "https://pythondiscord.com/pages/rules/#nickname-policy"
Expand All @@ -43,9 +46,7 @@ async def on_member_update(self, before: Member, after: Member) -> None:
f"{after.display_name}. Checking if the user is in superstar-prison..."
)

active_superstarifies = await self.bot.api_client.get(
"bot/infractions",
params={
active_superstarifies = await self._fetch_with_retries(params={
"active": "true",
"type": "superstar",
"user__id": str(before.id)
Expand Down Expand Up @@ -84,9 +85,7 @@ async def on_member_update(self, before: Member, after: Member) -> None:
@Cog.listener()
async def on_member_join(self, member: Member) -> None:
"""Reapply active superstar infractions for returning members."""
active_superstarifies = await self.bot.api_client.get(
"bot/infractions",
params={
active_superstarifies = await self._fetch_with_retries(params={
"active": "true",
"type": "superstar",
"user__id": member.id
Expand Down Expand Up @@ -238,6 +237,22 @@ async def cog_check(self, ctx: Context) -> bool:
"""Only allow moderators to invoke the commands in this cog."""
return await has_any_role(*constants.MODERATION_ROLES).predicate(ctx)

async def _fetch_with_retries(self,
retries: int = URLs.connect_max_retries,
params: dict[str, str] | None = None) -> list[dict]:
"""Fetch infractions from the API with retries and exponential backoff."""
if retries < 1:
raise ValueError("retries must be at least 1")

for attempt in range(1, retries + 1):
try:
return await self.bot.api_client.get("bot/infractions", params=params)
except Exception as e:
if attempt == retries or not is_retryable_api_error(e):
raise
await asyncio.sleep(URLs.connect_initial_backoff * (2 ** (attempt - 1)))
return None


async def setup(bot: Bot) -> None:
"""Load the Superstarify cog."""
Expand Down
33 changes: 27 additions & 6 deletions bot/exts/utils/reminders.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import random
import textwrap
import typing as t
Expand All @@ -23,6 +24,7 @@
POSITIVE_REPLIES,
Roles,
STAFF_AND_COMMUNITY_ROLES,
URLs,
)
from bot.converters import Duration, UnambiguousUser
from bot.errors import LockedResourceError
Expand Down Expand Up @@ -224,13 +226,25 @@ async def cog_unload(self) -> None:
async def cog_load(self) -> None:
"""Get all current reminders from the API and reschedule them."""
await self.bot.wait_until_guild_available()
response = await self.bot.api_client.get(
"bot/reminders",
params={"active": "true"}
)

# retry fetching reminders with exponential backoff
for attempt in range(1, URLs.connect_max_retries + 1):
try:
# response either throws, or is a list of reminders (possibly empty)
response = await self.bot.api_client.get(
"bot/reminders",
params={"active": "true"}
)
break
except Exception as e:
if not self._check_error_is_retriable(e):
log.error(f"Failed to load reminders due to non-retryable error: {e}")
raise
log.warning(f"Attempt {attempt} - Failed to fetch reminders from the API: {e}")
if attempt == URLs.connect_max_retries:
log.error("Max retry attempts reached. Failed to load reminders.")
raise
await asyncio.sleep(URLs.connect_initial_backoff * (2 ** (attempt - 1))) # Exponential backoff
now = datetime.now(UTC)

for reminder in response:
is_valid, *_ = self.ensure_valid_reminder(reminder)
if not is_valid:
Expand All @@ -244,6 +258,13 @@ async def cog_load(self) -> None:
else:
self.schedule_reminder(reminder)

def _check_error_is_retriable(self, error: Exception) -> bool:
"""Return whether loading filter lists failed due to some temporary error, thus retrying could help."""
if isinstance(error, ResponseCodeError):
return error.status in (408, 429) or error.status >= 500

return isinstance(error, (TimeoutError, OSError))

def ensure_valid_reminder(self, reminder: dict) -> tuple[bool, discord.TextChannel]:
"""Ensure reminder channel can be fetched otherwise delete the reminder."""
channel = self.bot.get_channel(reminder["channel_id"])
Expand Down
9 changes: 9 additions & 0 deletions bot/utils/retry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from pydis_core.site_api import ResponseCodeError


def is_retryable_api_error(error: Exception) -> bool:
"""Return whether an API error is temporary and worth retrying."""
if isinstance(error, ResponseCodeError):
return error.status in (408, 429) or error.status >= 500

return isinstance(error, (TimeoutError, OSError))
Loading