import httpx from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from pydantic import BaseModel from opentelemetry import trace from opentelemetry.trace import StatusCode from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from common.http import downstream_client from common.logging_config import configure_logging from common.redis_client import redis_client from common.settings import settings from common.telemetry import setup_telemetry from . import chaos from .scoring import score SERVICE_NAME = "fraud" logger = configure_logging(SERVICE_NAME) tracer = trace.get_tracer(__name__) setup_telemetry(service_name=SERVICE_NAME) app = FastAPI(title=SERVICE_NAME) FastAPIInstrumentor.instrument_app( app, excluded_urls="/healthz", exclude_spans=["receive", "send"], ) class ScoreRequest(BaseModel): account_id: str holder_name: str category_id: int | None = None amount_minor: int @app.get("/") async def root(): return {"service": SERVICE_NAME, "version": "0.1.0"} @app.get("/healthz") async def healthz(request: Request): if request.headers.get("X-Ledgerline-Probe") == "chatty-health": logger.info("healthz_probe_received", probe="chatty-health") return {"status": "ok", "service": SERVICE_NAME} @app.get("/readyz") async def readyz(): try: await redis_client.ping() except Exception as exc: span = trace.get_current_span() span.record_exception(exc) span.set_status(StatusCode.ERROR, str(exc)) logger.exception("readyz_check_failed") return JSONResponse(status_code=503, content={"status": "unavailable"}) return {"status": "ok"} @app.post("/internal/score") async def score_transaction(body: ScoreRequest): logger.info( "score_requested", account_id=body.account_id, amount_minor=body.amount_minor, category_id=body.category_id, ) await redis_client.incr("fraud:calls_total") if await chaos.blocking_event_loop_enabled(): logger.info("chaos_engaged", chaos_flag="blocking_event_loop", account_id=body.account_id) chaos.synchronous_cpu_work() with tracer.start_as_current_span( "fraud.score_transaction", attributes={ "ledgerline.account.id": body.account_id, "ledgerline.tx.amount_minor": body.amount_minor, }, ) as span: if body.category_id is not None: span.set_attribute("ledgerline.merchant.category_id", body.category_id) async with downstream_client() as client: try: resp = await client.post( f"{settings.sanctions_url}/internal/lookup", json={"holder_name": body.holder_name}, ) except httpx.TimeoutException as exc: span.record_exception(exc) span.set_status(StatusCode.ERROR, "sanctions lookup timed out") logger.exception("sanctions_check_timed_out", account_id=body.account_id) return JSONResponse(status_code=504, content={"error": "sanctions lookup timed out"}) except httpx.RequestError as exc: span.record_exception(exc) span.set_status(StatusCode.ERROR, "sanctions lookup failed") logger.exception("sanctions_check_failed", account_id=body.account_id) return JSONResponse(status_code=502, content={"error": "sanctions lookup failed"}) if resp.status_code >= 300: logger.warning( "sanctions_check_failed", account_id=body.account_id, status_code=resp.status_code ) return JSONResponse(status_code=502, content={"error": "sanctions lookup failed"}) matched = resp.json().get("matched", False) risk_score = score(body.category_id, matched) span.set_attribute("ledgerline.sanctions.matched", matched) span.set_attribute("ledgerline.risk.score", risk_score) logger.info( "transaction_scored", account_id=body.account_id, risk_score=risk_score, matched=matched ) return {"risk_score": risk_score, "matched": matched}