fix(whatsapp): run provisioning health check after async webhook setup and recover from enqueue failures

This commit is contained in:
Tanmay Deep Sharma
2026-07-22 10:01:01 +05:30
parent c349785bb9
commit 7b732e6a16
5 changed files with 99 additions and 84 deletions
@@ -5,7 +5,10 @@ class Channels::Whatsapp::WebhookSetupJob < ApplicationJob
# 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)
def perform(whatsapp_channel, run_health_check: false)
whatsapp_channel.setup_webhooks
# Health check runs only after registration so a freshly provisioned number
# isn't flagged as pending before setup_webhooks has a chance to register it.
whatsapp_channel.check_provisioning_health if run_health_check
end
end
+30 -3
View File
@@ -129,13 +129,40 @@ 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)
# Enqueue on the same channel record so GlobalID resolves it in the job. If the queue
# is unavailable, fall back to prompting reauthorization so the inbox has a visible
# recovery path instead of silently committing without its webhook registered.
def enqueue_webhook_setup(run_health_check: false)
Channels::Whatsapp::WebhookSetupJob.perform_later(self, run_health_check: run_health_check)
rescue StandardError => e
Rails.logger.error "[WHATSAPP] Failed to enqueue webhook setup: #{e.message}"
prompt_reauthorization!
end
# Runs after webhook registration (inside WebhookSetupJob) so it observes the
# post-registration provisioning state; prompts reauthorization if Meta still reports
# the number as not provisioned. Only used for new embedded-signup channels — running
# it before registration would spuriously flag freshly created numbers as pending.
def check_provisioning_health
health_data = Whatsapp::HealthService.new(self).fetch_health_status
return unless health_data
if provisioning_pending?(health_data)
prompt_reauthorization!
else
Rails.logger.info "[WHATSAPP] Channel #{phone_number} health check passed"
end
rescue StandardError => e
Rails.logger.error "[WHATSAPP] Health check failed for channel #{phone_number}: #{e.message}"
end
private
def provisioning_pending?(health_data)
health_data[:platform_type] == 'NOT_APPLICABLE' ||
health_data.dig(:throughput, 'level') == 'NOT_APPLICABLE'
end
def ensure_webhook_verify_token
provider_config['webhook_verify_token'] ||= SecureRandom.hex(16) if provider == 'whatsapp_cloud'
end
@@ -18,13 +18,11 @@ class Whatsapp::EmbeddedSignupService
# 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. 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.
check_channel_health_and_prompt_reauth(channel) if @inbox_id.blank?
# The job runs Meta's slow phone-registration/subscription calls off the request thread so they
# can't trip Rack::Timeout and roll back the just-created channel. The provisioning health check
# runs inside the job, after registration — and only for new channels, since a reauthorized
# number in a pending state would otherwise trigger a spurious disconnect right after reauth.
channel.enqueue_webhook_setup(run_health_check: @inbox_id.blank?)
channel
rescue StandardError => e
@@ -56,24 +54,6 @@ class Whatsapp::EmbeddedSignupService
end
end
def check_channel_health_and_prompt_reauth(channel)
health_data = Whatsapp::HealthService.new(channel).fetch_health_status
return unless health_data
if channel_in_pending_state?(health_data)
channel.prompt_reauthorization!
else
Rails.logger.info "[WHATSAPP] Channel #{channel.phone_number} health check passed"
end
rescue StandardError => e
Rails.logger.error "[WHATSAPP] Health check failed for channel #{channel.phone_number}: #{e.message}"
end
def channel_in_pending_state?(health_data)
health_data[:platform_type] == 'NOT_APPLICABLE' ||
health_data.dig(:throughput, 'level') == 'NOT_APPLICABLE'
end
def validate_parameters!
missing_params = []
missing_params << 'code' if @code.blank?
+54
View File
@@ -158,6 +158,60 @@ RSpec.describe Channel::Whatsapp do
end
end
describe '#enqueue_webhook_setup' do
let(:channel) do
create(:channel_whatsapp, account: create(:account),
validate_provider_config: false, sync_templates: false)
end
it 'enqueues the setup job with the health-check flag' do
expect { channel.enqueue_webhook_setup(run_health_check: true) }
.to have_enqueued_job(Channels::Whatsapp::WebhookSetupJob).with(channel, run_health_check: true)
end
it 'prompts reauthorization when the queue is unavailable' do
allow(Channels::Whatsapp::WebhookSetupJob).to receive(:perform_later).and_raise(StandardError, 'redis down')
expect(channel.reauthorization_required?).to be false
channel.enqueue_webhook_setup
expect(channel.reauthorization_required?).to be true
end
end
describe '#check_provisioning_health' do
let(:channel) do
create(:channel_whatsapp, account: create(:account),
validate_provider_config: false, sync_templates: false)
end
let(:health_service) { instance_double(Whatsapp::HealthService) }
before { allow(Whatsapp::HealthService).to receive(:new).with(channel).and_return(health_service) }
it 'prompts reauthorization when platform_type is NOT_APPLICABLE' do
allow(health_service).to receive(:fetch_health_status)
.and_return(platform_type: 'NOT_APPLICABLE', throughput: { 'level' => 'STANDARD' })
channel.check_provisioning_health
expect(channel.reauthorization_required?).to be true
end
it 'prompts reauthorization when throughput level is NOT_APPLICABLE' do
allow(health_service).to receive(:fetch_health_status)
.and_return(platform_type: 'CLOUD_API', throughput: { 'level' => 'NOT_APPLICABLE' })
channel.check_provisioning_health
expect(channel.reauthorization_required?).to be true
end
it 'does not prompt reauthorization for a healthy number' do
allow(health_service).to receive(:fetch_health_status)
.and_return(platform_type: 'CLOUD_API', throughput: { 'level' => 'STANDARD' })
channel.check_provisioning_health
expect(channel.reauthorization_required?).to be false
end
end
describe '#teardown_webhooks' do
let(:account) { create(:account) }
@@ -44,61 +44,11 @@ describe Whatsapp::EmbeddedSignupService do
.with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token)
.and_return(channel_creation)
allow(channel_creation).to receive(:perform).and_return(channel)
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({
platform_type: 'CLOUD_API',
throughput: { 'level' => 'STANDARD' },
messaging_limit_tier: 'TIER_1000'
})
end
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
health_service = instance_double(Whatsapp::HealthService)
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
expect(health_service).to receive(:fetch_health_status)
service.perform
end
context 'when channel is in pending state' do
it 'prompts reauthorization for pending channel' do
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({
platform_type: 'NOT_APPLICABLE',
throughput: { 'level' => 'STANDARD' },
messaging_limit_tier: 'TIER_1000'
})
expect(channel).to receive(:prompt_reauthorization!)
service.perform
end
it 'prompts reauthorization when throughput level is NOT_APPLICABLE' do
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({
platform_type: 'CLOUD_API',
throughput: { 'level' => 'NOT_APPLICABLE' },
messaging_limit_tier: 'TIER_1000'
})
expect(channel).to receive(:prompt_reauthorization!)
service.perform
end
end
context 'when channel is healthy' do
it 'does not prompt reauthorization for healthy channel' do
expect(channel).not_to receive(:prompt_reauthorization!)
service.perform
end
it 'creates the channel and enqueues webhook setup with the health check' do
expect { service.perform }
.to have_enqueued_job(Channels::Whatsapp::WebhookSetupJob).with(channel, run_health_check: true)
end
context 'when parameters are invalid' do
@@ -156,10 +106,11 @@ describe Whatsapp::EmbeddedSignupService do
})
end
it 'uses ReauthorizationService and enqueues webhook setup' do
it 'uses ReauthorizationService and enqueues webhook setup without the health check' do
expect(reauth_service).to receive(:perform)
expect { service_with_inbox.perform }.to have_enqueued_job(Channels::Whatsapp::WebhookSetupJob).with(channel)
expect { service_with_inbox.perform }
.to have_enqueued_job(Channels::Whatsapp::WebhookSetupJob).with(channel, run_health_check: false)
end
context 'with real channel requiring reauthorization' do