Skip to content

fix(bot): bound outbox delivery retries, treat 50278 as permanent - #244

Merged
Musiker15 merged 1 commit into
mainfrom
fix/outbox-retry-bounds
Aug 17, 2026
Merged

fix(bot): bound outbox delivery retries, treat 50278 as permanent#244
Musiker15 merged 1 commit into
mainfrom
fix/outbox-retry-bounds

Conversation

@Musiker15

Copy link
Copy Markdown
Member

Found while reading the bot logs on the server (the undici follow-up from the last session).

The bug

[bot] failed to DM user 473872362669080578: DiscordAPIError[50278]:
Cannot send messages to this user due to having no mutual guilds

notifications.ts treated only 50007 as a permanent "cannot DM" answer. Discord also returns 50278 when the applicant has left the guild, and that fell through to the transient branch: deliverOne returned false, the row was never marked read, and the poller retried it every 15 seconds forever. Three users were in that state, two of them appearing twice within 200 log lines.

Why it is worse than log noise

const BATCH = 25;
orderBy: { createdAt: "asc" }, take: BATCH

Permanently stuck rows are the oldest rows, so they occupy their slots in every batch. At 25 of them the outbox stops completely, and the table is shared: status DMs, review embeds and the guild activity log all stop with it. The count can only grow, since every departing applicant adds one.

The fix

Two parts, deliberately: one for this bug, one so the next unknown permanent code cannot do the same thing.

apps/bot/src/delivery-policy.ts holds the permanent DM codes, the attempt ceiling and the backoff. No discord.js, no Prisma, so the policy is unit-tested (9 new tests, bot suite 4 to 13). New permanent codes go there.

Notification gains attempts / lastError / nextAttemptAt, the shape WebhookDelivery already uses in this repo. A failing row backs off exponentially (30s doubling, capped at 30 min) and is retired after 8 attempts, about two hours, which outlasts a normal Discord incident without letting a broken row live forever.

readAt keeps its meaning of "retired", which already covered both delivered and permanently undeliverable. lastError is what distinguishes them after the fact, and it is the piece that was missing while diagnosing this: the evidence was in a log that rotates, not in the row.

Migration

20260818003000_notification_retry_bounds, hand-written because the pending index is partial and Prisma cannot express that. It folds next_attempt_at into the index and drops notifications_pending_idx rather than keeping two partial indexes over the same rows of a hot table.

Existing rows default to a due timestamp, so nothing is delayed by the deploy, and the three currently stuck rows retire on the first poll afterwards. No cleanup needed.

Verification

lint, typecheck, test and build pass locally. The migration SQL is exercised by CI, which runs prisma migrate deploy against its own Postgres 16 before the build.

Worth watching after deploy: giving up on notification <id> after 8 attempts should appear only for genuinely broken rows, and can't DM user ... (50278) should now say "dropping".

An applicant who leaves the guild makes their queued DM undeliverable
forever. Discord answers that with 50278 ("no mutual guilds"), but only
50007 was treated as permanent, so those rows were never retired: the
poller picked them up every 15 seconds and failed again, indefinitely.
Three users were in that state in production.

The retry noise is the smaller half. The poller selects `take: 25` ordered
by createdAt ascending, so permanently stuck rows are the oldest and hold
their slots for good. At 25 of them the outbox stops entirely, and not just
for DMs: review embeds and the guild activity log share the table.

Two changes, one for this bug and one for its class:

- apps/bot/src/delivery-policy.ts collects the permanent DM codes (now
  50007 and 50278), the attempt ceiling and the backoff, free of discord.js
  and Prisma so it can be tested. New permanent codes belong there.
- Notification gains attempts / lastError / nextAttemptAt, the same shape
  WebhookDelivery already uses. A failed row backs off exponentially and is
  retired after 8 attempts, roughly two hours, which outlasts an ordinary
  Discord incident. readAt keeps meaning "retired", covering delivered and
  never-deliverable alike; lastError is what tells them apart afterwards.

The poller's transient path now records why a row failed instead of only
logging it, which is the part that was missing when diagnosing this: the
evidence lived in a log that rotates, not in the row.

The migration folds next_attempt_at into the pending partial index and drops
its predecessor rather than keeping both over the same rows. Existing rows
default to a due timestamp, so they are picked up immediately after deploy,
and the three stuck ones retire on the first poll.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 3 advisory finding(s) below merit a look before merge.


Graphify review — findings

Adds a testable delivery-policy module (terminal DM codes, capped exponential backoff, attempt exhaustion) and wires it into deliverPendingNotifications, replacing the fixed CANNOT_DM = 50007 check with isTerminalDmCode that also covers 50278. Reworks the deliverOne/deliverReview/deliverLog return type from boolean to an Outcome that carries a retry reason, and has the poller persist attempts, lastError, and nextAttemptAt per failure, retiring rows once exhausted. Filters the pending query by nextAttemptAt <= now so permanently-undeliverable rows no longer starve newer notifications.

Worth a look

  • retryDelayMs returns NaN for NaN attempts despite non-NaN delay contractapps/bot/src/delivery-policy.ts:35 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
  • Delivered rows can be re-delivered because deliveredIds mark-read is separate from failure updatesapps/bot/src/notifications.ts:371 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
  • Retry attempt updates can be lost across concurrent pollersapps/bot/src/notifications.ts:389 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review Execution auto-disposal is off for this run; enable it (with sandbox isolation) to have Graphify try to confirm or refute this automatically.
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 21 functions depend on the 18 functions this change touches.

Health — this change adds coupling hotspots:

  • new: createClient() — 1 callers, 9 callees
  • new: deliverOne() — 1 callers, 7 callees
  • new: deliverReview() — 1 callers, 7 callees

Verification — 21 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 21 function(s) in the blast radius were not formally verified this run

· 2 grounded finding(s) anchored inline below; 1 more finding(s) on lines outside this diff (see the check run).

Comment thread apps/bot/src/notifications.ts
Comment thread apps/bot/src/notifications.ts
@Musiker15
Musiker15 merged commit 50cf972 into main Aug 17, 2026
6 checks passed
@Musiker15
Musiker15 deleted the fix/outbox-retry-bounds branch August 17, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant