const API = "https://www.prizerail.com/api/v1";
const BATCH = 1000;
// Nothing is sent this close to the cutoff, the request timeout, so no
// request can arrive after it and land in the next drawing.
const MARGIN_MS = 30_000;
type Entrant = { entrantId: string; entries?: number };
type Target = { id: string; cutoffAt: number };
const auth = () => ({ Authorization: `Bearer ${process.env.PRIZERAIL_API_KEY}` });
/** Seconds from a Retry-After header, or null if it isn't a number. */
function retryAfter(res: Response | null): number | null {
const seconds = Number(res?.headers.get("retry-after"));
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
}
/** The drawing taking entries now, and its cutoff. */
async function targetDrawing(url: string): Promise<Target> {
const res = await fetch(`${url}?limit=1`, {
headers: auth(),
signal: AbortSignal.timeout(30_000),
});
const body = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(`${res.status} ${body?.error ?? ""}: ${body?.detail ?? res.statusText}`);
}
return { id: body.drawing.id, cutoffAt: Date.parse(body.drawing.cutoffAt) };
}
async function postWithRetry(url: string, body: unknown, target: Target) {
for (let attempt = 0; ; attempt++) {
let res: Response | null = null;
try {
res = await fetch(url, {
method: "POST",
headers: { ...auth(), "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
} catch {
// A timeout or network error: retried below like a 5xx.
}
if (res?.ok) return res.json();
const retryable = !res || res.status === 429 || res.status >= 500;
if (!retryable || attempt === 6) {
const detail = res ? await res.text() : "network error";
throw new Error(`entries failed: ${res?.status ?? "-"} ${detail}`);
}
// Retry-After when given, otherwise about 2, 4, 8 … seconds, jittered.
const wait = retryAfter(res) ?? 2 ** (attempt + 1) * (0.5 + Math.random());
// A retry after the cutoff would go to the next drawing, and this
// request may already be stored. Stop rather than enter people twice.
if (Date.now() + wait * 1000 + MARGIN_MS >= target.cutoffAt) {
throw new Error(
`Drawing ${target.id}'s cutoff is too close to retry. Check which entries it has with GET /entries?drawing=${target.id}.`
);
}
await new Promise(r => setTimeout(r, wait * 1000));
// A timer can fire late, after a sleep or a busy loop, so check again.
if (Date.now() + MARGIN_MS >= target.cutoffAt) {
throw new Error(
`Drawing ${target.id}'s cutoff passed while waiting to retry. Check which entries it has with GET /entries?drawing=${target.id}.`
);
}
}
}
export async function sendEntries(entrants: Entrant[]) {
const url = `${API}/campaigns/${process.env.PRIZERAIL_CAMPAIGN}/entries`;
const target = await targetDrawing(url);
// The drawing's own totals, from the last response. Counting added and
// unchanged per batch would go wrong after a retry, since a batch stored
// before its answer was lost comes back as unchanged.
const totals = { drawingId: target.id, sent: 0, entrantCount: 0, totalEntries: 0 };
for (let i = 0; i < entrants.length; i += BATCH) {
if (Date.now() + MARGIN_MS >= target.cutoffAt) {
throw new Error(
`Drawing ${target.id}'s cutoff is too close: ${i} entrants sent, the rest not.`
);
}
const batch = entrants.slice(i, i + BATCH);
const res = await postWithRetry(url, { entrants: batch }, target);
// Every batch must land in the drawing read before the first one.
if (res.drawing.id !== target.id) {
throw new Error(
`The cutoff passed after ${i} entrants: the rest went to drawing ${res.drawing.id}, not ${target.id}.`
);
}
totals.sent += batch.length;
totals.entrantCount = res.entrantCount;
totals.totalEntries = res.totalEntries;
}
return totals;
}