fix(whatsapp): run webhook setup off the request thread to avoid inbox creation timeouts

This commit is contained in:
Tanmay Deep Sharma
2026-07-21 18:44:48 +05:30
parent ed30ff9c22
commit c349785bb9
5 changed files with 50 additions and 51 deletions
@@ -0,0 +1,11 @@
class Channels::Whatsapp::WebhookSetupJob < ApplicationJob
queue_as :low
# Runs Meta's phone-registration and webhook-subscription calls off the request
# thread. Inline, these Graph API calls can exceed the 15s Rack::Timeout and, since
# RequestTimeoutException bypasses setup_webhooks' rescue, abort inbox creation and
# roll it back — leaving the number connected on Meta but no inbox in Chatwoot.
def perform(whatsapp_channel)
whatsapp_channel.setup_webhooks
end
end
+8 -1
View File
@@ -35,7 +35,7 @@ class Channel::Whatsapp < ApplicationRecord
after_create :sync_templates
after_update_commit :log_credentials_transfer, if: :saved_change_to_provider_config?
before_destroy :teardown_webhooks
after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
after_commit :enqueue_webhook_setup, on: :create, if: :should_auto_setup_webhooks?
def name
'Whatsapp'
@@ -120,6 +120,8 @@ class Channel::Whatsapp < ApplicationRecord
delegate :media_url, to: :provider_service
delegate :api_headers, to: :provider_service
# Runs synchronously inside Channels::Whatsapp::WebhookSetupJob; the Meta Graph
# calls are kept off the request thread so a slow response can't trip Rack::Timeout.
def setup_webhooks
perform_webhook_setup
rescue StandardError => e
@@ -127,6 +129,11 @@ class Channel::Whatsapp < ApplicationRecord
prompt_reauthorization!
end
# Enqueue on the same channel record so GlobalID resolves it in the job.
def enqueue_webhook_setup
Channels::Whatsapp::WebhookSetupJob.perform_later(self)
end
private
def ensure_webhook_verify_token
@@ -15,11 +15,12 @@ class Whatsapp::EmbeddedSignupService
phone_info = fetch_phone_info(access_token)
channel = create_or_reauthorize_channel(access_token, phone_info)
# NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because:
# Enqueue webhook setup explicitly instead of relying on the after_commit callback because:
# 1. Reauthorization flow updates an existing channel (not a create), so after_commit on: :create won't trigger
# 2. We need to run check_channel_health_and_prompt_reauth after webhook setup completes
# 3. The channel is marked with source: 'embedded_signup' to skip the after_commit callback
channel.setup_webhooks
# 2. The channel is marked with source: 'embedded_signup' to skip the after_commit callback
# Run it in a job so Meta's slow phone-registration/subscription calls can't trip Rack::Timeout
# and roll back the just-created channel.
Channels::Whatsapp::WebhookSetupJob.perform_later(channel)
# Skip health check during reauthorization — phone numbers in pending provisioning state
# (platform_type: NOT_APPLICABLE) would incorrectly trigger a disconnect email right after
# a successful reauth. Only run health check for new channel creation.
+14 -15
View File
@@ -124,26 +124,25 @@ RSpec.describe Channel::Whatsapp do
end
context 'when channel is created through manual setup' do
it 'setups webhooks via after_commit callback' do
expect(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
expect(webhook_service).to receive(:perform)
it 'enqueues webhook setup via after_commit callback' do
# Explicitly set source to nil to test manual setup behavior (not embedded_signup)
create(:channel_whatsapp,
account: account,
provider: 'whatsapp_cloud',
provider_config: {
'business_account_id' => 'test_waba_id',
'api_key' => 'test_access_token',
'source' => nil
},
validate_provider_config: false,
sync_templates: false)
expect do
create(:channel_whatsapp,
account: account,
provider: 'whatsapp_cloud',
provider_config: {
'business_account_id' => 'test_waba_id',
'api_key' => 'test_access_token',
'source' => nil
},
validate_provider_config: false,
sync_templates: false)
end.to have_enqueued_job(Channels::Whatsapp::WebhookSetupJob)
end
end
context 'when channel is created with different provider' do
it 'does not setup webhooks for 360dialog provider' do
it 'does not enqueue webhook setup for 360dialog provider' do
expect(Whatsapp::WebhookSetupService).not_to receive(:new)
create(:channel_whatsapp,
@@ -20,7 +20,10 @@ describe Whatsapp::EmbeddedSignupService do
business_name: 'Test Business'
}
end
let(:channel) { instance_double(Channel::Whatsapp) }
let(:channel) do
create(:channel_whatsapp, account: account, phone_number: '+15550000001',
validate_provider_config: false, sync_templates: false)
end
describe '#perform' do
before do
@@ -42,9 +45,6 @@ describe Whatsapp::EmbeddedSignupService do
.and_return(channel_creation)
allow(channel_creation).to receive(:perform).and_return(channel)
allow(channel).to receive(:setup_webhooks)
allow(channel).to receive(:phone_number).and_return('+1234567890')
health_service = instance_double(Whatsapp::HealthService)
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
allow(health_service).to receive(:fetch_health_status).and_return({
@@ -54,11 +54,8 @@ describe Whatsapp::EmbeddedSignupService do
})
end
it 'creates channel and sets up webhooks' do
expect(channel).to receive(:setup_webhooks)
result = service.perform
expect(result).to eq(channel)
it 'creates channel and enqueues webhook setup' do
expect { service.perform }.to have_enqueued_job(Channels::Whatsapp::WebhookSetupJob).with(channel)
end
it 'checks health status after channel creation' do
@@ -121,27 +118,15 @@ describe Whatsapp::EmbeddedSignupService do
expect { service.perform }.to raise_error('Token error')
end
it 'prompts reauthorization when webhook setup fails' do
# Create a real channel to test the actual webhook failure behavior
# Webhook setup now runs in Channels::Whatsapp::WebhookSetupJob; the channel's own
# setup_webhooks rescue marks it for reauthorization when the Meta calls fail.
it 'marks the channel for reauthorization when webhook setup fails' do
real_channel = create(:channel_whatsapp, account: account, phone_number: '+1234567890',
validate_provider_config: false, sync_templates: false)
# Mock the channel creation to return our real channel
channel_creation = instance_double(Whatsapp::ChannelCreationService)
allow(Whatsapp::ChannelCreationService).to receive(:new).and_return(channel_creation)
allow(channel_creation).to receive(:perform).and_return(real_channel)
# Mock webhook setup to fail
allow(real_channel).to receive(:perform_webhook_setup).and_raise('Webhook setup error')
# Verify channel is not marked for reauthorization initially
expect(real_channel.reauthorization_required?).to be false
# The service completes successfully even if webhook fails (webhook error is rescued in setup_webhooks)
result = service.perform
expect(result).to eq(real_channel)
# Verify the channel is now marked for reauthorization
real_channel.setup_webhooks
expect(real_channel.reauthorization_required?).to be true
end
end
@@ -162,8 +147,6 @@ describe Whatsapp::EmbeddedSignupService do
).and_return(reauth_service)
allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel)
allow(channel).to receive(:phone_number).and_return('+1234567890')
health_service = instance_double(Whatsapp::HealthService)
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
allow(health_service).to receive(:fetch_health_status).and_return({
@@ -173,12 +156,10 @@ describe Whatsapp::EmbeddedSignupService do
})
end
it 'uses ReauthorizationService and sets up webhooks' do
it 'uses ReauthorizationService and enqueues webhook setup' do
expect(reauth_service).to receive(:perform)
expect(channel).to receive(:setup_webhooks)
result = service_with_inbox.perform
expect(result).to eq(channel)
expect { service_with_inbox.perform }.to have_enqueued_job(Channels::Whatsapp::WebhookSetupJob).with(channel)
end
context 'with real channel requiring reauthorization' do