Skip to content

Handle refused Discord acknowledgements once, and tell 10062 from 40060 - #139

Merged
NinjaLikesCheez merged 3 commits into
mainfrom
fix/discord-ack-failures
Oct 19, 2026
Merged

NinjaLikesCheez merged 3 commits into
mainfrom
fix/discord-ack-failures

Conversation

@NinjaLikesCheez

Copy link
Copy Markdown
Contributor

What was wrong

1. Double-reported deferral failures, and placeholder failures reported as command failures.
When a command takes more than 1.5 s, respond() calls deferReply(). If Discord refuses it (40060 already acknowledged, or 10062 unknown interaction), that promise had no handler until the work finished, so Node emitted unhandledRejection: process logging logged it as an error and Sentry captured it with no actor or command, and then the adapter logged it a second time as discord.duplicate_handler.
Separately, for commands with a placeholder (/status, /ha, admin roles, ...), a refused placeholder interaction.reply inside onPending was caught by the dispatcher as command.failed (error log, reporter.capture, error metric), defeating the adapter's warn-only branch.

2. Every 10062 blamed on a second instance.
10062 also happens when Pixel itself is slow to acknowledge (e.g. the form path can't defer before showModal, and beforeForm does Discord REST work), but it was always logged as a warning about a second instance and never reported.

What changed

  • respond.ts: the deferral promise gets a no-op .catch when it's created. It's still awaited afterwards, so its failure surfaces once, from respond().
  • dispatcher.ts (small change): if onPending throws, the dispatcher rethrows that error without logging command.failed or capturing it, and the handler still doesn't run. The adapter now decides how to log and report it. The DispatchHooks.onPending doc says so. I couldn't do this in the adapter alone: anything that keeps the handler from running has to throw out of onPending, and the dispatcher's catch captured every such throw. The change is in dispatch() only, not the form path.
  • interaction-errors.ts: classifyInteractionError returns duplicate (40060), expired (10062) or other. handleInteractionFailure is the one place the adapter logs these failures, and both the command and modal paths in index.ts use it:
    • 40060: warns discord.duplicate_handler (second instance), not reported.
    • 10062: logs an error discord.slow_ack with command and actorLogFields, and reports a SlowAcknowledgementError("Discord expired /<command> before Pixel acknowledged it", { cause }) with the actor, so Sentry groups it by command. Nothing typed by the user is logged.
    • anything else: logged and reported as before.
  • vitest.config.ts: interaction-errors.ts is added to the strict-coverage adapter files (a tighter floor, nothing lowered).
  • Runbook: the duplicate-instance entry now names only 40060, and there's a new entry for discord.slow_ack / "The application did not respond". CHANGELOG Fixed entries added.

Tests

  • discord.test.ts: a deferReply that rejects with 10062 while the work is still running causes no unhandledRejection (checked with a real setImmediate turn under fake timers), and respond() rejects once with that error. This test fails without the .catch. A refused placeholder reply makes respond() reject with that error.
  • dispatcher.test.ts: when onPending rejects, dispatch rejects with the same error, the handler doesn't run, and there's no capture and no command.failed. A handler failure after a placeholder is still captured.
  • interaction-errors.test.ts: classification, and log/report behaviour for 40060, 10062 and other errors, for commands and forms.
  • just check passes.

Needs a human reviewer (AGENTS.md rule 14): src/core/dispatcher.ts changed. Another open PR edits the dispatcher's form path (prepareForm). This PR only touches dispatch() around onPending, so a conflict, if any, should be trivial.

🤖 Generated with Claude Code

A deferral that Discord refused had no handler until the work finished,
so Node raised an unhandled rejection (logged and sent to Sentry with no
actor) before the adapter logged it again. A refused placeholder reply
was caught by the dispatcher as command.failed and captured. The
deferral now has a no-op catch at creation (still awaited later), and an
onPending failure is rethrown by the dispatcher unreported, so the
adapter handles it once. The handler still doesn't run.

Only 40060 is now treated as a second instance (discord.duplicate_handler).
10062 means Pixel acknowledged too slowly: it is logged as
discord.slow_ack with the command and actor, and reported as a
SlowAcknowledgementError naming the command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@NinjaLikesCheez NinjaLikesCheez added the bug Something isn't working label Oct 18, 2026
@NinjaLikesCheez
NinjaLikesCheez merged commit 498f6e8 into main Oct 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant