Compare commits

...
6 changed files with 193 additions and 133 deletions
@@ -0,0 +1,25 @@
class Channels::Whatsapp::WebhookSetupJob < ApplicationJob
queue_as :low
# Meta's Graph API calls (phone registration + webhook subscription) are slow and fail
# transiently. Retry a few times, and once retries are exhausted mark the channel for
# reauthorization so the inbox has a visible recovery path instead of silently missing its
# webhook subscription. Running these off the request thread also keeps them clear of the
# 15s Rack::Timeout, which previously aborted inbox creation and rolled it back.
retry_on StandardError, wait: :polynomially_longer, attempts: 3 do |job, error|
channel = job.arguments.first
Rails.logger.error("[WHATSAPP] Webhook setup failed after retries: #{error.message}")
channel.prompt_reauthorization! if channel.is_a?(Channel::Whatsapp)
end
# A deleted channel can't be set up; discard instead of retrying (takes precedence over
# the StandardError retry above, which would otherwise catch DeserializationError too).
discard_on ActiveJob::DeserializationError
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
+34 -2
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,15 +120,47 @@ class Channel::Whatsapp < ApplicationRecord
delegate :media_url, to: :provider_service
delegate :api_headers, to: :provider_service
# Runs inside Channels::Whatsapp::WebhookSetupJob, off the request thread so the slow Meta
# Graph calls can't trip Rack::Timeout. Raises on failure so the job can retry the flaky
# calls and, once retries are exhausted, mark the channel for reauthorization.
def setup_webhooks
perform_webhook_setup
end
# 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] Webhook setup failed: #{e.message}"
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
@@ -15,15 +15,14 @@ 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
# 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?
# 2. The channel is marked with source: 'embedded_signup' to skip the after_commit callback
# 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
@@ -55,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?
@@ -0,0 +1,49 @@
require 'rails_helper'
RSpec.describe Channels::Whatsapp::WebhookSetupJob do
let(:channel) do
create(:channel_whatsapp, validate_provider_config: false, sync_templates: false)
end
it 'runs webhook setup' do
allow(channel).to receive(:setup_webhooks)
described_class.perform_now(channel)
expect(channel).to have_received(:setup_webhooks)
end
it 'runs the provisioning health check when requested' do
allow(channel).to receive(:setup_webhooks)
allow(channel).to receive(:check_provisioning_health)
described_class.perform_now(channel, run_health_check: true)
expect(channel).to have_received(:check_provisioning_health)
end
it 'skips the provisioning health check by default' do
allow(channel).to receive(:setup_webhooks)
allow(channel).to receive(:check_provisioning_health)
described_class.perform_now(channel)
expect(channel).not_to have_received(:check_provisioning_health)
end
context 'when webhook setup keeps failing' do
before do
setup_service = instance_double(Whatsapp::WebhookSetupService)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
allow(setup_service).to receive(:perform).and_raise(StandardError, 'meta down')
end
it 'retries and marks the channel for reauthorization once retries are exhausted' do
expect(channel.reauthorization_required?).to be false
perform_enqueued_jobs { described_class.perform_later(channel) }
expect(channel.reauthorization_required?).to be true
end
end
end
+68 -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,
@@ -159,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) }
@@ -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
@@ -41,67 +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)
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({
platform_type: 'CLOUD_API',
throughput: { 'level' => 'STANDARD' },
messaging_limit_tier: 'TIER_1000'
})
end
it 'creates channel and sets up webhooks' do
expect(channel).to receive(:setup_webhooks)
result = service.perform
expect(result).to eq(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
@@ -120,30 +67,6 @@ describe Whatsapp::EmbeddedSignupService do
expect(Rails.logger).to receive(:error).with('[WHATSAPP] Embedded signup failed: Token error')
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
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
expect(real_channel.reauthorization_required?).to be true
end
end
context 'with reauthorization flow' do
@@ -162,8 +85,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 +94,11 @@ describe Whatsapp::EmbeddedSignupService do
})
end
it 'uses ReauthorizationService and sets up webhooks' do
it 'uses ReauthorizationService and enqueues webhook setup without the health check' 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, run_health_check: false)
end
context 'with real channel requiring reauthorization' do