Fix Discord pick notifications silently dropped during autodraft chains
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 1m29s
🚀 Deploy / ʦ TypeScript (pull_request) Successful in 1m20s
🚀 Deploy / 🔍 Lint (pull_request) Successful in 50s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-27 09:36:32 -07:00
parent 8a40953019
commit fa85786401
3 changed files with 94 additions and 53 deletions

View file

@ -10,6 +10,7 @@ import { getSeasonSportsSimple } from "./season-sport";
import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { sendOnTheClockEmail } from "~/services/draft-email.server"; import { sendOnTheClockEmail } from "~/services/draft-email.server";
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server"; import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
import { enqueuePickNotification } from "~/services/discord";
import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket"; import { getSocketIO, scheduleDraftRoomClosure } from "../../server/socket";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
@ -833,6 +834,7 @@ export async function executeAutoPick(params: {
if (!pickedSlot) { if (!pickedSlot) {
logger.warn(`[AutoPick] Skipping Discord pick announcement: no draft slot found for team ${teamId}`); logger.warn(`[AutoPick] Skipping Discord pick announcement: no draft slot found for team ${teamId}`);
} else { } else {
enqueuePickNotification(season.leagueId, () =>
notifyPickMadeOnDiscord({ notifyPickMadeOnDiscord({
seasonId, seasonId,
leagueId: season.leagueId, leagueId: season.leagueId,
@ -847,7 +849,8 @@ export async function executeAutoPick(params: {
totalTeams, totalTeams,
draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })), draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })),
db, db,
}).catch((err) => logger.error("[AutoPick] Discord pick announcement failed:", err)); }).catch((err) => logger.error("[AutoPick] Discord pick announcement failed:", err))
);
} }
// Check if next team has autodraft enabled and trigger immediately // Check if next team has autodraft enabled and trigger immediately

View file

@ -13,6 +13,7 @@ import { logger } from "~/lib/logger";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
import { sendOnTheClockEmail } from "~/services/draft-email.server"; import { sendOnTheClockEmail } from "~/services/draft-email.server";
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server"; import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
import { enqueuePickNotification } from "~/services/discord";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
@ -305,6 +306,7 @@ export async function action(args: ActionFunctionArgs) {
// Announce before triggering the chain so Discord messages arrive in pick-number // 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. // order. If the chain ran first, chained picks would announce before this one.
enqueuePickNotification(season.leagueId, () =>
notifyPickMadeOnDiscord({ notifyPickMadeOnDiscord({
seasonId, seasonId,
leagueId: season.leagueId, leagueId: season.leagueId,
@ -319,7 +321,8 @@ export async function action(args: ActionFunctionArgs) {
totalTeams, totalTeams,
draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })), draftSlots: draftSlots.map((s) => ({ teamId: s.teamId, draftOrder: s.draftOrder })),
db, db,
}).catch((err) => logger.error("Discord pick announcement failed:", err)); }).catch((err) => logger.error("Discord pick announcement failed:", err))
);
// Check if next team has autodraft enabled and trigger immediately // Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) { if (!isDraftComplete) {

View file

@ -14,38 +14,73 @@ interface DiscordWebhookPayload {
allowed_mentions?: { parse: string[]; users: string[] }; 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<string, Array<() => Promise<void>>>();
const drainingLeagues = new Set<string>();
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>): void {
let queue = leagueQueues.get(leagueId);
if (!queue) {
queue = [];
leagueQueues.set(leagueId, queue);
}
queue.push(fn);
drainLeagueQueue(leagueId).catch(() => {});
}
export async function sendDiscordWebhook( export async function sendDiscordWebhook(
webhookUrl: string, webhookUrl: string,
payload: DiscordWebhookPayload payload: DiscordWebhookPayload
): Promise<void> { ): Promise<void> {
const response = await fetch(webhookUrl, { const body = JSON.stringify(payload);
method: "POST", const headers = { "Content-Type": "application/json" };
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (response.status === 429) { for (let attempt = 0; attempt < 3; attempt++) {
// Cap at 10 s so awaited callers (e.g. settings actions) are never hung for const res = await fetch(webhookUrl, { method: "POST", headers, body });
// 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. if (res.status === 429) {
const retryAfterSeconds = Math.min(Number(response.headers.get("Retry-After")) || 1, 10); // Cap the total wait at 10 s so awaited callers (e.g. settings actions) are
await new Promise((r) => setTimeout(r, retryAfterSeconds * 1000)); // never hung for a full Discord global-rate-limit window. Use || 1 instead of
const retry = await fetch(webhookUrl, { // ?? 1 so NaN (non-numeric header) and 0 both fall back to a 1-second default.
method: "POST", // Multiply by (attempt + 1) for progressive backoff, then cap the product.
headers: { "Content-Type": "application/json" }, const wait = Math.min((Number(res.headers.get("Retry-After")) || 1) * (attempt + 1), 10);
body: JSON.stringify(payload), await new Promise((r) => setTimeout(r, wait * 1000));
}); continue;
if (!retry.ok) {
const text = await retry.text().catch(() => "");
throw new Error(`Discord webhook failed after retry: ${retry.status} ${text}`);
} }
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Discord webhook failed: ${res.status} ${text}`);
}
return; return;
} }
if (!response.ok) { throw new Error("Discord webhook failed after 3 retries (rate limited)");
const text = await response.text().catch(() => "");
throw new Error(`Discord webhook failed: ${response.status} ${text}`);
}
} }
/** Escape characters that trigger Discord markdown formatting. */ /** Escape characters that trigger Discord markdown formatting. */