Files
payguard-poc/api/main.py
T
Pat TeruelandCopilot 3783392524 Fix WebSocket message delivery - improve error handling and logging
- Refactored WebSocket handler to distinguish between queue.Empty and actual errors
- Added detailed logging for message send confirmation
- Better exception handling to prevent silent failures
- All fraud detection messages now confirmed sent to clients

Co-authored-by: Copilot <[email protected]>
2026-05-01 19:51:50 +08:00

360 lines
15 KiB
Python

"""
PayGuard Transaction Ingestion API
Responsibilities:
- Authenticate incoming requests using JWT
- Validate transaction data structure
- Publish transactions to Kafka asynchronously
- Return immediately without waiting for fraud analysis (demonstrates decoupling)
Academic Point:
The API does not process transactions directly. It simply validates identity and
hands off responsibility to the message queue. This separation of concerns is
fundamental to scalable microservices architecture.
"""
from fastapi import FastAPI, Depends, HTTPException, status, WebSocket, WebSocketDisconnect
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from jose import jwt, JWTError
from kafka import KafkaProducer
import json
import uuid
import time
import logging
import redis
import threading
import asyncio
from queue import Queue
# ══════════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ══════════════════════════════════════════════════════════════════════════════
# Secret key for JWT signing (for demo purposes only)
SECRET_KEY = "payguard-demo-secret-key-change-in-production"
ALGORITHM = "HS256"
# Kafka bootstrap server (use 'kafka:29092' for Docker internal, localhost:9092 for host access)
KAFKA_BROKER = "kafka:29092"
KAFKA_TOPIC = "transactions"
# Logging setup
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s'
)
logger = logging.getLogger(__name__)
# ══════════════════════════════════════════════════════════════════════════════
# MODELS
# ══════════════════════════════════════════════════════════════════════════════
class TransactionRequest(BaseModel):
"""Request body for transaction submission"""
amount: float
merchant: str
description: str = ""
class TransactionResponse(BaseModel):
"""Response after transaction is accepted"""
status: str
transaction_id: str
message: str
# ══════════════════════════════════════════════════════════════════════════════
# KAFKA PRODUCER
# ══════════════════════════════════════════════════════════════════════════════
kafka_producer = None
def get_kafka_producer():
"""Lazy initialization of Kafka producer with retry logic"""
global kafka_producer
if kafka_producer is None:
try:
kafka_producer = KafkaProducer(
bootstrap_servers=KAFKA_BROKER,
value_serializer=lambda v: json.dumps(v).encode('utf-8'),
acks='all',
retries=3,
request_timeout_ms=10000,
api_version=(2, 5, 0) # Specify Kafka API version
)
logger.info(f"[KAFKA PRODUCER] Initialized, broker={KAFKA_BROKER}, topic={KAFKA_TOPIC}")
except Exception as e:
logger.warning(f"[KAFKA PRODUCER] Delayed initialization (will retry on first request): {e}")
kafka_producer = None
raise
return kafka_producer
# ══════════════════════════════════════════════════════════════════════════════
# REDIS & WEBSOCKET PUBSUB
# ══════════════════════════════════════════════════════════════════════════════
redis_client = None
message_queue = Queue() # Thread-safe queue for passing messages
connected_clients = [] # List of asyncio queues
pubsub_thread = None
def get_redis_client():
"""Get or create Redis client"""
global redis_client
if redis_client is None:
try:
redis_client = redis.Redis(
host="redis",
port=6379,
decode_responses=True
)
redis_client.ping()
logger.info("[REDIS] Connected to Redis")
except Exception as e:
logger.warning(f"[REDIS] Connection failed: {e}")
redis_client = None
raise
return redis_client
def start_pubsub_listener():
"""Start background thread to listen for fraud results on Redis pub/sub"""
global pubsub_thread
def listen_and_queue():
try:
redis_conn = get_redis_client()
pubsub = redis_conn.pubsub()
pubsub.subscribe("fraud_results")
logger.info("[PUBSUB] Listening to 'fraud_results' channel")
for message in pubsub.listen():
if message["type"] == "message":
try:
data = json.loads(message["data"])
logger.info(f"[PUBSUB] Received: {data.get('transaction_id', 'unknown')[:8]}...")
# Store latest result in Redis (for late connections to fetch)
redis_conn.setex("latest_fraud_result", 3600, json.dumps(data))
# Queue message for real-time delivery to connected clients
message_queue.put(data)
logger.info(f"[PUBSUB] Queued for {len(connected_clients)} WebSocket clients")
except Exception as e:
logger.error(f"[PUBSUB] Failed to parse message: {e}")
except Exception as e:
logger.error(f"[PUBSUB] Listener error: {e}")
pubsub_thread = threading.Thread(target=listen_and_queue, daemon=True)
pubsub_thread.start()
# ══════════════════════════════════════════════════════════════════════════════
# AUTHENTICATION
# ══════════════════════════════════════════════════════════════════════════════
security = HTTPBearer()
def verify_jwt(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
"""
Verify JWT token and extract user information.
Academic Point: In a real system, this would validate against a user database
and use proper token refresh mechanisms. For demo, we accept any valid JWT.
"""
try:
token = credentials.credentials
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id = payload.get("sub")
if user_id is None:
logger.warning("[JWT AUTH] Token missing 'sub' claim")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token: missing user ID"
)
logger.info(f"[JWT AUTH] ✓ Token verified for user={user_id}")
return {"user_id": user_id}
except JWTError as e:
logger.warning(f"[JWT AUTH] ✗ Token validation failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token"
)
# ══════════════════════════════════════════════════════════════════════════════
# API APPLICATION
# ══════════════════════════════════════════════════════════════════════════════
app = FastAPI(
title="PayGuard Fraud Detection API",
description="Transaction ingestion layer for fraud detection pipeline",
version="1.0.0"
)
# Add CORS middleware to allow requests from frontend
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "service": "payguard-api"}
@app.post("/transaction", response_model=TransactionResponse)
async def submit_transaction(
request: TransactionRequest,
user: dict = Depends(verify_jwt)
) -> TransactionResponse:
"""
Accept a transaction and publish it to Kafka for async processing.
Returns immediately without waiting for fraud analysis.
This is a key architectural pattern: decoupling ingestion from processing.
"""
user_id = user["user_id"]
transaction_id = str(uuid.uuid4())
timestamp = time.time()
# Build transaction event
event = {
"transaction_id": transaction_id,
"user_id": user_id,
"amount": request.amount,
"merchant": request.merchant,
"description": request.description,
"timestamp": timestamp
}
logger.info(
f"[TRANSACTION INTAKE] Received from {user_id}: "
f"${request.amount:.2f} at {request.merchant}"
)
# Publish to Kafka
try:
producer = get_kafka_producer()
future = producer.send(KAFKA_TOPIC, event)
future.get(timeout=10) # Wait for confirmation
logger.info(
f"[KAFKA PUBLISH] ✓ Transaction {transaction_id} "
f"published to topic '{KAFKA_TOPIC}'"
)
return TransactionResponse(
status="accepted",
transaction_id=transaction_id,
message="Transaction accepted. Fraud analysis in progress."
)
except Exception as e:
logger.error(f"[KAFKA PUBLISH] ✗ Failed to publish: {str(e)}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Message broker unavailable. Please try again."
)
@app.post("/token")
async def get_demo_token(user_id: str):
"""
Generate a demo JWT token for testing.
In production, this would validate credentials against a user database.
For demo purposes, accept any user_id.
"""
token = jwt.encode({"sub": user_id}, SECRET_KEY, algorithm=ALGORITHM)
logger.info(f"[JWT GENERATION] Generated token for user={user_id}")
return {"access_token": token, "token_type": "bearer"}
@app.websocket("/ws/fraud-results")
async def websocket_fraud_results(websocket: WebSocket):
"""
WebSocket endpoint for real-time fraud detection results.
Clients connect and receive live updates when fraud detection completes.
Results are streamed from Redis pub/sub channel via background listener.
"""
await websocket.accept()
connected_clients.append(websocket)
logger.info(f"[WEBSOCKET] Client connected. Total clients: {len(connected_clients)}")
try:
# Send latest result if available (for late connections)
try:
latest = redis_client.get("latest_fraud_result")
if latest:
data = json.loads(latest)
logger.info(f"[WEBSOCKET] Sending latest result to new client")
await websocket.send_json(data)
except Exception as e:
logger.debug(f"[WEBSOCKET] No latest result available: {e}")
# Now listen for new results
while True:
try:
msg = message_queue.get_nowait()
logger.info(f"[WEBSOCKET] Sending fraud result to client: {msg.get('transaction_id', 'unknown')}")
await websocket.send_json(msg)
logger.info(f"[WEBSOCKET] ✓ Message sent successfully")
except Exception as e:
if e.__class__.__name__ == 'Empty':
# Queue empty, sleep briefly before checking again
await asyncio.sleep(0.2)
else:
# Real error - log it but continue
logger.error(f"[WEBSOCKET] Error sending message: {type(e).__name__}: {e}")
await asyncio.sleep(0.2)
except WebSocketDisconnect:
try:
connected_clients.remove(websocket)
except:
pass
logger.info(f"[WEBSOCKET] Client disconnected. Total clients: {len(connected_clients)}")
except Exception as e:
logger.error(f"[WEBSOCKET] Error: {type(e).__name__}: {e}")
try:
connected_clients.remove(websocket)
except:
pass
# ══════════════════════════════════════════════════════════════════════════════
# STARTUP / SHUTDOWN
# ══════════════════════════════════════════════════════════════════════════════
@app.on_event("startup")
async def startup_event():
logger.info("="*80)
logger.info("PayGuard Transaction Ingestion API - STARTUP")
logger.info("="*80)
logger.info(f"Kafka Broker: {KAFKA_BROKER}")
logger.info(f"Topic: {KAFKA_TOPIC}")
logger.info("Listening for authenticated transaction requests...")
logger.info("="*80)
# Start Redis pub/sub listener for WebSocket broadcasts
try:
get_redis_client()
start_pubsub_listener()
except Exception as e:
logger.warning(f"[STARTUP] Redis pub/sub not available: {e}")
@app.on_event("shutdown")
async def shutdown_event():
logger.info("Shutting down API...")
if kafka_producer is not None:
kafka_producer.close()
if __name__ == "__main__":
import uvicorn
uvicorn.run(
app,
host="0.0.0.0",
port=8000,
log_level="info"
)