Build a Notification Service from Scratch — Part 3: Retries, Rate Limiting, and Monitoring
Step 1: Per-Channel Rate Limiting
Prevent hitting provider rate limits (e.g., Twilio’s 100 SMS/sec):
# app/rate_limiter.py
from app.core.cache import redis_client
import time
CHANNEL_LIMITS = {
"email": 50, # per second
"sms": 10, # per second
"push": 100 # per second
}
def check_rate_limit(channel: str) -> bool:
"""Returns True if within rate limit, False if exceeded."""
limit = CHANNEL_LIMITS.get(channel, 10)
key = f"rate_limit:{channel}:{int(time.time())}"
current = redis_client.incr(key)
redis_client.expire(key, 2) # Auto-cleanup after 2 seconds
return current <= limit
Integrate into the Celery task:
@celery_app.task(bind=True, max_retries=3)
def deliver_notification(self, notification_id: int):
# ... load notification ...
if not check_rate_limit(notification.channel):
raise self.retry(countdown=5) # Wait and retry
# ... dispatch ...
Step 2: Dead Letter Queue
Notifications that exhaust all retries go to a dead letter queue for manual review:
def move_to_dlq(notification_id: int, error: str):
"""Move failed notification to dead letter queue."""
redis_client.lpush("dlq", json.dumps({
"notification_id": notification_id,
"error": str(error),
"moved_at": datetime.now(timezone.utc).isoformat()
}))
def process_dlq():
"""View dead letter queue items for manual inspection."""
items = redis_client.lrange("dlq", 0, -1)
return [json.loads(item) for item in items]
def retry_from_dlq(notification_id: int):
"""Manually retry a notification from the DLQ."""
# Remove from DLQ and re-enqueue
redis_client.lrem("dlq", 0, f'"notification_id":{notification_id}')
enqueue_notification(notification_id)
Step 3: Prometheus Metrics
# app/metrics.py
from prometheus_client import Counter, Histogram, Gauge, generate_latest
from fastapi import Response
notifications_sent = Counter(
"notifications_sent_total",
"Total notifications sent",
["channel", "status"]
)
delivery_duration = Histogram(
"notification_delivery_seconds",
"Time to deliver a notification",
["channel"]
)
queue_size = Gauge(
"notification_queue_size",
"Number of notifications in queue",
["channel"]
)
@app.get("/metrics")
def metrics():
return Response(content=generate_latest(), media_type="text/plain")
Record metrics in the Celery task:
@celery_app.task(bind=True, max_retries=3)
def deliver_notification(self, notification_id: int):
start = time.time()
# ... deliver ...
duration = time.time() - start
notifications_sent.labels(
channel=notification.channel,
status=notification.status
).inc()
delivery_duration.labels(channel=notification.channel).observe(duration)
Step 4: Alerting Rules
Set up alerts for critical conditions:
# prometheus/rules.yml
groups:
- name: notification_alerts
rules:
- alert: HighFailureRate
expr: rate(notifications_sent_total{status="failed"}[5m]) > 0.1
annotations:
summary: "Notification failure rate > 10%"
- alert: DLQGrowing
expr: dlq_size > 100
annotations:
summary: "Dead letter queue has {{ $value }} items"
- alert: DeliveryLatency
expr: histogram_quantile(0.95, notification_delivery_seconds) > 5
annotations:
summary: "P95 delivery latency > 5 seconds"
Summary
- Rate limiting prevents exceeding provider thresholds
- Dead letter queue preserves failed notifications for manual review
- Prometheus metrics track volume, latency, and error rates per channel
- Alerting rules notify on high failure rates, growing DLQ, or degraded latency
Advertisement