From d1d835bc89490d4928c2e7639bd1c891968bdb04 Mon Sep 17 00:00:00 2001 From: itsXactlY <6356217+itsXactlY@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:59:10 +0200 Subject: [PATCH] fix(discord): report the 30032 command cap as itself, not a generic sync failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilds #48087 on current main and applies its review feedback. Discord rejects the entire slash-command sync batch with HTTP 400 / error code 30032 ("Maximum number of application commands reached") once an app holds 100 global commands. That rejection is neither a 429 — so the rate-limit branch in _run_post_connect_initialization() skips it — nor recognizable in the outer handler, which logs it as "Slash command sync failed" with a stack trace. The operator is left with no hint that the fix is to free command slots, and every slash command stays broken. A narrow _is_discord_command_cap_error() now precedes the rate-limit check and logs the condition in plain words. Review feedback applied: the warning names HTTP 400 *and* error code 30032 separately. 30032 is a Discord JSON error code, not an HTTP status; labelling it "HTTP 30032" would send an operator looking in the wrong place. The test asserts both fragments. Detection follows the house style of _is_discord_unknown_interaction rather than the original PR's narrower version: it also reads the code out of a JSON `data` payload and prefers exc.status over exc.response.status. It stays deliberately narrow — a bare 400 proves nothing (50035 "Invalid Form Body" is also a 400), so a match needs the 30032 code, or a 400 plus the specific cap message for older forks and mocks that expose no code. Anything else keeps raising, so unrelated bugs still surface with their traceback. Tests (tests/gateway/test_discord_connect.py): code attr, JSON payload code, legacy message-only fallback, unrelated 400 ignored, 429 ignored, the end-to-end log wording, and an unrelated RuntimeError still reaching the generic handler. --- plugins/platforms/discord/adapter.py | 61 +++++++++++++ tests/gateway/test_discord_connect.py | 124 ++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 5fab2307c30a8..96fcbe202d222 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -61,6 +61,11 @@ def __init__(self, id: int) -> None: # noqa: A002 - matches discord API # every slash command — not just the overflow ones. We keep the desired set # at or below this limit at registration time. _DISCORD_MAX_APP_COMMANDS = 100 +# The Discord JSON error code returned with that rejection. It arrives as +# HTTP 400 with ``{"code": 30032}`` in the body — the status alone is not +# distinctive (50035 "Invalid Form Body" is also a 400), so detection keys +# on the code. +_DISCORD_COMMAND_CAP_ERROR_CODE = 30032 _DISCORD_SELECT_FIELD_LIMIT = 100 _DISCORD_BUTTON_LABEL_LIMIT = 80 _DISCORD_ELLIPSIS = "\u2026" @@ -1880,6 +1885,46 @@ def _is_discord_rate_limit(exc: BaseException) -> bool: return True return False + @staticmethod + def _is_discord_command_cap_error(exc: BaseException) -> bool: + """True only for Discord's global-application-command cap rejection. + + Discord refuses the whole batch with **HTTP 400 / error code 30032** + ("Maximum number of application commands reached") once an app is at + ``_DISCORD_MAX_APP_COMMANDS``. That is the one known cause of an + otherwise-inexplicable sync failure on a loaded install, and it is + worth telling the operator about in plain words rather than a stack + trace. + + Deliberately narrow, mirroring ``_is_discord_rate_limit``: the 400 + status alone proves nothing (50035 "Invalid Form Body" is also a + 400), so a match requires the 30032 code, or — for older discord.py + forks, mocks, and exotic transports that don't expose one — a 400 + *plus* the specific cap message. Arbitrary 400s keep raising, so + unrelated bugs still surface with their traceback. + """ + code = getattr(exc, "code", None) + if code is None: + data = getattr(exc, "data", None) + if isinstance(data, dict): + code = data.get("code") + try: + code = int(code) + except (TypeError, ValueError): + code = None + if code == _DISCORD_COMMAND_CAP_ERROR_CODE: + return True + + status = getattr(exc, "status", None) + if status is None: + response = getattr(exc, "response", None) + if response is not None: + status = getattr(response, "status", None) or getattr(response, "status_code", None) + if status != 400: + return False + text = getattr(exc, "text", "") or str(exc) or "" + return "Maximum number of application commands reached" in text + @staticmethod def _is_discord_unknown_interaction(exc: BaseException) -> bool: """True for Discord's expired interaction token error.""" @@ -1949,6 +1994,22 @@ async def _run_post_connect_initialization(self) -> None: # persist Discord's retry-after when it refuses the batch. summary = await asyncio.wait_for(self._safe_sync_slash_commands(), timeout=600) except Exception as e: + # The command cap is checked before the rate-limit branch: + # it is neither a 429 nor a transient condition, so it would + # otherwise fall through to the outer handler and reach the + # operator as a "Slash command sync failed" stack trace with + # no hint that the fix is to free command slots. + if self._is_discord_command_cap_error(e): + logger.warning( + "[%s] Discord slash command sync rejected: app is at the " + "%d global command cap (HTTP 400 / error code %d). " + "Disable unused plugins or trim COMMAND_REGISTRY to free " + "slots; the sync will retry on the next reconnect.", + self.name, + _DISCORD_MAX_APP_COMMANDS, + _DISCORD_COMMAND_CAP_ERROR_CODE, + ) + return if not self._is_discord_rate_limit(e): raise retry_after = self._extract_discord_retry_after(e) diff --git a/tests/gateway/test_discord_connect.py b/tests/gateway/test_discord_connect.py index 2ef7c877628f9..2f7a485681308 100644 --- a/tests/gateway/test_discord_connect.py +++ b/tests/gateway/test_discord_connect.py @@ -1,5 +1,6 @@ import asyncio import json +import logging import os import sys from types import SimpleNamespace @@ -622,3 +623,126 @@ async def test_no_discord_lib_sets_non_retryable_fatal(self, monkeypatch): assert adapter.fatal_error_retryable is False assert adapter.fatal_error_code == "missing_dependency" + + +# ============================================================================ +# #48087: the 100-global-command cap (HTTP 400 / error code 30032) is reported +# as itself, not as a generic "Slash command sync failed" stack trace +# ============================================================================ + +class TestDiscordCommandCapDiagnostic: + """Discord rejects the whole sync batch with HTTP 400 / code 30032 once an + app holds 100 global commands. Without a dedicated branch it is neither a + 429 (so the rate-limit handler skips it) nor recognisable in the outer + handler's traceback, leaving the operator with no hint that the fix is to + free command slots.""" + + class _CapError(Exception): + """HTTPException-shaped 30032, as discord.py raises it.""" + + code = 30032 + status = 400 + text = "Maximum number of application commands reached (100)." + + def __str__(self): + return self.text + + def test_detects_code_30032(self): + assert DiscordAdapter._is_discord_command_cap_error(self._CapError()) is True + + def test_detects_code_from_payload_when_attr_absent(self): + """Some transports expose the JSON body rather than a ``code`` attr.""" + + class _PayloadCapError(Exception): + data = {"code": 30032, "message": "Maximum number of application commands reached"} + status = 400 + + assert DiscordAdapter._is_discord_command_cap_error(_PayloadCapError()) is True + + def test_detects_via_message_on_legacy_exception(self): + """Older forks / mocks expose no code — fall back to 400 + the message.""" + + class _LegacyCapError(Exception): + response = SimpleNamespace(status=400, status_code=400) + text = "Maximum number of application commands reached (100)." + + assert DiscordAdapter._is_discord_command_cap_error(_LegacyCapError()) is True + + def test_ignores_unrelated_400(self): + """A 400 alone proves nothing — 50035 must keep raising.""" + + class _InvalidFormBody(Exception): + code = 50035 + status = 400 + text = "Invalid Form Body" + + assert DiscordAdapter._is_discord_command_cap_error(_InvalidFormBody()) is False + + def test_ignores_rate_limit(self): + class _RateLimited(Exception): + status = 429 + text = "Too Many Requests" + + assert DiscordAdapter._is_discord_command_cap_error(_RateLimited()) is False + assert DiscordAdapter._is_discord_command_cap_error(RuntimeError("boom")) is False + + @pytest.mark.asyncio + async def test_cap_error_logs_http_400_and_error_code(self, tmp_path, monkeypatch, caplog): + """The warning must name both the HTTP status and the Discord error + code — ``30032`` is not an HTTP status, and conflating the two sends + an operator looking in the wrong place.""" + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token")) + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + + class _DesiredCommand: + def to_dict(self, tree): + return {"name": "status", "description": "Show status", "type": 1, "options": []} + + adapter._client = SimpleNamespace( + tree=SimpleNamespace(get_commands=lambda: [_DesiredCommand()]), + application_id=4242, + user=SimpleNamespace(id=4242), + ) + sync = AsyncMock(side_effect=self._CapError()) + monkeypatch.setattr(adapter, "_safe_sync_slash_commands", sync) + + with caplog.at_level(logging.WARNING, logger="plugins.platforms.discord.adapter"): + # Must not raise: the cap branch absorbs it after reporting. + await adapter._run_post_connect_initialization() + + sync.assert_awaited_once() + messages = [r.getMessage() for r in caplog.records] + cap_lines = [m for m in messages if "global command cap" in m] + assert cap_lines, f"expected a cap-specific warning, got: {messages!r}" + assert "HTTP 400" in cap_lines[0], f"status missing: {cap_lines[0]!r}" + assert "error code 30032" in cap_lines[0], f"error code missing: {cap_lines[0]!r}" + assert "100 global command cap" in cap_lines[0] + assert not any("Slash command sync failed" in m for m in messages), ( + f"cap error fell through to the generic handler: {messages!r}" + ) + + @pytest.mark.asyncio + async def test_unrelated_failure_still_hits_generic_handler(self, tmp_path, monkeypatch, caplog): + """The narrow branch must not swallow anything else.""" + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token")) + monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: tmp_path) + + class _DesiredCommand: + def to_dict(self, tree): + return {"name": "status", "description": "Show status", "type": 1, "options": []} + + adapter._client = SimpleNamespace( + tree=SimpleNamespace(get_commands=lambda: [_DesiredCommand()]), + application_id=4243, + user=SimpleNamespace(id=4243), + ) + monkeypatch.setattr( + adapter, "_safe_sync_slash_commands", AsyncMock(side_effect=RuntimeError("boom")) + ) + + with caplog.at_level(logging.WARNING, logger="plugins.platforms.discord.adapter"): + await adapter._run_post_connect_initialization() + + messages = [r.getMessage() for r in caplog.records] + assert any("Slash command sync failed" in m for m in messages), messages + assert not any("global command cap" in m for m in messages), messages