Files
LedgerLine/services/fraud/app.py
T

125 lines
4.4 KiB
Python
Raw Normal View History

import httpx
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
2026-10-19 16:05:40 +03:30
from opentelemetry import metrics, trace
2026-10-13 19:48:27 +03:30
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"],
)
2026-10-19 16:05:40 +03:30
meter = metrics.get_meter(__name__)
fraud_score = meter.create_histogram(
"ledgerline.fraud.score",
description="Risk score per scoring call (0-1; gateway rejects above 0.8)",
explicit_bucket_boundaries_advisory=[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
)
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:
2026-10-13 19:48:27 +03:30
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},
)
2026-10-13 19:48:27 +03:30
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:
2026-10-13 19:48:27 +03:30
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)
2026-10-19 16:05:40 +03:30
fraud_score.record(risk_score)
logger.info(
"transaction_scored", account_id=body.account_id, risk_score=risk_score, matched=matched
)
return {"risk_score": risk_score, "matched": matched}