diff --git a/app/jobs/channels/whatsapp/webhook_setup_job.rb b/app/jobs/channels/whatsapp/webhook_setup_job.rb index f605aa989..567a28b6a 100644 --- a/app/jobs/channels/whatsapp/webhook_setup_job.rb +++ b/app/jobs/channels/whatsapp/webhook_setup_job.rb @@ -1,10 +1,21 @@ 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. + # 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 diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb index c4f1b5550..e3569152b 100644 --- a/app/models/channel/whatsapp.rb +++ b/app/models/channel/whatsapp.rb @@ -120,13 +120,11 @@ 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. + # 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 - rescue StandardError => e - Rails.logger.error "[WHATSAPP] Webhook setup failed: #{e.message}" - prompt_reauthorization! end # Enqueue on the same channel record so GlobalID resolves it in the job. If the queue diff --git a/spec/jobs/channels/whatsapp/webhook_setup_job_spec.rb b/spec/jobs/channels/whatsapp/webhook_setup_job_spec.rb new file mode 100644 index 000000000..7ef6a44c9 --- /dev/null +++ b/spec/jobs/channels/whatsapp/webhook_setup_job_spec.rb @@ -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 diff --git a/spec/services/whatsapp/embedded_signup_service_spec.rb b/spec/services/whatsapp/embedded_signup_service_spec.rb index e106b894f..5a4a25f32 100644 --- a/spec/services/whatsapp/embedded_signup_service_spec.rb +++ b/spec/services/whatsapp/embedded_signup_service_spec.rb @@ -67,18 +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 - - # 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) - allow(real_channel).to receive(:perform_webhook_setup).and_raise('Webhook setup error') - - expect(real_channel.reauthorization_required?).to be false - real_channel.setup_webhooks - expect(real_channel.reauthorization_required?).to be true - end end context 'with reauthorization flow' do