From fb0ea8c49513b4a5590ffde054dfc9155493f191 Mon Sep 17 00:00:00 2001 From: ddc Date: Fri, 8 May 2026 16:33:34 -0300 Subject: [PATCH] v3.0.14 --- pyproject.toml | 2 +- src/bot/constants/settings.py | 2 +- src/bot/tools/bot_utils.py | 49 +++++++++-- tests/unit/bot/constants/test_settings.py | 2 +- tests/unit/bot/tools/test_bot_utils_extra.py | 89 ++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 137 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bca5fd9..0bb0f61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/bot/constants/settings.py b/src/bot/constants/settings.py index 1756f16..3f21b86 100644 --- a/src/bot/constants/settings.py +++ b/src/bot/constants/settings.py @@ -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 diff --git a/src/bot/tools/bot_utils.py b/src/bot/tools/bot_utils.py index cae61cb..16c26d8 100644 --- a/src/bot/tools/bot_utils.py +++ b/src/bot/tools/bot_utils.py @@ -1,3 +1,4 @@ +import asyncio import discord import random from datetime import UTC, datetime @@ -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: @@ -124,11 +163,11 @@ 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() ) @@ -136,13 +175,13 @@ async def send_embed(ctx, embed, dm=False): 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): diff --git a/tests/unit/bot/constants/test_settings.py b/tests/unit/bot/constants/test_settings.py index 78b73b6..7195d41 100644 --- a/tests/unit/bot/constants/test_settings.py +++ b/tests/unit/bot/constants/test_settings.py @@ -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 diff --git a/tests/unit/bot/tools/test_bot_utils_extra.py b/tests/unit/bot/tools/test_bot_utils_extra.py index 180a208..413e865 100644 --- a/tests/unit/bot/tools/test_bot_utils_extra.py +++ b/tests/unit/bot/tools/test_bot_utils_extra.py @@ -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 diff --git a/uv.lock b/uv.lock index c02b8aa..373aa3a 100644 --- a/uv.lock +++ b/uv.lock @@ -381,7 +381,7 @@ wheels = [ [[package]] name = "discordbot" -version = "3.0.13" +version = "3.0.14" source = { virtual = "." } dependencies = [ { name = "alembic" },