Fix Discord pick announcements arriving out of order and add 429 retry
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 1m30s
🚀 Deploy / ʦ TypeScript (pull_request) Successful in 1m16s
🚀 Deploy / 🔍 Lint (pull_request) Successful in 48s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

- Move notifyPickMadeOnDiscord() before checkAndTriggerNextAutodraft() in
  both executeAutoPick() and the manual pick action. Previously the
  triggering pick's announcement fired after all chained autodraft picks
  had already announced, causing Discord to show e.g. pick #106 before
  #105 before #104. Now each pick announces itself before the next pick
  in the chain is triggered.

- Add a single retry with Retry-After delay in sendDiscordWebhook() on
  HTTP 429. A burst of consecutive autodraft picks could hit Discord's
  per-webhook rate limit and silently drop announcements. Use || 1
  (not ?? 1) to handle non-numeric/NaN headers, and cap at 10 s so
  that awaited callers such as the draft-order settings actions are
  never hung for a full global rate-limit window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-24 21:29:02 -07:00
parent 425b4724d2
commit 77f520823c
3 changed files with 55 additions and 33 deletions

View file

@ -827,23 +827,8 @@ export async function executeAutoPick(params: {
logger.error("[AutoPick] Error updating Brackt EVs after pick:", error);
}
// Check if next team has autodraft enabled and trigger immediately
// Only run the chain from the top-level call to prevent recursion
if (!isDraftComplete && params.chainEnabled !== false) {
await checkAndTriggerNextAutodraft({
seasonId,
nextPickNumber,
totalTeams,
draftSlots,
db,
});
// Fire-and-forget: sendOnTheClockEmail fetches the current pick number from
// the DB itself, so it always sees the post-chain state without blocking here.
sendOnTheClockEmail({ seasonId, totalTeams, draftSlots, db })
.catch((err) => logger.error("[AutoPick] On-the-clock email failed:", err));
}
// 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.
const pickedSlot = draftSlots.find((s) => s.teamId === teamId);
if (!pickedSlot) {
logger.warn(`[AutoPick] Skipping Discord pick announcement: no draft slot found for team ${teamId}`);
@ -865,6 +850,23 @@ export async function executeAutoPick(params: {
}).catch((err) => logger.error("[AutoPick] Discord pick announcement failed:", err));
}
// Check if next team has autodraft enabled and trigger immediately
// Only run the chain from the top-level call to prevent recursion
if (!isDraftComplete && params.chainEnabled !== false) {
await checkAndTriggerNextAutodraft({
seasonId,
nextPickNumber,
totalTeams,
draftSlots,
db,
});
// Fire-and-forget: sendOnTheClockEmail fetches the current pick number from
// the DB itself, so it always sees the post-chain state without blocking here.
sendOnTheClockEmail({ seasonId, totalTeams, draftSlots, db })
.catch((err) => logger.error("[AutoPick] On-the-clock email failed:", err));
}
return {
success: true,
pick: draftPick,

View file

@ -303,6 +303,24 @@ export async function action(args: ActionFunctionArgs) {
logger.error("Socket.IO error:", error);
}
// 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));
// Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) {
try {
@ -331,22 +349,6 @@ export async function action(args: ActionFunctionArgs) {
.catch((err) => logger.error("On-the-clock email failed:", err));
}
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));
return Response.json({
success: true,
pick: draftPick,

View file

@ -24,6 +24,24 @@ export async function sendDiscordWebhook(
body: JSON.stringify(payload),
});
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}`);
}
return;
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Discord webhook failed: ${response.status} ${text}`);