Retry Resend calls on 429 and throttle Newsletter Archive lookups; v2.7.1
Publish Container / build (push) Successful in 15m57s
Publish Container / build (push) Successful in 15m57s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
66cdb9c4c9
commit
dad0e5691e
4 files changed
+37
-17
No files matched your search
+17
-4
@@ -84,6 +84,19 @@ export async function sendResendEmailWithRetry({ resend, payload, context, maxAt
|
||||
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) {
|
||||
if (!process.env.RESEND_API_KEY) return
|
||||
const { firstName, lastName } = splitName(name)
|
||||
@@ -91,19 +104,19 @@ export async function syncContactToResend(name, email) {
|
||||
try {
|
||||
// Never touch `unsubscribed` for a contact that already exists — otherwise a form submission
|
||||
// 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) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
const { error: createError } = await contactResend.contacts.create({
|
||||
const { error: createError } = await resendRetry(() => contactResend.contacts.create({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
unsubscribed: false,
|
||||
...(process.env.RESEND_SEGMENT_ID ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } : {}),
|
||||
})
|
||||
}))
|
||||
if (createError) console.error('[resend] contact sync error:', createError)
|
||||
} catch (err) {
|
||||
console.error('[resend] contact sync exception:', err)
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
buildContactAdminNotificationTemplate,
|
||||
sendResendEmailWithRetry,
|
||||
syncContactToResend,
|
||||
resendRetry,
|
||||
} from '../email.js'
|
||||
|
||||
// RFC 5322 msg-id: "<" printable-ASCII-no-whitespace ">"
|
||||
@@ -446,7 +447,7 @@ export function register(app) {
|
||||
const out = []
|
||||
let after
|
||||
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')
|
||||
const rows = data?.data ?? []
|
||||
out.push(...rows)
|
||||
@@ -550,13 +551,13 @@ export function register(app) {
|
||||
try {
|
||||
if (action === 'add-to-resend') {
|
||||
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,
|
||||
...(segmentId ? { segments: [{ id: segmentId }] } : {}),
|
||||
})
|
||||
}))
|
||||
if (error) throw new Error(error.message)
|
||||
} 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)
|
||||
}
|
||||
done += 1
|
||||
@@ -583,7 +584,7 @@ export function register(app) {
|
||||
const templates = []
|
||||
let after
|
||||
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)
|
||||
templates.push(...(data?.data ?? []))
|
||||
if (!data?.has_more || !data.data?.length) break
|
||||
@@ -606,7 +607,7 @@ export function register(app) {
|
||||
}
|
||||
try {
|
||||
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) {
|
||||
res.status(404).json({ message: error?.message || 'Template not found.' }); return
|
||||
}
|
||||
@@ -761,7 +762,7 @@ export function register(app) {
|
||||
|
||||
let topicId
|
||||
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)
|
||||
topicId = (topics?.data ?? []).find(t => String(t.name || '').trim().toLowerCase() === 'new episode')?.id
|
||||
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),
|
||||
segmentId,
|
||||
from: getResendFromAddress(),
|
||||
@@ -780,7 +781,7 @@ export function register(app) {
|
||||
...(topicId ? { topicId } : {}),
|
||||
send: true,
|
||||
...(scheduled ? { scheduledAt: scheduled } : {}),
|
||||
})
|
||||
}))
|
||||
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()}`)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { requireAdminAuth } from '../auth.js'
|
||||
import { state } from '../state.js'
|
||||
import { queueNewsletterIssuesWrite } from '../data.js'
|
||||
import { escapeHtml } from '../helpers.js'
|
||||
import { resendRetry } from '../email.js'
|
||||
|
||||
const BASE = 'https://versebyversewithnate.us'
|
||||
const NEW_EPISODE_TOPIC_NAME = 'new episode'
|
||||
@@ -117,8 +118,8 @@ export function register(app) {
|
||||
try {
|
||||
const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
const [{ data: topicsData, error: topicsError }, { data: listData, error: listError }] = await Promise.all([
|
||||
resend.topics.list(),
|
||||
resend.broadcasts.list(),
|
||||
resendRetry(() => resend.topics.list()),
|
||||
resendRetry(() => resend.broadcasts.list()),
|
||||
])
|
||||
if (topicsError) throw new Error(topicsError.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 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 summary = sentBroadcasts[i]
|
||||
|
||||
Reference in new issue
Block a user