From fa857864013c7fea7861c14f7e4e70ca5237d1e5 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Wed, 27 May 2026 09:36:32 -0700 Subject: [PATCH] Fix Discord pick notifications silently dropped during autodraft chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues identified by code review: 1. Per-league queue: replace single global queue with a Map keyed by leagueId so concurrent drafts in different leagues don't block each other. 2. Deadlock fix: wrap drainer body in try/finally so drainingLeagues is always cleaned up even if a task throws unexpectedly, preventing the queue from being permanently stuck. 3. Wait cap fix: move Math.min to wrap the full backoff expression (retryAfter * (attempt+1)) so the 10s ceiling applies to the product, not just the base — settings actions were previously blockable for up to 30s. Co-Authored-By: Claude Sonnet 4.6 --- app/models/draft-utils.ts | 33 +++++++------ app/routes/api/draft.make-pick.ts | 33 +++++++------ app/services/discord.ts | 81 ++++++++++++++++++++++--------- 3 files changed, 94 insertions(+), 53 deletions(-) diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index 9707204..415be11 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -10,6 +10,7 @@ import { getSeasonSportsSimple } from "./season-sport"; import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { sendOnTheClockEmail } from "~/services/draft-email.server"; import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server"; +import { enqueuePickNotification } from "~/services/discord"; import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket"; import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; @@ -833,21 +834,23 @@ export async function executeAutoPick(params: { if (!pickedSlot) { logger.warn(`[AutoPick] Skipping Discord pick announcement: no draft slot found for team ${teamId}`); } else { - notifyPickMadeOnDiscord({ - seasonId, - leagueId: season.leagueId, - pickedTeamName: pickedSlot.team.name, - participantName: participantToPick.name, - sportName: participantToPick.sportsSeason.sport.name, - pickNumber, - nextPickNumber, - round: currentRound, - rawPickInRound, - isDraftComplete, - totalTeams, - draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })), - db, - }).catch((err) => logger.error("[AutoPick] Discord pick announcement failed:", err)); + enqueuePickNotification(season.leagueId, () => + notifyPickMadeOnDiscord({ + seasonId, + leagueId: season.leagueId, + pickedTeamName: pickedSlot.team.name, + participantName: participantToPick.name, + sportName: participantToPick.sportsSeason.sport.name, + pickNumber, + nextPickNumber, + round: currentRound, + rawPickInRound, + isDraftComplete, + totalTeams, + draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })), + db, + }).catch((err) => logger.error("[AutoPick] Discord pick announcement failed:", err)) + ); } // Check if next team has autodraft enabled and trigger immediately diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index 813ce90..168ff5e 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -13,6 +13,7 @@ import { logger } from "~/lib/logger"; import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; import { sendOnTheClockEmail } from "~/services/draft-email.server"; import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server"; +import { enqueuePickNotification } from "~/services/discord"; import type { ActionFunctionArgs } from "react-router"; export async function action(args: ActionFunctionArgs) { @@ -305,21 +306,23 @@ export async function action(args: ActionFunctionArgs) { // Announce before triggering the chain so Discord messages arrive in pick-number // order. If the chain ran first, chained picks would announce before this one. - notifyPickMadeOnDiscord({ - seasonId, - leagueId: season.leagueId, - pickedTeamName: currentDraftSlot.team.name, - participantName: participant.name, - sportName: participant.sportsSeason.sport.name, - pickNumber: currentPickNumber, - nextPickNumber, - round: currentRound, - rawPickInRound, - isDraftComplete, - totalTeams, - draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })), - db, - }).catch((err) => logger.error("Discord pick announcement failed:", err)); + enqueuePickNotification(season.leagueId, () => + notifyPickMadeOnDiscord({ + seasonId, + leagueId: season.leagueId, + pickedTeamName: currentDraftSlot.team.name, + participantName: participant.name, + sportName: participant.sportsSeason.sport.name, + pickNumber: currentPickNumber, + nextPickNumber, + round: currentRound, + rawPickInRound, + isDraftComplete, + totalTeams, + draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })), + db, + }).catch((err) => logger.error("Discord pick announcement failed:", err)) + ); // Check if next team has autodraft enabled and trigger immediately if (!isDraftComplete) { diff --git a/app/services/discord.ts b/app/services/discord.ts index 2ad9c28..ff8ed9c 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -14,38 +14,73 @@ interface DiscordWebhookPayload { allowed_mentions?: { parse: string[]; users: string[] }; } +// Per-league serial queue: serializes pick notifications within a league to +// respect Discord's per-webhook rate limit (~5 req/2s) during autodraft chains. +// Keyed by leagueId so concurrent drafts in different leagues don't block each other. +const leagueQueues = new Map Promise>>(); +const drainingLeagues = new Set(); + +async function drainLeagueQueue(leagueId: string) { + if (drainingLeagues.has(leagueId)) return; + drainingLeagues.add(leagueId); + try { + for (;;) { + const queue = leagueQueues.get(leagueId); + if (!queue || queue.length === 0) break; + const task = queue.shift(); + if (!task) break; + try { + await task(); + } catch { + // task already .catch()es its own errors; this is a safety net so one + // bad task doesn't prevent the rest of the queue from draining + } + } + } finally { + drainingLeagues.delete(leagueId); + leagueQueues.delete(leagueId); + } +} + +export function enqueuePickNotification(leagueId: string, fn: () => Promise): void { + let queue = leagueQueues.get(leagueId); + if (!queue) { + queue = []; + leagueQueues.set(leagueId, queue); + } + queue.push(fn); + drainLeagueQueue(leagueId).catch(() => {}); +} + export async function sendDiscordWebhook( webhookUrl: string, payload: DiscordWebhookPayload ): Promise { - const response = await fetch(webhookUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); + const body = JSON.stringify(payload); + const headers = { "Content-Type": "application/json" }; - if (response.status === 429) { - // Cap at 10 s so awaited callers (e.g. settings actions) are never hung for - // a full Discord global-rate-limit window. Use || 1 instead of ?? 1 so that - // NaN (non-numeric header) and 0 both fall back to a 1-second default. - const retryAfterSeconds = Math.min(Number(response.headers.get("Retry-After")) || 1, 10); - await new Promise((r) => setTimeout(r, retryAfterSeconds * 1000)); - const retry = await fetch(webhookUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!retry.ok) { - const text = await retry.text().catch(() => ""); - throw new Error(`Discord webhook failed after retry: ${retry.status} ${text}`); + for (let attempt = 0; attempt < 3; attempt++) { + const res = await fetch(webhookUrl, { method: "POST", headers, body }); + + if (res.status === 429) { + // Cap the total wait at 10 s so awaited callers (e.g. settings actions) are + // never hung for a full Discord global-rate-limit window. Use || 1 instead of + // ?? 1 so NaN (non-numeric header) and 0 both fall back to a 1-second default. + // Multiply by (attempt + 1) for progressive backoff, then cap the product. + const wait = Math.min((Number(res.headers.get("Retry-After")) || 1) * (attempt + 1), 10); + await new Promise((r) => setTimeout(r, wait * 1000)); + continue; } + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Discord webhook failed: ${res.status} ${text}`); + } + return; } - if (!response.ok) { - const text = await response.text().catch(() => ""); - throw new Error(`Discord webhook failed: ${response.status} ${text}`); - } + throw new Error("Discord webhook failed after 3 retries (rate limited)"); } /** Escape characters that trigger Discord markdown formatting. */ -- 2.45.3