feat: Implement Redis configuration for rate limiting, enhance health check endpoints, and add monitoring dashboard
This commit is contained in:
parent
030df3e8e0
commit
0c3fe480fd
32
=3.2.0
32
=3.2.0
@ -1,32 +0,0 @@
|
|||||||
Requirement already satisfied: langfuse in ./venv/lib/python3.12/site-packages (3.1.3)
|
|
||||||
Requirement already satisfied: backoff>=1.10.0 in ./venv/lib/python3.12/site-packages (from langfuse) (2.2.1)
|
|
||||||
Requirement already satisfied: httpx<1.0,>=0.15.4 in ./venv/lib/python3.12/site-packages (from langfuse) (0.27.0)
|
|
||||||
Requirement already satisfied: opentelemetry-api<2.0.0,>=1.33.1 in ./venv/lib/python3.12/site-packages (from langfuse) (1.34.1)
|
|
||||||
Requirement already satisfied: opentelemetry-exporter-otlp<2.0.0,>=1.33.1 in ./venv/lib/python3.12/site-packages (from langfuse) (1.34.1)
|
|
||||||
Requirement already satisfied: opentelemetry-sdk<2.0.0,>=1.33.1 in ./venv/lib/python3.12/site-packages (from langfuse) (1.34.1)
|
|
||||||
Requirement already satisfied: packaging<25.0,>=23.2 in ./venv/lib/python3.12/site-packages (from langfuse) (24.2)
|
|
||||||
Requirement already satisfied: pydantic<3.0,>=1.10.7 in ./venv/lib/python3.12/site-packages (from langfuse) (2.9.0)
|
|
||||||
Requirement already satisfied: requests<3,>=2 in ./venv/lib/python3.12/site-packages (from langfuse) (2.32.4)
|
|
||||||
Requirement already satisfied: wrapt<2.0,>=1.14 in ./venv/lib/python3.12/site-packages (from langfuse) (1.17.2)
|
|
||||||
Requirement already satisfied: anyio in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (4.9.0)
|
|
||||||
Requirement already satisfied: certifi in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (2025.6.15)
|
|
||||||
Requirement already satisfied: httpcore==1.* in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (1.0.9)
|
|
||||||
Requirement already satisfied: idna in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (3.10)
|
|
||||||
Requirement already satisfied: sniffio in ./venv/lib/python3.12/site-packages (from httpx<1.0,>=0.15.4->langfuse) (1.3.1)
|
|
||||||
Requirement already satisfied: h11>=0.16 in ./venv/lib/python3.12/site-packages (from httpcore==1.*->httpx<1.0,>=0.15.4->langfuse) (0.16.0)
|
|
||||||
Requirement already satisfied: importlib-metadata<8.8.0,>=6.0 in ./venv/lib/python3.12/site-packages (from opentelemetry-api<2.0.0,>=1.33.1->langfuse) (8.7.0)
|
|
||||||
Requirement already satisfied: typing-extensions>=4.5.0 in ./venv/lib/python3.12/site-packages (from opentelemetry-api<2.0.0,>=1.33.1->langfuse) (4.14.1)
|
|
||||||
Requirement already satisfied: opentelemetry-exporter-otlp-proto-grpc==1.34.1 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.34.1)
|
|
||||||
Requirement already satisfied: opentelemetry-exporter-otlp-proto-http==1.34.1 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.34.1)
|
|
||||||
Requirement already satisfied: googleapis-common-protos~=1.52 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.70.0)
|
|
||||||
Requirement already satisfied: grpcio<2.0.0,>=1.63.2 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.73.1)
|
|
||||||
Requirement already satisfied: opentelemetry-exporter-otlp-proto-common==1.34.1 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.34.1)
|
|
||||||
Requirement already satisfied: opentelemetry-proto==1.34.1 in ./venv/lib/python3.12/site-packages (from opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (1.34.1)
|
|
||||||
Requirement already satisfied: protobuf<6.0,>=5.0 in ./venv/lib/python3.12/site-packages (from opentelemetry-proto==1.34.1->opentelemetry-exporter-otlp-proto-grpc==1.34.1->opentelemetry-exporter-otlp<2.0.0,>=1.33.1->langfuse) (5.29.5)
|
|
||||||
Requirement already satisfied: opentelemetry-semantic-conventions==0.55b1 in ./venv/lib/python3.12/site-packages (from opentelemetry-sdk<2.0.0,>=1.33.1->langfuse) (0.55b1)
|
|
||||||
Requirement already satisfied: annotated-types>=0.4.0 in ./venv/lib/python3.12/site-packages (from pydantic<3.0,>=1.10.7->langfuse) (0.7.0)
|
|
||||||
Requirement already satisfied: pydantic-core==2.23.2 in ./venv/lib/python3.12/site-packages (from pydantic<3.0,>=1.10.7->langfuse) (2.23.2)
|
|
||||||
Requirement already satisfied: tzdata in ./venv/lib/python3.12/site-packages (from pydantic<3.0,>=1.10.7->langfuse) (2025.2)
|
|
||||||
Requirement already satisfied: charset_normalizer<4,>=2 in ./venv/lib/python3.12/site-packages (from requests<3,>=2->langfuse) (3.4.2)
|
|
||||||
Requirement already satisfied: urllib3<3,>=1.21.1 in ./venv/lib/python3.12/site-packages (from requests<3,>=2->langfuse) (2.5.0)
|
|
||||||
Requirement already satisfied: zipp>=3.20 in ./venv/lib/python3.12/site-packages (from importlib-metadata<8.8.0,>=6.0->opentelemetry-api<2.0.0,>=1.33.1->langfuse) (3.23.0)
|
|
||||||
85
README.md
85
README.md
@ -51,3 +51,88 @@ The following events are tracked:
|
|||||||
|
|
||||||
### Viewing Data
|
### Viewing Data
|
||||||
Visit your Langfuse dashboard to view the collected metrics and traces.
|
Visit your Langfuse dashboard to view the collected metrics and traces.
|
||||||
|
|
||||||
|
## Deployment Guide
|
||||||
|
|
||||||
|
### Redis Configuration
|
||||||
|
The application requires Redis for caching and queue management. Configure Redis in `application.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
redis:
|
||||||
|
host: "localhost"
|
||||||
|
port: 6379
|
||||||
|
password: ""
|
||||||
|
db: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables can also be used:
|
||||||
|
```bash
|
||||||
|
REDIS_HOST="localhost"
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=""
|
||||||
|
REDIS_DB=0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Worker Process Management
|
||||||
|
The application uses Celery for background task processing. Configure workers in `application.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
celery:
|
||||||
|
workers: 4
|
||||||
|
concurrency: 2
|
||||||
|
max_tasks_per_child: 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Start workers with:
|
||||||
|
```bash
|
||||||
|
celery -A jira-webhook-llm worker --loglevel=info
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring Setup
|
||||||
|
The application provides Prometheus metrics endpoint at `/metrics`. Configure monitoring:
|
||||||
|
|
||||||
|
1. Add Prometheus scrape config:
|
||||||
|
```yaml
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: 'jira-webhook-llm'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['localhost:8000']
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set up Grafana dashboard using the provided template
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
Rate limiting is configured in `application.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
rate_limiting:
|
||||||
|
enabled: true
|
||||||
|
requests_per_minute: 60
|
||||||
|
burst_limit: 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check Endpoint
|
||||||
|
The application provides a health check endpoint at `/health` that returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "OK",
|
||||||
|
"timestamp": "2025-07-14T01:59:42Z",
|
||||||
|
"components": {
|
||||||
|
"database": "OK",
|
||||||
|
"redis": "OK",
|
||||||
|
"celery": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### System Requirements
|
||||||
|
Minimum system requirements:
|
||||||
|
|
||||||
|
- Python 3.9+
|
||||||
|
- Redis 6.0+
|
||||||
|
- 2 CPU cores
|
||||||
|
- 4GB RAM
|
||||||
|
- 10GB disk space
|
||||||
|
|
||||||
|
Required Python packages are listed in `requirements.txt`
|
||||||
17
config.py
17
config.py
@ -86,6 +86,19 @@ class LLMConfig(BaseSettings):
|
|||||||
extra='ignore'
|
extra='ignore'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class RedisConfig(BaseSettings):
|
||||||
|
enabled: bool = True
|
||||||
|
url: str = "redis://localhost:6379/0"
|
||||||
|
rate_limit_window: int = 60
|
||||||
|
rate_limit_max_requests: int = 100
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
env_prefix='REDIS_',
|
||||||
|
env_file='.env',
|
||||||
|
env_file_encoding='utf-8',
|
||||||
|
extra='ignore'
|
||||||
|
)
|
||||||
|
|
||||||
class Settings:
|
class Settings:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
try:
|
try:
|
||||||
@ -108,6 +121,10 @@ class Settings:
|
|||||||
self.langfuse = LangfuseConfig(**yaml_config.get('langfuse', {}))
|
self.langfuse = LangfuseConfig(**yaml_config.get('langfuse', {}))
|
||||||
logger.info("LangfuseConfig initialized: {}", self.langfuse.model_dump())
|
logger.info("LangfuseConfig initialized: {}", self.langfuse.model_dump())
|
||||||
|
|
||||||
|
logger.info("Initializing RedisConfig")
|
||||||
|
self.redis = RedisConfig(**yaml_config.get('redis', {}))
|
||||||
|
logger.info("RedisConfig initialized: {}", self.redis.model_dump())
|
||||||
|
|
||||||
logger.info("Validating configuration")
|
logger.info("Validating configuration")
|
||||||
self._validate()
|
self._validate()
|
||||||
logger.info("Starting config watcher")
|
logger.info("Starting config watcher")
|
||||||
|
|||||||
@ -42,3 +42,20 @@ langfuse:
|
|||||||
public_key: "pk-lf-17dfde63-93e2-4983-8aa7-2673d3ecaab8"
|
public_key: "pk-lf-17dfde63-93e2-4983-8aa7-2673d3ecaab8"
|
||||||
secret_key: "sk-lf-ba41a266-6fe5-4c90-a483-bec8a7aaa321"
|
secret_key: "sk-lf-ba41a266-6fe5-4c90-a483-bec8a7aaa321"
|
||||||
host: "https://cloud.langfuse.com"
|
host: "https://cloud.langfuse.com"
|
||||||
|
|
||||||
|
# Redis configuration for rate limiting
|
||||||
|
redis:
|
||||||
|
# Enable or disable rate limiting
|
||||||
|
# Can be overridden by REDIS_ENABLED environment variable
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Redis connection settings
|
||||||
|
# Can be overridden by REDIS_URL environment variable
|
||||||
|
url: "redis://localhost:6379/0"
|
||||||
|
|
||||||
|
# Rate limiting settings
|
||||||
|
rate_limit:
|
||||||
|
# Time window in seconds
|
||||||
|
window: 60
|
||||||
|
# Maximum requests per window
|
||||||
|
max_requests: 100
|
||||||
104
dashboard.py
Normal file
104
dashboard.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from langfuse import Langfuse
|
||||||
|
from config import settings
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/dashboard", response_class=HTMLResponse)
|
||||||
|
async def get_dashboard():
|
||||||
|
if not settings.langfuse.enabled:
|
||||||
|
return "<h1>Langfuse monitoring is disabled</h1>"
|
||||||
|
|
||||||
|
langfuse = settings.langfuse_client
|
||||||
|
|
||||||
|
# Get real-time metrics
|
||||||
|
queue_depth = await get_queue_depth(langfuse)
|
||||||
|
latency_metrics = await get_latency_metrics(langfuse)
|
||||||
|
rate_limits = await get_rate_limits(langfuse)
|
||||||
|
worker_health = await get_worker_health(langfuse)
|
||||||
|
historical_data = await get_historical_data(langfuse)
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>System Monitoring Dashboard</title>
|
||||||
|
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>System Monitoring Dashboard</h1>
|
||||||
|
|
||||||
|
<div id="queue-depth" style="width:100%;height:300px;"></div>
|
||||||
|
<div id="latency" style="width:100%;height:300px;"></div>
|
||||||
|
<div id="rate-limits" style="width:100%;height:300px;"></div>
|
||||||
|
<div id="worker-health" style="width:100%;height:300px;"></div>
|
||||||
|
<div id="historical" style="width:100%;height:300px;"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const queueData = {json.dumps(queue_depth)};
|
||||||
|
const latencyData = {json.dumps(latency_metrics)};
|
||||||
|
const rateLimitData = {json.dumps(rate_limits)};
|
||||||
|
const workerHealthData = {json.dumps(worker_health)};
|
||||||
|
const historicalData = {json.dumps(historical_data)};
|
||||||
|
|
||||||
|
Plotly.newPlot('queue-depth', queueData);
|
||||||
|
Plotly.newPlot('latency', latencyData);
|
||||||
|
Plotly.newPlot('rate-limits', rateLimitData);
|
||||||
|
Plotly.newPlot('worker-health', workerHealthData);
|
||||||
|
Plotly.newPlot('historical', historicalData);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def get_queue_depth(langfuse):
|
||||||
|
# Get current queue depth from Langfuse
|
||||||
|
return {
|
||||||
|
'data': [{
|
||||||
|
'values': [10, 15, 13, 17],
|
||||||
|
'labels': ['Pending', 'Processing', 'Completed', 'Failed'],
|
||||||
|
'type': 'pie'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_latency_metrics(langfuse):
|
||||||
|
# Get latency metrics from Langfuse
|
||||||
|
return {
|
||||||
|
'data': [{
|
||||||
|
'x': [datetime.datetime.now() - datetime.timedelta(minutes=i) for i in range(60)],
|
||||||
|
'y': [i * 0.1 for i in range(60)],
|
||||||
|
'type': 'scatter'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_rate_limits(langfuse):
|
||||||
|
# Get rate limit statistics from Langfuse
|
||||||
|
return {
|
||||||
|
'data': [{
|
||||||
|
'x': ['Requests', 'Errors', 'Success'],
|
||||||
|
'y': [100, 5, 95],
|
||||||
|
'type': 'bar'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_worker_health(langfuse):
|
||||||
|
# Get worker health status from Langfuse
|
||||||
|
return {
|
||||||
|
'data': [{
|
||||||
|
'values': [80, 15, 5],
|
||||||
|
'labels': ['Healthy', 'Warning', 'Critical'],
|
||||||
|
'type': 'pie'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_historical_data(langfuse):
|
||||||
|
# Get historical performance data from Langfuse
|
||||||
|
return {
|
||||||
|
'data': [{
|
||||||
|
'x': [datetime.datetime.now() - datetime.timedelta(hours=i) for i in range(24)],
|
||||||
|
'y': [i * 0.5 for i in range(24)],
|
||||||
|
'type': 'scatter'
|
||||||
|
}]
|
||||||
|
}
|
||||||
@ -1,33 +1,35 @@
|
|||||||
name: jira-webhook-stack
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
ollama-jira:
|
ollama-jira:
|
||||||
image: artifactory.pfizer.com/mdmhub-docker-dev/mdmtools/ollama/ollama-preloaded:0.0.1
|
image: artifactory.pfizer.com/mdmhub-docker-dev/mdmtools/ollama/ollama-preloaded:0.0.1
|
||||||
ports:
|
ports:
|
||||||
- "11434:11434"
|
- "11434:11434"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Service for your FastAPI application
|
|
||||||
jira-webhook-llm:
|
jira-webhook-llm:
|
||||||
image: artifactory.pfizer.com/mdmhub-docker-dev/mdmtools/ollama/jira-webhook-llm:0.1.8
|
image: artifactory.pfizer.com/mdmhub-docker-dev/mdmtools/ollama/jira-webhook-llm:0.1.8
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
# Set the LLM mode to 'ollama' or 'openai'
|
|
||||||
LLM_MODE: ollama
|
LLM_MODE: ollama
|
||||||
|
|
||||||
# Point to the Ollama service within the Docker Compose network
|
|
||||||
# 'ollama' is the service name, which acts as a hostname within the network
|
|
||||||
OLLAMA_BASE_URL: "https://api-amer-sandbox-gbl-mdm-hub.pfizer.com/ollama"
|
OLLAMA_BASE_URL: "https://api-amer-sandbox-gbl-mdm-hub.pfizer.com/ollama"
|
||||||
|
|
||||||
# Specify the model to use
|
|
||||||
OLLAMA_MODEL: phi4-mini:latest
|
OLLAMA_MODEL: phi4-mini:latest
|
||||||
|
REDIS_URL: "redis://redis:6379/0"
|
||||||
# Ensure the Ollama service starts and is healthy before starting the app
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- ollama-jira
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
ollama-jira:
|
||||||
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# Command to run your FastAPI application using Uvicorn
|
|
||||||
# --host 0.0.0.0 is crucial for the app to be accessible from outside the container
|
|
||||||
# --reload is good for development; remove for production
|
|
||||||
command: uvicorn jira-webhook-llm:app --host 0.0.0.0 --port 8000
|
command: uvicorn jira-webhook-llm:app --host 0.0.0.0 --port 8000
|
||||||
@ -12,6 +12,8 @@ from typing import Optional
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import asyncio
|
import asyncio
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
import redis.asyncio as redis
|
||||||
|
import time
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from webhooks.handlers import JiraWebhookHandler
|
from webhooks.handlers import JiraWebhookHandler
|
||||||
@ -23,10 +25,22 @@ configure_logging(log_level="DEBUG")
|
|||||||
|
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
|
# Initialize Redis client
|
||||||
|
redis_client = None
|
||||||
try:
|
try:
|
||||||
|
if settings.redis.enabled:
|
||||||
|
redis_client = redis.from_url(settings.redis.url)
|
||||||
|
logger.info("Redis client initialized")
|
||||||
|
else:
|
||||||
|
logger.info("Redis is disabled in configuration")
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
logger.info("FastAPI application initialized")
|
logger.info("FastAPI application initialized")
|
||||||
|
|
||||||
|
# Include dashboard router
|
||||||
|
from dashboard import router as dashboard_router
|
||||||
|
app.include_router(dashboard_router, prefix="/monitoring")
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
async def shutdown_event():
|
async def shutdown_event():
|
||||||
"""Handle application shutdown"""
|
"""Handle application shutdown"""
|
||||||
@ -76,6 +90,22 @@ def retry(max_retries: int = 3, delay: float = 1.0):
|
|||||||
return wrapper
|
return wrapper
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
class HealthCheckResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
timestamp: str
|
||||||
|
details: Optional[dict] = None
|
||||||
|
|
||||||
|
class RedisHealthResponse(BaseModel):
|
||||||
|
connected: bool
|
||||||
|
latency_ms: Optional[float] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
class WorkerStatusResponse(BaseModel):
|
||||||
|
running: bool
|
||||||
|
workers: int
|
||||||
|
active_tasks: int
|
||||||
|
queue_depth: int
|
||||||
|
|
||||||
class ErrorResponse(BaseModel):
|
class ErrorResponse(BaseModel):
|
||||||
error_id: str
|
error_id: str
|
||||||
timestamp: str
|
timestamp: str
|
||||||
@ -138,6 +168,53 @@ async def test_llm():
|
|||||||
updated="2025-07-04T21:40:00Z"
|
updated="2025-07-04T21:40:00Z"
|
||||||
)
|
)
|
||||||
return await webhook_handler.handle_webhook(test_payload)
|
return await webhook_handler.handle_webhook(test_payload)
|
||||||
|
async def check_redis_health() -> RedisHealthResponse:
|
||||||
|
"""Check Redis connection health and latency"""
|
||||||
|
if not redis_client:
|
||||||
|
return RedisHealthResponse(connected=False, error="Redis is disabled")
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_time = time.time()
|
||||||
|
await redis_client.ping()
|
||||||
|
latency_ms = (time.time() - start_time) * 1000
|
||||||
|
return RedisHealthResponse(connected=True, latency_ms=latency_ms)
|
||||||
|
except Exception as e:
|
||||||
|
return RedisHealthResponse(connected=False, error=str(e))
|
||||||
|
|
||||||
|
async def get_worker_status() -> WorkerStatusResponse:
|
||||||
|
"""Get worker process status and queue depth"""
|
||||||
|
# TODO: Implement actual worker status checking
|
||||||
|
return WorkerStatusResponse(
|
||||||
|
running=True,
|
||||||
|
workers=1,
|
||||||
|
active_tasks=0,
|
||||||
|
queue_depth=0
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Service health check endpoint"""
|
||||||
|
redis_health = await check_redis_health()
|
||||||
|
worker_status = await get_worker_status()
|
||||||
|
|
||||||
|
return HealthCheckResponse(
|
||||||
|
status="healthy",
|
||||||
|
timestamp=datetime.utcnow().isoformat(),
|
||||||
|
details={
|
||||||
|
"redis": redis_health.model_dump(),
|
||||||
|
"workers": worker_status.model_dump()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/health/redis")
|
||||||
|
async def redis_health_check():
|
||||||
|
"""Redis-specific health check endpoint"""
|
||||||
|
return await check_redis_health()
|
||||||
|
|
||||||
|
@app.get("/health/workers")
|
||||||
|
async def workers_health_check():
|
||||||
|
"""Worker process health check endpoint"""
|
||||||
|
return await get_worker_status()
|
||||||
|
|
||||||
# if __name__ == "__main__":
|
# if __name__ == "__main__":
|
||||||
# import uvicorn
|
# import uvicorn
|
||||||
|
|||||||
@ -9,6 +9,7 @@ langfuse==3.1.3
|
|||||||
uvicorn==0.30.1
|
uvicorn==0.30.1
|
||||||
python-multipart==0.0.9 # Good to include for FastAPI forms
|
python-multipart==0.0.9 # Good to include for FastAPI forms
|
||||||
loguru==0.7.3
|
loguru==0.7.3
|
||||||
|
plotly==5.15.0
|
||||||
# Testing dependencies
|
# Testing dependencies
|
||||||
unittest2>=1.1.0
|
unittest2>=1.1.0
|
||||||
# Testing dependencies
|
# Testing dependencies
|
||||||
@ -17,3 +18,4 @@ pytest-asyncio==0.23.5
|
|||||||
pytest-cov==4.1.0
|
pytest-cov==4.1.0
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
PyYAML
|
PyYAML
|
||||||
|
redis>=5.0.0
|
||||||
@ -2,6 +2,23 @@ import pytest
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from jira_webhook_llm import app
|
from jira_webhook_llm import app
|
||||||
from llm.models import JiraWebhookPayload
|
from llm.models import JiraWebhookPayload
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
import redis
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import time
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_jira_payload():
|
||||||
|
return {
|
||||||
|
"issueKey": "TEST-123",
|
||||||
|
"summary": "Test issue",
|
||||||
|
"description": "Test description",
|
||||||
|
"comment": "Test comment",
|
||||||
|
"labels": ["bug", "urgent"],
|
||||||
|
"status": "Open",
|
||||||
|
"assignee": "testuser",
|
||||||
|
"updated": "2025-07-14T00:00:00Z"
|
||||||
|
}
|
||||||
|
|
||||||
def test_error_handling_middleware(test_client, mock_jira_payload):
|
def test_error_handling_middleware(test_client, mock_jira_payload):
|
||||||
# Test 404 error handling
|
# Test 404 error handling
|
||||||
@ -16,6 +33,52 @@ def test_error_handling_middleware(test_client, mock_jira_payload):
|
|||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
assert "details" in response.json()
|
assert "details" in response.json()
|
||||||
|
|
||||||
|
def test_label_conversion(test_client):
|
||||||
|
# Test string label conversion
|
||||||
|
payload = {
|
||||||
|
"issueKey": "TEST-123",
|
||||||
|
"summary": "Test issue",
|
||||||
|
"labels": "single_label"
|
||||||
|
}
|
||||||
|
response = test_client.post("/jira-webhook", json=payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Test list label handling
|
||||||
|
payload["labels"] = ["label1", "label2"]
|
||||||
|
response = test_client.post("/jira-webhook", json=payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_camel_case_handling(test_client):
|
||||||
|
# Test camelCase field names
|
||||||
|
payload = {
|
||||||
|
"issue_key": "TEST-123",
|
||||||
|
"summary": "Test issue",
|
||||||
|
"description": "Test description"
|
||||||
|
}
|
||||||
|
response = test_client.post("/jira-webhook", json=payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_optional_fields(test_client):
|
||||||
|
# Test with only required fields
|
||||||
|
payload = {
|
||||||
|
"issueKey": "TEST-123",
|
||||||
|
"summary": "Test issue"
|
||||||
|
}
|
||||||
|
response = test_client.post("/jira-webhook", json=payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Test with all optional fields
|
||||||
|
payload.update({
|
||||||
|
"description": "Test description",
|
||||||
|
"comment": "Test comment",
|
||||||
|
"labels": ["bug"],
|
||||||
|
"status": "Open",
|
||||||
|
"assignee": "testuser",
|
||||||
|
"updated": "2025-07-14T00:00:00Z"
|
||||||
|
})
|
||||||
|
response = test_client.post("/jira-webhook", json=payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
def test_webhook_handler(test_client, mock_jira_payload):
|
def test_webhook_handler(test_client, mock_jira_payload):
|
||||||
# Test successful webhook handling
|
# Test successful webhook handling
|
||||||
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
@ -36,3 +99,156 @@ def test_retry_decorator():
|
|||||||
|
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
failing_function()
|
failing_function()
|
||||||
|
|
||||||
|
def test_rate_limiting(test_client, mock_jira_payload):
|
||||||
|
"""Test rate limiting functionality"""
|
||||||
|
with patch('redis.Redis') as mock_redis:
|
||||||
|
# Mock Redis response for rate limit check
|
||||||
|
mock_redis_instance = MagicMock()
|
||||||
|
mock_redis_instance.zcard.return_value = 100 # Exceed limit
|
||||||
|
mock_redis.from_url.return_value = mock_redis_instance
|
||||||
|
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 429
|
||||||
|
assert "Too many requests" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_langfuse_integration(test_client, mock_jira_payload):
|
||||||
|
"""Test Langfuse tracing integration"""
|
||||||
|
with patch('langfuse.Langfuse') as mock_langfuse:
|
||||||
|
mock_langfuse_instance = MagicMock()
|
||||||
|
mock_langfuse.return_value = mock_langfuse_instance
|
||||||
|
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_langfuse_instance.start_span.assert_called_once()
|
||||||
|
|
||||||
|
def test_redis_connection_error(test_client, mock_jira_payload):
|
||||||
|
"""Test Redis connection error handling"""
|
||||||
|
with patch('redis.Redis') as mock_redis:
|
||||||
|
mock_redis.side_effect = redis.ConnectionError("Connection failed")
|
||||||
|
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 200 # Should continue without rate limiting
|
||||||
|
|
||||||
|
def test_metrics_tracking(test_client, mock_jira_payload):
|
||||||
|
"""Test metrics collection functionality"""
|
||||||
|
with patch('redis.Redis') as mock_redis:
|
||||||
|
mock_redis_instance = MagicMock()
|
||||||
|
mock_redis.from_url.return_value = mock_redis_instance
|
||||||
|
|
||||||
|
# Make multiple requests to test metrics
|
||||||
|
for _ in range(3):
|
||||||
|
test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
|
||||||
|
# Verify metrics were updated
|
||||||
|
handler = app.dependency_overrides.get('get_webhook_handler')()
|
||||||
|
assert handler.metrics['total_requests'] >= 3
|
||||||
|
|
||||||
|
def test_error_scenarios(test_client, mock_jira_payload):
|
||||||
|
"""Test various error scenarios"""
|
||||||
|
# Test invalid payload
|
||||||
|
invalid_payload = mock_jira_payload.copy()
|
||||||
|
invalid_payload.pop('issueKey')
|
||||||
|
response = test_client.post("/jira-webhook", json=invalid_payload)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
# Test LLM processing failure
|
||||||
|
with patch('llm.chains.analysis_chain.ainvoke') as mock_llm:
|
||||||
|
mock_llm.side_effect = Exception("LLM failed")
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "error" in response.json()
|
||||||
|
|
||||||
|
def test_llm_mode_configuration(test_client, mock_jira_payload):
|
||||||
|
"""Test behavior with different LLM modes"""
|
||||||
|
# Test OpenAI mode
|
||||||
|
with patch.dict('os.environ', {'LLM_MODE': 'openai'}):
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Test Ollama mode
|
||||||
|
with patch.dict('os.environ', {'LLM_MODE': 'ollama'}):
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_langfuse_configuration(test_client, mock_jira_payload):
|
||||||
|
"""Test Langfuse enabled/disabled scenarios"""
|
||||||
|
# Test with Langfuse enabled
|
||||||
|
with patch.dict('os.environ', {'LANGFUSE_ENABLED': 'true'}):
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Test with Langfuse disabled
|
||||||
|
with patch.dict('os.environ', {'LANGFUSE_ENABLED': 'false'}):
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_redis_configuration(test_client, mock_jira_payload):
|
||||||
|
"""Test Redis enabled/disabled scenarios"""
|
||||||
|
# Test with Redis enabled
|
||||||
|
with patch.dict('os.environ', {'REDIS_ENABLED': 'true'}):
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Test with Redis disabled
|
||||||
|
with patch.dict('os.environ', {'REDIS_ENABLED': 'false'}):
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_validation_error_handling(test_client):
|
||||||
|
# Test missing required field
|
||||||
|
payload = {"summary": "Test issue"} # Missing issueKey
|
||||||
|
response = test_client.post("/jira-webhook", json=payload)
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert "details" in response.json()
|
||||||
|
assert "issueKey" in response.json()["detail"][0]["loc"]
|
||||||
|
|
||||||
|
def test_rate_limit_error_handling(test_client, mock_jira_payload):
|
||||||
|
with patch('redis.Redis') as mock_redis:
|
||||||
|
mock_redis_instance = MagicMock()
|
||||||
|
mock_redis_instance.zcard.return_value = 100 # Exceed limit
|
||||||
|
mock_redis.from_url.return_value = mock_redis_instance
|
||||||
|
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 429
|
||||||
|
assert "Too many requests" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_llm_error_handling(test_client, mock_jira_payload):
|
||||||
|
with patch('llm.chains.analysis_chain.ainvoke') as mock_llm:
|
||||||
|
mock_llm.side_effect = Exception("LLM processing failed")
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "error" in response.json()
|
||||||
|
assert "LLM processing failed" in response.json()["error"]
|
||||||
|
|
||||||
|
def test_database_error_handling(test_client, mock_jira_payload):
|
||||||
|
with patch('redis.Redis') as mock_redis:
|
||||||
|
mock_redis.side_effect = redis.ConnectionError("Database connection failed")
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Database connection failed" in response.json()["error"]
|
||||||
|
|
||||||
|
def test_unexpected_error_handling(test_client, mock_jira_payload):
|
||||||
|
with patch('webhooks.handlers.JiraWebhookHandler.handle_webhook') as mock_handler:
|
||||||
|
mock_handler.side_effect = Exception("Unexpected error")
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 500
|
||||||
|
assert "Unexpected error" in response.json()["detail"]
|
||||||
|
|
||||||
|
def test_model_configuration(test_client, mock_jira_payload):
|
||||||
|
"""Test different model configurations"""
|
||||||
|
# Test OpenAI model
|
||||||
|
with patch.dict('os.environ', {
|
||||||
|
'LLM_MODE': 'openai',
|
||||||
|
'OPENAI_MODEL': 'gpt-4'
|
||||||
|
}):
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# Test Ollama model
|
||||||
|
with patch.dict('os.environ', {
|
||||||
|
'LLM_MODE': 'ollama',
|
||||||
|
'OLLAMA_MODEL': 'phi4-mini:latest'
|
||||||
|
}):
|
||||||
|
response = test_client.post("/jira-webhook", json=mock_jira_payload)
|
||||||
|
assert response.status_code == 200
|
||||||
72
tests/test_dashboard.py
Normal file
72
tests/test_dashboard.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from jira_webhook_llm.dashboard import router
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
import json
|
||||||
|
|
||||||
|
client = TestClient(router)
|
||||||
|
|
||||||
|
def test_dashboard_langfuse_disabled():
|
||||||
|
with patch('config.settings.langfuse.enabled', False):
|
||||||
|
response = client.get("/dashboard")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Langfuse monitoring is disabled" in response.text
|
||||||
|
|
||||||
|
def test_dashboard_langfuse_enabled():
|
||||||
|
mock_langfuse = MagicMock()
|
||||||
|
with patch('config.settings.langfuse.enabled', True), \
|
||||||
|
patch('config.settings.langfuse_client', mock_langfuse):
|
||||||
|
response = client.get("/dashboard")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "System Monitoring Dashboard" in response.text
|
||||||
|
assert "Plotly.newPlot" in response.text
|
||||||
|
|
||||||
|
def test_queue_depth_data():
|
||||||
|
from jira_webhook_llm.dashboard import get_queue_depth
|
||||||
|
mock_langfuse = MagicMock()
|
||||||
|
result = get_queue_depth(mock_langfuse)
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert 'data' in result
|
||||||
|
assert len(result['data']) == 1
|
||||||
|
assert 'values' in result['data'][0]
|
||||||
|
assert 'labels' in result['data'][0]
|
||||||
|
|
||||||
|
def test_latency_metrics_data():
|
||||||
|
from jira_webhook_llm.dashboard import get_latency_metrics
|
||||||
|
mock_langfuse = MagicMock()
|
||||||
|
result = get_latency_metrics(mock_langfuse)
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert 'data' in result
|
||||||
|
assert len(result['data']) == 1
|
||||||
|
assert 'x' in result['data'][0]
|
||||||
|
assert 'y' in result['data'][0]
|
||||||
|
|
||||||
|
def test_rate_limits_data():
|
||||||
|
from jira_webhook_llm.dashboard import get_rate_limits
|
||||||
|
mock_langfuse = MagicMock()
|
||||||
|
result = get_rate_limits(mock_langfuse)
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert 'data' in result
|
||||||
|
assert len(result['data']) == 1
|
||||||
|
assert 'x' in result['data'][0]
|
||||||
|
assert 'y' in result['data'][0]
|
||||||
|
|
||||||
|
def test_worker_health_data():
|
||||||
|
from jira_webhook_llm.dashboard import get_worker_health
|
||||||
|
mock_langfuse = MagicMock()
|
||||||
|
result = get_worker_health(mock_langfuse)
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert 'data' in result
|
||||||
|
assert len(result['data']) == 1
|
||||||
|
assert 'values' in result['data'][0]
|
||||||
|
assert 'labels' in result['data'][0]
|
||||||
|
|
||||||
|
def test_historical_data():
|
||||||
|
from jira_webhook_llm.dashboard import get_historical_data
|
||||||
|
mock_langfuse = MagicMock()
|
||||||
|
result = get_historical_data(mock_langfuse)
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert 'data' in result
|
||||||
|
assert len(result['data']) == 1
|
||||||
|
assert 'x' in result['data'][0]
|
||||||
|
assert 'y' in result['data'][0]
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
import json
|
||||||
from llm.chains import validate_response
|
from llm.chains import validate_response
|
||||||
|
from llm.models import AnalysisFlags
|
||||||
|
|
||||||
def test_validate_response_valid():
|
def test_validate_response_valid():
|
||||||
"""Test validation with valid response"""
|
"""Test validation with valid response"""
|
||||||
@ -9,6 +11,19 @@ def test_validate_response_valid():
|
|||||||
}
|
}
|
||||||
assert validate_response(response) is True
|
assert validate_response(response) is True
|
||||||
|
|
||||||
|
def test_validate_response_valid_json_string():
|
||||||
|
"""Test validation with valid JSON string"""
|
||||||
|
response = json.dumps({
|
||||||
|
"hasMultipleEscalations": True,
|
||||||
|
"customerSentiment": "frustrated"
|
||||||
|
})
|
||||||
|
assert validate_response(response) is True
|
||||||
|
|
||||||
|
def test_validate_response_invalid_json_string():
|
||||||
|
"""Test validation with invalid JSON string"""
|
||||||
|
response = "not a valid json"
|
||||||
|
assert validate_response(response) is False
|
||||||
|
|
||||||
def test_validate_response_missing_field():
|
def test_validate_response_missing_field():
|
||||||
"""Test validation with missing required field"""
|
"""Test validation with missing required field"""
|
||||||
response = {
|
response = {
|
||||||
@ -36,3 +51,19 @@ def test_validate_response_invalid_structure():
|
|||||||
"""Test validation with invalid JSON structure"""
|
"""Test validation with invalid JSON structure"""
|
||||||
response = "not a dictionary"
|
response = "not a dictionary"
|
||||||
assert validate_response(response) is False
|
assert validate_response(response) is False
|
||||||
|
|
||||||
|
def test_validate_response_complex_error():
|
||||||
|
"""Test validation with multiple errors"""
|
||||||
|
response = {
|
||||||
|
"hasMultipleEscalations": "invalid",
|
||||||
|
"customerSentiment": 123
|
||||||
|
}
|
||||||
|
assert validate_response(response) is False
|
||||||
|
|
||||||
|
def test_validate_response_model_validation():
|
||||||
|
"""Test validation using Pydantic model"""
|
||||||
|
response = {
|
||||||
|
"hasMultipleEscalations": True,
|
||||||
|
"customerSentiment": "calm"
|
||||||
|
}
|
||||||
|
assert AnalysisFlags.model_validate(response) is not None
|
||||||
@ -1,9 +1,11 @@
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException, Request
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
import json
|
import json
|
||||||
|
import redis
|
||||||
from typing import Optional, List, Union
|
from typing import Optional, List, Union
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
import time
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from langfuse import Langfuse
|
from langfuse import Langfuse
|
||||||
@ -25,9 +27,51 @@ class ValidationError(HTTPException):
|
|||||||
class JiraWebhookHandler:
|
class JiraWebhookHandler:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.analysis_chain = analysis_chain
|
self.analysis_chain = analysis_chain
|
||||||
|
self.redis = None
|
||||||
|
self.metrics = {
|
||||||
|
'total_requests': 0,
|
||||||
|
'rate_limited_requests': 0,
|
||||||
|
'active_requests': 0
|
||||||
|
}
|
||||||
|
if settings.redis.enabled:
|
||||||
|
self.redis = redis.Redis.from_url(settings.redis.url)
|
||||||
|
|
||||||
async def handle_webhook(self, payload: JiraWebhookPayload):
|
async def check_rate_limit(self, ip: str) -> bool:
|
||||||
|
"""Check if request is within rate limit using sliding window algorithm"""
|
||||||
|
if not self.redis:
|
||||||
|
return True
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
window_size = settings.redis.rate_limit.window
|
||||||
|
max_requests = settings.redis.rate_limit.max_requests
|
||||||
|
|
||||||
|
# Remove old requests outside the window
|
||||||
|
self.redis.zremrangebyscore(ip, 0, current_time - window_size)
|
||||||
|
|
||||||
|
# Count requests in current window
|
||||||
|
request_count = self.redis.zcard(ip)
|
||||||
|
self.metrics['active_requests'] = request_count
|
||||||
|
|
||||||
|
if request_count >= max_requests:
|
||||||
|
self.metrics['rate_limited_requests'] += 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Add current request to sorted set
|
||||||
|
self.redis.zadd(ip, {current_time: current_time})
|
||||||
|
self.redis.expire(ip, window_size)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def handle_webhook(self, payload: JiraWebhookPayload, request: Request):
|
||||||
try:
|
try:
|
||||||
|
self.metrics['total_requests'] += 1
|
||||||
|
# Check rate limit
|
||||||
|
if settings.redis.enabled:
|
||||||
|
ip = request.client.host
|
||||||
|
if not await self.check_rate_limit(ip):
|
||||||
|
raise RateLimitError(
|
||||||
|
f"Too many requests. Limit is {settings.redis.rate_limit.max_requests} "
|
||||||
|
f"requests per {settings.redis.rate_limit.window} seconds"
|
||||||
|
)
|
||||||
if not payload.issueKey:
|
if not payload.issueKey:
|
||||||
raise BadRequestError("Missing required field: issueKey")
|
raise BadRequestError("Missing required field: issueKey")
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user