Skip to content

Commit 5184897

Browse files
⚡ perf: Add Redis caching to /v1/breaches and /v1/metrics
1 parent 03b9ca9 commit 5184897

2 files changed

Lines changed: 116 additions & 29 deletions

File tree

api/v1/breaches.py

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,18 @@
22

33
# Standard library imports
44
import json
5-
from datetime import datetime
6-
from typing import Optional, Union
5+
from datetime import datetime, timedelta
6+
from typing import Dict, Optional, Union
77
from urllib.parse import urlparse
88

99
# Third-party imports
1010
from fastapi import APIRouter, Header, HTTPException, Path, Query, Request
1111
from fastapi.responses import JSONResponse, Response
1212
from google.cloud import datastore
13+
from redis import Redis
1314

1415
# Local imports
15-
from config.settings import MAX_EMAIL_LENGTH
16+
from config.settings import MAX_EMAIL_LENGTH, REDIS_DB, REDIS_HOST, REDIS_PORT
1617
from models.responses import (
1718
BreachAnalyticsResponse,
1819
BreachAnalyticsV2Response,
@@ -41,6 +42,44 @@
4142

4243
router = APIRouter()
4344

45+
# Redis client for caching
46+
redis_client = Redis(
47+
host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, decode_responses=True
48+
)
49+
50+
# Cache TTL: 24 hours
51+
BREACH_CACHE_TTL_HOURS = 24
52+
53+
54+
def get_breach_cache_key(breach_id: Optional[str], domain: Optional[str]) -> str:
55+
"""Generate cache key based on query parameters."""
56+
if breach_id:
57+
return f"breaches:id:{breach_id.lower()}"
58+
elif domain:
59+
return f"breaches:domain:{domain.lower()}"
60+
return "breaches:all"
61+
62+
63+
def get_cached_breaches(cache_key: str) -> Optional[Dict]:
64+
"""Retrieve cached breach results from Redis."""
65+
try:
66+
cached_data = redis_client.get(cache_key)
67+
if cached_data:
68+
return json.loads(cached_data)
69+
except Exception:
70+
pass
71+
return None
72+
73+
74+
def cache_breaches(
75+
cache_key: str, result: Dict, expiry_hours: int = BREACH_CACHE_TTL_HOURS
76+
) -> None:
77+
"""Cache breach results in Redis."""
78+
try:
79+
redis_client.setex(cache_key, timedelta(hours=expiry_hours), json.dumps(result))
80+
except Exception:
81+
pass
82+
4483

4584
@router.get("/breaches", response_model=BreachListResponse)
4685
@custom_rate_limiter("2 per second;50 per hour;100 per day")
@@ -55,13 +94,10 @@ async def get_xposed_breaches(
5594
or for all domains if no domain is specified.
5695
"""
5796
try:
58-
client = datastore.Client()
59-
query = client.query(kind="xon_breaches")
60-
97+
# Validate inputs first
6198
if breach_id:
6299
if not validate_variables([breach_id]):
63100
raise HTTPException(status_code=400, detail="Invalid Breach ID")
64-
query.key_filter(client.key("xon_breaches", breach_id), "=")
65101
elif domain:
66102
# Try to extract domain from URL if a full URL is provided
67103
if not validate_domain(domain):
@@ -71,23 +107,24 @@ async def get_xposed_breaches(
71107
domain = extracted
72108
else:
73109
raise HTTPException(status_code=400, detail="Invalid Domain")
110+
111+
# Check cache first
112+
cache_key = get_breach_cache_key(breach_id, domain)
113+
cached_result = get_cached_breaches(cache_key)
114+
if cached_result:
115+
return BreachListResponse(**cached_result)
116+
117+
# Cache miss - query Datastore
118+
client = datastore.Client()
119+
query = client.query(kind="xon_breaches")
120+
121+
if breach_id:
122+
query.key_filter(client.key("xon_breaches", breach_id), "=")
123+
elif domain:
74124
query.add_filter("domain", "=", domain)
75125
else:
76126
query.order = ["-timestamp"]
77127

78-
# Check if-modified-since header
79-
latest_entity = list(query.fetch(limit=1))
80-
if latest_entity and if_modified_since:
81-
latest_timestamp = latest_entity[0]["timestamp"]
82-
try:
83-
if_modified_dt = datetime.strptime(
84-
if_modified_since, "%a, %d %b %Y %H:%M:%S GMT"
85-
)
86-
if latest_timestamp.replace(tzinfo=None) <= if_modified_dt:
87-
return Response(status_code=304)
88-
except ValueError:
89-
pass
90-
91128
entities = list(query.fetch())
92129
if not entities:
93130
return BreachListResponse(
@@ -136,6 +173,13 @@ async def get_xposed_breaches(
136173
status="notFound", message=f"No breaches found for domain {domain}"
137174
)
138175

176+
# Build response and cache it
177+
response_data = {
178+
"status": "success",
179+
"exposedBreaches": [breach.model_dump() for breach in breach_details],
180+
}
181+
cache_breaches(cache_key, response_data)
182+
139183
return BreachListResponse(status="success", exposedBreaches=breach_details)
140184

141185
except HTTPException:

api/v1/metrics.py

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,51 @@
11
"""Metrics-related API endpoints."""
22

3-
from datetime import datetime
3+
import json
4+
from datetime import datetime, timedelta
5+
from typing import Dict, Optional
46

57
from fastapi import APIRouter, HTTPException, Request
68
from fastapi.responses import JSONResponse
9+
from redis import Redis
710

8-
from utils.custom_limiter import custom_rate_limiter
9-
from models.responses import MetricsResponse, DetailedMetricsResponse
11+
from config.settings import REDIS_DB, REDIS_HOST, REDIS_PORT
12+
from models.responses import DetailedMetricsResponse, MetricsResponse
1013
from services.analytics import get_detailed_metrics
1114
from services.send_email import send_exception_email
15+
from utils.custom_limiter import custom_rate_limiter
1216
from utils.helpers import validate_url
1317

1418
router = APIRouter()
1519

20+
# Redis client for caching
21+
redis_client = Redis(
22+
host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, decode_responses=True
23+
)
24+
25+
# Cache TTL: 24 hours
26+
METRICS_CACHE_TTL_HOURS = 24
27+
28+
29+
def get_cached_metrics(cache_key: str) -> Optional[Dict]:
30+
"""Retrieve cached metrics from Redis."""
31+
try:
32+
cached_data = redis_client.get(cache_key)
33+
if cached_data:
34+
return json.loads(cached_data)
35+
except Exception:
36+
pass
37+
return None
38+
39+
40+
def cache_metrics(
41+
cache_key: str, result: Dict, expiry_hours: int = METRICS_CACHE_TTL_HOURS
42+
) -> None:
43+
"""Cache metrics in Redis."""
44+
try:
45+
redis_client.setex(cache_key, timedelta(hours=expiry_hours), json.dumps(result))
46+
except Exception:
47+
pass
48+
1649

1750
@router.get("/metrics", response_model=MetricsResponse)
1851
@custom_rate_limiter("5 per minute;50 per hour;100 per day")
@@ -22,13 +55,23 @@ async def get_metrics_endpoint(request: Request) -> MetricsResponse:
2255
if not validate_url(request):
2356
raise HTTPException(status_code=400, detail="Invalid request URL")
2457

58+
# Check cache first
59+
cache_key = "metrics:basic"
60+
cached_result = get_cached_metrics(cache_key)
61+
if cached_result:
62+
return MetricsResponse(**cached_result)
63+
64+
# Cache miss - fetch from service
2565
metrics = await get_detailed_metrics()
26-
return MetricsResponse(
27-
Breaches_Count=metrics["breaches_count"],
28-
Breaches_Records=metrics["breaches_total_records"],
29-
Pastes_Count=str(metrics["pastes_count"]),
30-
Pastes_Records=metrics["pastes_total_records"],
31-
)
66+
response_data = {
67+
"Breaches_Count": metrics["breaches_count"],
68+
"Breaches_Records": metrics["breaches_total_records"],
69+
"Pastes_Count": str(metrics["pastes_count"]),
70+
"Pastes_Records": metrics["pastes_total_records"],
71+
}
72+
cache_metrics(cache_key, response_data)
73+
74+
return MetricsResponse(**response_data)
3275

3376
except Exception as e:
3477
await send_exception_email(

0 commit comments

Comments
 (0)