Retrying a hard bounce five times does not deliver the email — it generates five additional bounce events against your sending domain. Bounce rate is the primary metric email providers use to classify senders as spam sources. A sustained bounce rate above 2% triggers deliverability penalties from Gmail and Yahoo that affect all emails from the domain, including transactional messages to valid recipients. CWE-390 covers detection of errors without action; re-throwing permanent failures into the retry pipeline is the application-layer pattern that drives deliverability degradation.
Medium because repeated retries on permanent failures degrade sender reputation across all recipients rather than causing immediate security or data integrity failures.
Acknowledge permanent failures immediately in workers/email.worker.ts without re-throwing into the retry pipeline:
try {
await esp.send(job.data)
} catch (err: unknown) {
const kind = classifyEspError(err)
if (kind === 'permanent') {
await db.recipient.update({
where: { email: job.data.to },
data: { deliverabilityStatus: 'undeliverable', reason: String(err) }
})
logger.warn({ to: job.data.to }, 'Permanent failure — not retrying')
return // Return without throwing — BullMQ marks job complete
}
throw err // Re-throw transient errors for BullMQ backoff
}
Distinguish at minimum: 400 (invalid address), 403 (blocked), and 422 (unsubscribed) as permanent failure codes that must not enter the retry path.
ID: sending-pipeline-infrastructure.retry-error-handling.no-retry-permanent-failures
Severity: medium
What to look for: Check whether the retry logic distinguishes between permanent and transient failures. Permanent failures — invalid email address, domain not found, recipient has unsubscribed, sending domain blocked — should not be retried. Retrying them wastes send quota, contributes to bounce rate, and can damage sender reputation. Look for all errors being re-thrown unconditionally into the retry mechanism, or catch blocks that do not check error type before re-throwing.
Pass criteria: When the ESP returns an error indicating a permanent failure (4xx: invalid address, hard bounce, unsubscribed), the job is acknowledged (not retried) and the recipient is flagged as undeliverable in the application database. Count the number of permanent error codes explicitly handled — at least 3 must be distinguished (e.g., 400 invalid, 401 unauthorized, 403 blocked). Must not pass when all error types are re-thrown into the retry pipeline without classification.
Fail criteria: All errors, including permanent ones, are re-thrown and retried up to the maximum attempt count. An invalid email address causes 5 retry attempts, burning quota and generating 5 bounce events.
Skip (N/A) when: The application delegates all bounce classification to the ESP — confirmed by explicit delegation in the worker code and webhook-based status updates.
Detail on fail: "All ESP errors re-thrown into BullMQ retry pipeline regardless of type — hard bounce errors are retried 5 times before being discarded" or "4xx permanent failures trigger same retry path as 5xx transient errors"
Remediation: Acknowledge permanent failures immediately without retry:
async function processEmailJob(job: Job<EmailJobData>) {
try {
await esp.send(job.data)
} catch (err: unknown) {
const kind = classifyEspError(err)
if (kind === 'permanent') {
// Mark recipient as permanently undeliverable
await db.recipient.update({
where: { email: job.data.to },
data: { deliverabilityStatus: 'undeliverable', undeliverableReason: String(err) }
})
// Return without throwing — BullMQ will mark the job as complete
logger.warn({ to: job.data.to }, 'Permanent delivery failure — not retrying')
return
}
// Re-throw for transient errors — BullMQ will retry with backoff
throw err
}
}