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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "DiscordBot"
version = "3.0.13"
version = "3.0.14"
description = "A simple Discord bot with OpenAI support and server administration tools"
urls.Repository = "https://github.com/ddc/DiscordBot"
urls.Homepage = "https://ddc.github.io/DiscordBot"
Expand Down
2 changes: 1 addition & 1 deletion src/bot/constants/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class BotSettings(BaseSettings):
exclusive_users: str = Field(default="")

# OpenAi
openai_model: str = Field(default="gpt-5.4", description="https://developers.openai.com/api/docs/models")
openai_model: str = Field(default="gpt-5.5", description="https://developers.openai.com/api/docs/models")
openai_api_key: str | None = Field(default=None)

# Cooldowns
Expand Down
49 changes: 44 additions & 5 deletions src/bot/tools/bot_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import discord
import random
from datetime import UTC, datetime
Expand Down Expand Up @@ -115,6 +116,44 @@ async def send_help_msg(ctx, cmd):
await ctx.send(chat_formatting.box(cmd.help))


def _is_transient_discord_error(e: discord.HTTPException) -> bool:
"""Return True for Discord errors worth retrying (5xx, or 429 with code 40062)."""
status = getattr(e, "status", None)
code = getattr(e, "code", None)
return (isinstance(status, int) and status >= 500) or code == 40062


async def _send_with_retry(ctx, send_method, *args, max_attempts: int = 3, base_delay: float = 1.0, **kwargs):
"""Call send_method(*args, **kwargs) and retry on transient Discord errors.

On the first transient failure, posts a one-time "retrying" notice to the channel.
Non-transient errors propagate immediately, preserving caller's error handling.
"""
notified = False
for attempt in range(1, max_attempts + 1):
try:
return await send_method(*args, **kwargs)
except discord.HTTPException as e:
if not _is_transient_discord_error(e) or attempt >= max_attempts:
raise
ctx.bot.log.warning(
f"Transient Discord error (status={getattr(e, 'status', None)}, "
f"code={getattr(e, 'code', None)}), retry {attempt}/{max_attempts - 1}: {e}"
)
if not notified:
try:
await ctx.send(
embed=discord.Embed(
description="⏳ Discord API is having issues — retrying...",
color=discord.Color.orange(),
)
)
notified = True
except discord.HTTPException:
pass # Notice itself failed; keep retrying the main send
await asyncio.sleep(base_delay * (2 ** (attempt - 1)))


async def send_embed(ctx, embed, dm=False):
try:
if not embed.color:
Expand All @@ -124,25 +163,25 @@ async def send_embed(ctx, embed, dm=False):

if is_private_message(ctx):
# Already in DM, just send the embed
await ctx.author.send(embed=embed)
await _send_with_retry(ctx, ctx.author.send, embed=embed)
elif dm:
# Send to DM and notify in channel
try:
await ctx.author.send(embed=embed)
await _send_with_retry(ctx, ctx.author.send, embed=embed)
notification_embed = discord.Embed(
description="📬 Response sent to your DM", color=discord.Color.green()
)
notification_embed.set_author(
name=ctx.author.display_name,
icon_url=ctx.author.avatar.url if ctx.author.avatar else ctx.author.default_avatar.url,
)
await ctx.send(embed=notification_embed)
await _send_with_retry(ctx, ctx.send, embed=notification_embed)
except discord.Forbidden, discord.HTTPException:
# DM failed, fall back to sending in the channel
await ctx.send(embed=embed)
await _send_with_retry(ctx, ctx.send, embed=embed)
else:
# Send to channel
await ctx.send(embed=embed)
await _send_with_retry(ctx, ctx.send, embed=embed)
except (discord.Forbidden, discord.HTTPException) as e:
ctx.bot.log.error(f"Failed to send message: {e}")
if dm or is_private_message(ctx):
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/bot/constants/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def test_partial_env_var_overrides(self):
assert settings.admin_cooldown == 35

# Default values for non-overridden fields
assert settings.openai_model == "gpt-5.4"
assert settings.openai_model == "gpt-5.5"
# Note: openai_api_key might have a value from actual env, so we'll check it's set
assert settings.embed_color == "green"
assert settings.config_cooldown == 20
Expand Down
89 changes: 89 additions & 0 deletions tests/unit/bot/tools/test_bot_utils_extra.py
Original file line number Diff line number Diff line change
Expand Up @@ -1067,3 +1067,92 @@ async def test_delete_embed_pages(self, mock_dal):
"""Test delete_embed_pages calls db_utils.execute."""
await mock_dal.delete_embed_pages(111)
mock_dal._mock_db_utils.execute.assert_awaited_once()


def _make_http_exception(status: int, code: int = 0) -> discord.HTTPException:
"""Build a discord.HTTPException with concrete status and Discord error code."""
response = MagicMock()
response.status = status
return discord.HTTPException(response, {"message": "boom", "code": code})


class TestSendWithRetry:
"""Test _send_with_retry helper for transient Discord errors."""

@pytest.fixture
def mock_ctx(self):
ctx = MagicMock()
ctx.bot = MagicMock()
ctx.bot.log = MagicMock()
ctx.send = AsyncMock()
return ctx

@pytest.mark.asyncio
async def test_success_on_first_attempt_no_retry(self, mock_ctx):
"""Happy path: send_method called once, no notice sent."""
send = AsyncMock(return_value="ok")
result = await bot_utils._send_with_retry(mock_ctx, send, embed="x")
assert result == "ok"
send.assert_awaited_once_with(embed="x")
mock_ctx.send.assert_not_called()

@pytest.mark.asyncio
async def test_retries_on_500_then_succeeds(self, mock_ctx):
"""500 error → retry, second attempt succeeds; one channel notice sent."""
send = AsyncMock(side_effect=[_make_http_exception(500), "ok"])
with patch("src.bot.tools.bot_utils.asyncio.sleep", new_callable=AsyncMock):
result = await bot_utils._send_with_retry(mock_ctx, send, embed="x")
assert result == "ok"
assert send.await_count == 2
# Notice sent exactly once
mock_ctx.send.assert_called_once()
notice_embed = mock_ctx.send.call_args[1]["embed"]
assert "retrying" in notice_embed.description.lower()

@pytest.mark.asyncio
async def test_retries_on_429_code_40062(self, mock_ctx):
"""429 with code 40062 is treated as transient and retried."""
send = AsyncMock(side_effect=[_make_http_exception(429, code=40062), "ok"])
with patch("src.bot.tools.bot_utils.asyncio.sleep", new_callable=AsyncMock):
result = await bot_utils._send_with_retry(mock_ctx, send)
assert result == "ok"
assert send.await_count == 2

@pytest.mark.asyncio
async def test_does_not_retry_on_403_forbidden(self, mock_ctx):
"""Forbidden (403) is not transient — raises immediately."""
forbidden = discord.Forbidden(MagicMock(status=403), {"message": "no", "code": 50007})
send = AsyncMock(side_effect=forbidden)
with pytest.raises(discord.Forbidden):
await bot_utils._send_with_retry(mock_ctx, send)
send.assert_awaited_once()
mock_ctx.send.assert_not_called()

@pytest.mark.asyncio
async def test_does_not_retry_on_429_other_code(self, mock_ctx):
"""429 without code 40062 is not retried by this helper."""
send = AsyncMock(side_effect=_make_http_exception(429, code=20016))
with pytest.raises(discord.HTTPException):
await bot_utils._send_with_retry(mock_ctx, send)
send.assert_awaited_once()

@pytest.mark.asyncio
async def test_exhausts_retries_then_raises(self, mock_ctx):
"""All attempts fail with 500 → final attempt's exception propagates."""
send = AsyncMock(side_effect=_make_http_exception(500))
with patch("src.bot.tools.bot_utils.asyncio.sleep", new_callable=AsyncMock):
with pytest.raises(discord.HTTPException):
await bot_utils._send_with_retry(mock_ctx, send, max_attempts=3)
assert send.await_count == 3
# Notice sent at most once even across multiple failed attempts
assert mock_ctx.send.call_count == 1

@pytest.mark.asyncio
async def test_notice_failure_does_not_break_retry(self, mock_ctx):
"""If the retry notice itself fails, retry loop continues silently."""
mock_ctx.send.side_effect = _make_http_exception(500)
send = AsyncMock(side_effect=[_make_http_exception(500), "ok"])
with patch("src.bot.tools.bot_utils.asyncio.sleep", new_callable=AsyncMock):
result = await bot_utils._send_with_retry(mock_ctx, send)
assert result == "ok"
assert send.await_count == 2
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading