Retry Resend calls on 429 and throttle Newsletter Archive lookups; v2.7.1
Publish Container / build (push) Successful in 15m57s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 09:14:49 -04:00
1 parent 66cdb9c4c9
commit dad0e5691e
4 files changed
+37 -17

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.7.0", "version": "2.7.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+17 -4
View File
@@ -84,6 +84,19 @@ export async function sendResendEmailWithRetry({ resend, payload, context, maxAt
throw lastError ?? new Error(`[${context}] email send failed`) throw lastError ?? new Error(`[${context}] email send failed`)
} }
// Retry a Resend SDK call when the account's rate limit (429) is hit. The SDK returns { error } rather than throwing.
export async function resendRetry(call, maxAttempts = 5) {
let result
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
result = await call()
const err = result?.error
const limited = err && (err.statusCode === 429 || err.name === 'rate_limit_exceeded')
if (!limited) return result
await new Promise(resolve => setTimeout(resolve, 1000 * 2 ** attempt))
}
return result
}
export async function syncContactToResend(name, email) { export async function syncContactToResend(name, email) {
if (!process.env.RESEND_API_KEY) return if (!process.env.RESEND_API_KEY) return
const { firstName, lastName } = splitName(name) const { firstName, lastName } = splitName(name)
@@ -91,19 +104,19 @@ export async function syncContactToResend(name, email) {
try { try {
// Never touch `unsubscribed` for a contact that already exists — otherwise a form submission // Never touch `unsubscribed` for a contact that already exists — otherwise a form submission
// would silently resubscribe someone who opted out in Resend. // would silently resubscribe someone who opted out in Resend.
const { data: existing } = await contactResend.contacts.get({ email }) const { data: existing } = await resendRetry(() => contactResend.contacts.get({ email }))
if (existing) { if (existing) {
const { error: updateError } = await contactResend.contacts.update({ email, firstName, lastName }) const { error: updateError } = await resendRetry(() => contactResend.contacts.update({ email, firstName, lastName }))
if (updateError) console.error('[resend] contact sync error:', updateError) if (updateError) console.error('[resend] contact sync error:', updateError)
return return
} }
const { error: createError } = await contactResend.contacts.create({ const { error: createError } = await resendRetry(() => contactResend.contacts.create({
email, email,
firstName, firstName,
lastName, lastName,
unsubscribed: false, unsubscribed: false,
...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}), ...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}),
}) }))
if (createError) console.error('[resend] contact sync error:', createError) if (createError) console.error('[resend] contact sync error:', createError)
} catch (err) { } catch (err) {
console.error('[resend] contact sync exception:', err) console.error('[resend] contact sync exception:', err)
+10 -9
View File
@@ -33,6 +33,7 @@ import {
buildContactAdminNotificationTemplate, buildContactAdminNotificationTemplate,
sendResendEmailWithRetry, sendResendEmailWithRetry,
syncContactToResend, syncContactToResend,
resendRetry,
} from '../email.js' } from '../email.js'
// RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">" // RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">"
@@ -446,7 +447,7 @@ export function register(app) {
const out = [] const out = []
let after let after
for (let page = 0; page < 50; page += 1) { for (let page = 0; page < 50; page += 1) {
const { data, error } = await resend.contacts.list({ limit: 100, ...(segmentId ? { segmentId } : {}), ...(after ? { after } : {}) }) const { data, error } = await resendRetry(() => resend.contacts.list({ limit: 100, ...(segmentId ? { segmentId } : {}), ...(after ? { after } : {}) }))
if (error) throw new Error(error.message || 'Resend contacts.list failed') if (error) throw new Error(error.message || 'Resend contacts.list failed')
const rows = data?.data ?? [] const rows = data?.data ?? []
out.push(...rows) out.push(...rows)
@@ -550,13 +551,13 @@ export function register(app) {
try { try {
if (action === 'add-to-resend') { if (action === 'add-to-resend') {
const { firstName, lastName } = splitName(target.name) const { firstName, lastName } = splitName(target.name)
const { error } = await resend.contacts.create({ const { error } = await resendRetry(() => resend.contacts.create({
email: target.email, firstName, lastName, unsubscribed: false, email: target.email, firstName, lastName, unsubscribed: false,
...(segmentId ? { segments: [{ id: segmentId }] } : {}), ...(segmentId ? { segments: [{ id: segmentId }] } : {}),
}) }))
if (error) throw new Error(error.message) if (error) throw new Error(error.message)
} else { } else {
const { error } = await resend.contacts.segments.add({ email: target.email, segmentId }) const { error } = await resendRetry(() => resend.contacts.segments.add({ email: target.email, segmentId }))
if (error) throw new Error(error.message) if (error) throw new Error(error.message)
} }
done += 1 done += 1
@@ -583,7 +584,7 @@ export function register(app) {
const templates = [] const templates = []
let after let after
for (let page = 0; page < 5; page += 1) { for (let page = 0; page < 5; page += 1) {
const { data, error } = await resend.templates.list({ limit: 100, ...(after ? { after } : {}) }) const { data, error } = await resendRetry(() => resend.templates.list({ limit: 100, ...(after ? { after } : {}) }))
if (error) throw new Error(error.message) if (error) throw new Error(error.message)
templates.push(...(data?.data ?? [])) templates.push(...(data?.data ?? []))
if (!data?.has_more || !data.data?.length) break if (!data?.has_more || !data.data?.length) break
@@ -606,7 +607,7 @@ export function register(app) {
} }
try { try {
const resend = new Resend(process.env.RESEND_API_KEY) const resend = new Resend(process.env.RESEND_API_KEY)
const { data, error } = await resend.templates.get(req.params.id) const { data, error } = await resendRetry(() => resend.templates.get(req.params.id))
if (error || !data) { if (error || !data) {
res.status(404).json({ message: error?.message || 'Template not found.' }); return res.status(404).json({ message: error?.message || 'Template not found.' }); return
} }
@@ -761,7 +762,7 @@ export function register(app) {
let topicId let topicId
if (tagNewEpisode === true) { if (tagNewEpisode === true) {
const { data: topics, error: topicsError } = await resend.topics.list() const { data: topics, error: topicsError } = await resendRetry(() => resend.topics.list())
if (topicsError) throw new Error(topicsError.message) if (topicsError) throw new Error(topicsError.message)
topicId = (topics?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === 'new episode')?.id topicId = (topics?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === 'new episode')?.id
if (!topicId) { if (!topicId) {
@@ -769,7 +770,7 @@ export function register(app) {
} }
} }
const { data, error } = await resend.broadcasts.create({ const { data, error } = await resendRetry(() => resend.broadcasts.create({
name: subject.trim().slice(0, 120), name: subject.trim().slice(0, 120),
segmentId, segmentId,
from: getResendFromAddress(), from: getResendFromAddress(),
@@ -780,7 +781,7 @@ export function register(app) {
...(topicId ? { topicId } : {}), ...(topicId ? { topicId } : {}),
send: true, send: true,
...(scheduled ? { scheduledAt: scheduled } : {}), ...(scheduled ? { scheduledAt: scheduled } : {}),
}) }))
if (error || !data?.id) throw new Error(error?.message || 'Resend did not return a broadcast id.') if (error || !data?.id) throw new Error(error?.message || 'Resend did not return a broadcast id.')
console.log(`[admin-broadcast] ${scheduled ? 'scheduled' : 'sent'} Resend broadcast ${data.id}: ${subject.trim()}`) console.log(`[admin-broadcast] ${scheduled ? 'scheduled' : 'sent'} Resend broadcast ${data.id}: ${subject.trim()}`)
+9 -3
View File
@@ -4,6 +4,7 @@ import { requireAdminAuth } from '../auth.js'
import { state } from '../state.js' import { state } from '../state.js'
import { queueNewsletterIssuesWrite } from '../data.js' import { queueNewsletterIssuesWrite } from '../data.js'
import { escapeHtml } from '../helpers.js' import { escapeHtml } from '../helpers.js'
import { resendRetry } from '../email.js'
const BASE = 'https://versebyversewithnate.us' const BASE = 'https://versebyversewithnate.us'
const NEW_EPISODE_TOPIC_NAME = 'new episode' const NEW_EPISODE_TOPIC_NAME = 'new episode'
@@ -117,8 +118,8 @@ export function register(app) {
try { try {
const resend = new Resend(process.env.RESEND_API_KEY) const resend = new Resend(process.env.RESEND_API_KEY)
const [{ data: topicsData, error: topicsError }, { data: listData, error: listError }] = await Promise.all([ const [{ data: topicsData, error: topicsError }, { data: listData, error: listError }] = await Promise.all([
resend.topics.list(), resendRetry(() => resend.topics.list()),
resend.broadcasts.list(), resendRetry(() => resend.broadcasts.list()),
]) ])
if (topicsError) throw new Error(topicsError.message) if (topicsError) throw new Error(topicsError.message)
if (listError) throw new Error(listError.message) if (listError) throw new Error(listError.message)
@@ -126,7 +127,12 @@ export function register(app) {
const newEpisodeTopic = (topicsData?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === NEW_EPISODE_TOPIC_NAME) const newEpisodeTopic = (topicsData?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === NEW_EPISODE_TOPIC_NAME)
const sentBroadcasts = (listData?.data ?? []).filter(b => b.status === 'sent') const sentBroadcasts = (listData?.data ?? []).filter(b => b.status === 'sent')
const details = await Promise.all(sentBroadcasts.map(b => resend.broadcasts.get(b.id))) // Fetch a few at a time so a long broadcast history can't exceed Resend's requests-per-second limit.
const details = []
for (let i = 0; i < sentBroadcasts.length; i += 4) {
const chunk = await Promise.all(sentBroadcasts.slice(i, i + 4).map(b => resendRetry(() => resend.broadcasts.get(b.id))))
details.push(...chunk)
}
const broadcasts = details.map(({ data }, i) => { const broadcasts = details.map(({ data }, i) => {
const summary = sentBroadcasts[i] const summary = sentBroadcasts[i]