The exception propagates. Django's error reporting sends an alert and the webhook answers with a 500, which makes SendGrid re-deliver the event within minutes and run the handler again from scratch. An event would have to hit a deadlock ten times in a row to be lost. It has not happened in production.
Webhook ingestion that survives the campaign burst
The gap showed up in an audit, not in a dashboard. A compliance check compared the delivery log of a B2B marketplace against SendGrid's own records and found events missing, weeks old by then. Every transactional mail, from signup confirmations to invoices, reported back through webhooks. Nobody had noticed that some of those reports never reached the database.
Services
Backend Engineering, DevOps
Industry
B2B Marketplace
Year
2023-2024
THE CHALLENGE
Two events, one row, one survivor
The webhook endpoint processed events synchronously inside a Django signal handler, which was fine at normal load. During a campaign, thousands of messages went out within minutes and SendGrid fired hundreds of tracking events per second. When two of them tried to create the same EmailHooks record at the same instant, the database deadlocked. Django caught the IntegrityError, the handler crashed, and the second event was gone. No retry, no log line, no alert. The delivery log looked complete until someone compared it with SendGrid's records, weeks later.
THE SOLUTION
Retry the deadlock, defer the enrichment
Every event now enters a transaction.atomic() block with get_or_create keyed on the SendGrid event id, so a duplicate delivery returns early instead of writing twice. If the write hits a deadlock, the handler waits and tries again, up to five times with a backoff that grows in half-second steps; the staggering stops concurrent retries from colliding a second time. Once the write is committed, transaction.on_commit() queues a Celery task that calls the SendGrid Messages API and fills in subject, sender and open and click counts. The alternative was a proper queue in front of the endpoint, RabbitMQ or SQS. It would have meant rearchitecting the ingestion layer and running infrastructure the team had no capacity to operate, for a problem that fit in one file. Celery was already there for other background work.
The signal handler with deadlock retry and deferred enrichment:
Python
MAX_RETRIES = 5
@receiver(event_received)
def handle_webhook_event(sender, event_data, **kwargs):
for attempt in range(MAX_RETRIES):
try:
with transaction.atomic():
hook, created = EmailHooks.objects.get_or_create(
event_id=event_data['sg_event_id'],
defaults={
'email': event_data['email'],
'event': event_data['event'],
'timestamp': parse_datetime(
event_data['timestamp']
),
'sg_message_id': event_data.get('sg_message_id'),
}
)
if not created:
return # Already processed: idempotent
transaction.on_commit(
lambda pk=hook.pk: enrich_hook.delay(pk)
)
return
except (IntegrityError, OperationalError) as exc:
if 'Deadlock' in str(exc) and attempt < MAX_RETRIES - 1:
sleep(0.5 * (attempt + 1))
continue
raiseA burst hits the handler
Eight to ten events arrive within a second, some for the same message row. Watch a pair collide on Write: with the rebuilt handler the loser waits, retries and goes through, and Enrich lights up only after Commit. Switch to the old handler to see the loser disappear. The last event is a duplicate delivery.
Webhook burst
Ready
| Event | Row | Signal, Write, Commit, Enrich | Status |
|---|---|---|---|
| #4f2adelivered | msg 9c1e | Enriched | |
| #b7d3opened | msg 9c1e | Enriched2 attempts | |
| #e10cdelivered | msg 27a4 | Enriched | |
| #58f9bounced | msg d3b0 | Enriched | |
| #a6c1clicked | msg 27a4 | Enriched2 attempts | |
| #0d7edelivered | msg f412 | Enriched | |
| #4f2adelivered | msg 9c1e | Duplicate, returned early |
- Committed
- 6
- Retries
- 2
- Enriched
- 6
- Lost
- 0
Burst finished: 6 committed, 2 retries, 6 enriched, 0 lost.
The Celery task that enriches each record through the SendGrid API, with its own retries:
Python
@shared_task(bind=True, max_retries=3)
def enrich_hook(self, hook_id):
hook = EmailHooks.objects.get(pk=hook_id)
try:
msg = sg_client.client.messages._(
hook.sg_message_id
).get()
hook.subject = msg['subject']
hook.from_email = msg['from_email']
hook.opens_count = msg.get('opens_count', 0)
hook.clicks_count = msg.get('clicks_count', 0)
hook.status = 'enriched'
hook.save(update_fields=[
'subject', 'from_email',
'opens_count', 'clicks_count', 'status',
])
except Exception as exc:
raise self.retry(exc=exc, countdown=30)THE RESULT
The log matches the provider
During peak campaign periods the rebuilt handler takes in more than fifty thousand events a day, and the compliance team has not found another gap in the delivery log. Deadlocks still happen under load; they are now resolved by the retry without anyone being paged. The support team sees the full metadata of an email event within seconds of it happening and can answer delivery questions without asking engineering.
KEY METRICS
50000+Events per day, peak campaigns
0Events lost since launch
99.8%Deadlocks resolved by retry
CLIENT FEEDBACK
The audit used to be where we found out about missing records, weeks late. Now the delivery log matches SendGrid's, and support can look up the status of any mail without asking us.
Engineering Lead
B2B marketplace, platform operations
FOR YOUR PROJECT
- When it applies
- Any webhook or callback endpoint that writes one row per event and gets its events in bursts: email providers, payment providers, CRM sync. The symptom to look for is an event count that does not match the provider's export.
- What to check
- Whether your handler treats a deadlock and a constraint violation the same, whether the event id sits in a unique column so a replay is harmless, and whether background tasks are queued before the commit they depend on.
- What it needs
- One file change in the handler, a unique key on the event id, and a task queue you already run for the enrichment. Budget one provider API call per event; at fifty thousand events a day that is the real load, not the database write.
FAQ
TECHNOLOGY STACK
Django
Python
PostgreSQL
Manuel Kasbarian - CEO, SophistiXWe have enjoyed working with Daniel for 10 years now. We highly appreciate his fast response times around the clock and his all-round knowledge. Whether server configurations or programming, he always has the right solution.
Join Manuel and launch your next project with confidence.
Open to new ProjectsGet In Touch