Build a Notification Service from Scratch — Part 2: Queue and Worker System

In this part, we set up Celery workers to process notifications asynchronously through Redis queues.

Step 1: Celery Setup

# app/celery_app.py
from celery import Celery
from app.config import settings

celery_app = Celery(
    "notification_service",
    broker=settings.redis_url,
    backend=settings.redis_url
)

celery_app.conf.update(
    task_serializer="json",
    result_serializer="json",
    accept_content=["json"],
    task_acks_late=True,           # Re-deliver if worker crashes
    task_reject_on_worker_lost=True,
    task_default_retry_delay=60,    # 1 minute
    task_max_retries=3,
    worker_prefetch_multiplier=1     # One task at a time per worker
)

Step 2: Enqueue Notifications

# app/queue.py
from app.celery_app import celery_app
from app.models import NotificationStatus

@celery_app.task(bind=True, max_retries=3)
def deliver_notification(self, notification_id: int):
    from app.core.database import SessionLocal
    from app.models import Notification

    db = SessionLocal()
    notification = db.query(Notification).get(notification_id)

    if not notification:
        return

    try:
        # Dispatch based on channel
        if notification.channel == "email":
            send_email(notification.recipient, notification.subject, notification.body)
        elif notification.channel == "sms":
            send_sms(notification.recipient, notification.body)
        elif notification.channel == "push":
            send_push(notification.recipient, notification.body)

        notification.status = NotificationStatus.SENT
        notification.sent_at = datetime.now(timezone.utc)
        db.commit()

    except Exception as e:
        notification.retry_count += 1
        db.commit()

        if notification.retry_count < notification.max_retries:
            raise self.retry(exc=e, countdown=60 * (2 ** notification.retry_count))
        else:
            notification.status = NotificationStatus.FAILED
            db.commit()
    finally:
        db.close()

def enqueue_notification(notification_id: int):
    deliver_notification.delay(notification_id)

Step 3: Channel Dispatchers

# app/dispatchers/email.py
import smtplib
from email.mime.text import MIMEText
from app.config import settings

def send_email(to: str, subject: str, body: str):
    msg = MIMEText(body, "html")
    msg["Subject"] = subject
    msg["From"] = settings.email_from
    msg["To"] = to

    with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
        server.starttls()
        server.login(settings.smtp_user, settings.smtp_password)
        server.send_message(msg)

# app/dispatchers/sms.py
import requests

def send_sms(to: str, body: str):
    """Send SMS via Twilio (or any provider)."""
    response = requests.post(
        f"https://api.twilio.com/2010-04-01/Accounts/{settings.twilio_sid}/Messages.json",
        auth=(settings.twilio_sid, settings.twilio_token),
        data={"To": to, "From": settings.twilio_phone, "Body": body}
    )
    response.raise_for_status()

# app/dispatchers/push.py
def send_push(device_token: str, body: str):
    """Send push notification via FCM."""
    requests.post(
        "https://fcm.googleapis.com/fcm/send",
        headers={"Authorization": f"key={settings.fcm_key}"},
        json={"to": device_token, "notification": {"body": body}}
    )

Step 4: API Endpoints

# app/api/notifications.py
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from app.services.notification_service import NotificationService
from app.core.database import get_db

router = APIRouter(prefix="/notifications", tags=["notifications"])

class SendRequest(BaseModel):
    template_name: str
    recipient: str
    context: dict = {}

@router.post("/send", status_code=202)
def send_notification(req: SendRequest, db=Depends(get_db)):
    service = NotificationService(db)
    notification = service.send(req.template_name, req.recipient, req.context)
    return {"id": notification.id, "status": notification.status}

@router.get("/{notification_id}")
def get_status(notification_id: int, db=Depends(get_db)):
    notification = db.query(Notification).get(notification_id)
    if not notification:
        raise HTTPException(404, "Notification not found")
    return {"id": notification.id, "status": notification.status, "retry_count": notification.retry_count}

Step 5: Run Workers

# Start a Celery worker
celery -A app.celery_app worker --loglevel=info --concurrency=4

# Start the API
uvicorn app.main:app --reload

Verification

# Create a template first
curl -X POST http://localhost:8000/templates \
  -H "Content-Type: application/json" \
  -d '{"name":"welcome","channel":"email","subject_template":"Welcome!","body_template":"Hello {{user_name}}!","variables":["user_name"]}'

# Send a notification
curl -X POST http://localhost:8000/notifications/send \
  -H "Content-Type: application/json" \
  -d '{"template_name":"welcome","recipient":"user@example.com","context":{"user_name":"John"}}'

# Check status
curl http://localhost:8000/notifications/1
# {"id": 1, "status": "sent", "retry_count": 0}

Summary

  • Celery + Redis for reliable, asynchronous job processing
  • Channel dispatchers separate email, SMS, and push logic
  • Exponential backoff retry with configurable max retries
  • ACKs late ensures no job loss on worker crash

← Part 1 | Part 3 →


Advertisement