Retry mailer jobs on common networking and SMTP errors

This concern is lifted nearly verbatim from Basecamp.

ref: https://app.fizzy.do/5986089/cards/3300
This commit is contained in:
Mike Dalessio
2025-12-04 10:07:20 -05:00
parent 2fcdd921fc
commit c4a8996562
4 changed files with 100 additions and 0 deletions
@@ -0,0 +1,36 @@
module SmtpDeliveryErrorHandling
extend ActiveSupport::Concern
included do
# Retry delivery to possibly-unavailable remote mailservers.
retry_on Net::OpenTimeout, Net::ReadTimeout, Socket::ResolutionError, wait: :polynomially_longer
# Net::SMTPServerBusy is SMTP error code 4xx, a temporary error.
# Common one we've seen is 452 4.3.1 Insufficient system storage.
# Patiently retry.
retry_on Net::SMTPServerBusy, wait: :polynomially_longer
# SMTP error 50x.
rescue_from Net::SMTPSyntaxError do |error|
case error.message
when /\A501 5\.1\.3/
# Ignore undeliverable email addresses.
Sentry.capture_exception error, level: :info if Fizzy.saas?
else
raise
end
end
# SMTP error 5xx except 50x and 53x.
# * 550 5.1.1: Unknown users
# * 552 5.6.0: Message/headers too large
rescue_from Net::SMTPFatalError do |error|
case error.message
when /\A550 5\.1\.1/, /\A552 5\.6\.0/, /\A555 5\.5\.4/
Sentry.capture_exception error, level: :info if Fizzy.saas?
else
raise
end
end
end
end
@@ -1,4 +1,6 @@
class Notification::Bundle::DeliverJob < ApplicationJob class Notification::Bundle::DeliverJob < ApplicationJob
include SmtpDeliveryErrorHandling
queue_as :backend queue_as :backend
def perform(bundle) def perform(bundle)
@@ -0,0 +1,3 @@
Rails.application.config.to_prepare do
ActionMailer::MailDeliveryJob.include SmtpDeliveryErrorHandling
end
+59
View File
@@ -0,0 +1,59 @@
require "test_helper"
class SmtpDeliveryErrorTest < ActionMailer::TestCase
class TestMailer < ApplicationMailer
def smtp_syntax_error(message)
raise Net::SMTPSyntaxError, Net::SMTP::Response.parse(message)
end
def smtp_fatal_error(message)
raise Net::SMTPFatalError, Net::SMTP::Response.parse(message)
end
def ephemeral_retry
self.class.goes_boom_once
end
def self.goes_boom_once
# Stubbed in test to raise exception once
end
end
tests TestMailer
test "deliver_later ignores bad recipient addresses" do
assert_nothing_raised do
perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do
TestMailer.smtp_syntax_error("501 5.1.3 Bad recipient address syntax\n").deliver_later
end
end
end
test "deliver_later ignores rejected recipient addresses" do
assert_nothing_raised do
perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do
TestMailer.smtp_fatal_error("550 5.1.1 fooaddress: Recipient address rejected: User unknown in local recipient table\n").deliver_later
end
end
end
test "deliver_later re-raises other SMTP syntax errors" do
perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do
assert_raises Net::SMTPSyntaxError do
TestMailer.smtp_syntax_error("not a recipient address error").deliver_later
end
end
end
[ Net::OpenTimeout, Net::ReadTimeout, Net::SMTPServerBusy.new(Net::SMTP::Response.parse("4xx Server Busy")) ].each do |exception|
test "deliver_later retries temporary #{exception}" do
TestMailer.stubs(:goes_boom_once).raises(exception).then.returns(nil)
perform_enqueued_jobs only: ActionMailer::MailDeliveryJob do
assert_nothing_raised do
TestMailer.ephemeral_retry.deliver_later
end
end
end
end
end