fix(webhook): run inbound processing in after() so it survives serverless freeze

The POST handler ack'd Meta with 200 and then ran processWebhook() as a
detached promise. On Vercel the function can be frozen/terminated the moment
the response is sent, so those DB writes were not guaranteed to complete.

This dropped a non-deterministic subset of inbound messages: the contact and
conversation rows (written first) landed, but the message insert (written
after) often did not — leaving conversations that appear in the inbox with an
empty thread, with no durable logs to explain it (issue #301).

Move the work into next/server `after()`, which keeps the function alive until
the callback resolves (within the route's maxDuration) while still ack'ing Meta
fast enough to avoid retry-driven duplicate inserts. Add maxDuration=60 for
headroom since processing can fan out to per-media Meta verification calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-26 17:34:44 +03:00
parent b2ed58a41f
commit 11d1c622d0
No known key found for this signature in database

View File

@ -1,4 +1,4 @@
import { NextResponse } from 'next/server' import { NextResponse, after } from 'next/server'
import { createClient } from '@supabase/supabase-js' import { createClient } from '@supabase/supabase-js'
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption' import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api' import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api'
@ -12,6 +12,12 @@ import {
isTemplateWebhookField, isTemplateWebhookField,
} from '@/lib/whatsapp/template-webhook' } from '@/lib/whatsapp/template-webhook'
// The `after()` callback in POST runs within this route's max duration.
// Inbound processing can fan out to per-media Meta verification calls, so
// give it headroom beyond the platform default (Vercel clamps this to the
// plan's ceiling). Tune as needed.
export const maxDuration = 60
// Lazy-initialized to avoid build-time crash when env vars are missing // Lazy-initialized to avoid build-time crash when env vars are missing
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let _adminClient: any = null let _adminClient: any = null
@ -182,9 +188,26 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
} }
// Process asynchronously so we can ack Meta within their timeout. // Process AFTER the response so we ack Meta within their ~20s timeout
processWebhook(body).catch((error) => { // (a slow ack triggers Meta retries + duplicate inserts), while still
console.error('Error processing webhook:', error) // guaranteeing the work runs to completion.
//
// This MUST use `after()` rather than a detached `processWebhook(body)`
// promise: on serverless platforms (we run on Vercel) the function can
// be frozen or terminated the moment the response is sent, so a floating
// promise's DB writes are not guaranteed to finish. That dropped a
// non-deterministic *subset* of inbound messages — contacts/conversations
// were created but the message insert never landed, leaving conversations
// that show in the inbox with an empty thread, and no logs to explain it
// (see issue #301). `after()` hands the callback to the runtime, which
// keeps the function alive until it resolves (within the route's
// maxDuration).
after(async () => {
try {
await processWebhook(body)
} catch (error) {
console.error('Error processing webhook:', error)
}
}) })
return NextResponse.json({ status: 'received' }, { status: 200 }) return NextResponse.json({ status: 'received' }, { status: 200 })