Files
2026-07-13 13:22:34 +08:00

242 lines
7.2 KiB
Python

import base64
import hashlib
import hmac
import itertools
import json
import sys
from pathlib import Path
import fastapi
import uvicorn
from fastapi import HTTPException, Request
from mlflow.webhooks.constants import (
WEBHOOK_DELIVERY_ID_HEADER,
WEBHOOK_SIGNATURE_HEADER,
WEBHOOK_SIGNATURE_VERSION,
WEBHOOK_TIMESTAMP_HEADER,
)
LOG_FILE = Path("logs.jsonl")
app = fastapi.FastAPI()
@app.get("/health")
async def health_check():
return {"status": "ok"}
@app.post("/insecure-webhook")
async def insecure_webhook(request: Request):
payload = await request.json()
# Extract the data field from webhook payload
actual_payload = payload.get("data", payload)
webhook_data = {
"endpoint": "/insecure-webhook",
"payload": actual_payload,
"headers": dict(request.headers),
"status_code": 200,
"error": None,
}
with LOG_FILE.open("a") as f:
f.write(json.dumps(webhook_data) + "\n")
return {"status": "received"}
@app.post("/reset")
async def reset():
"""Reset both logs and counters for testing"""
global flaky_counter, rate_limited_counter
# Clear logs
if LOG_FILE.exists():
LOG_FILE.open("w").close()
# Reset all counters
flaky_counter = itertools.count(1)
rate_limited_counter = itertools.count(1)
return {"status": "reset complete", "logs": "cleared", "counters": "reset"}
@app.get("/logs")
async def get_logs():
if not LOG_FILE.exists():
return {"logs": []}
with LOG_FILE.open("r") as f:
logs = [json.loads(s) for line in f if (s := line.strip())]
return {"logs": logs}
# Secret key for HMAC verification (in real world, this would be stored securely)
WEBHOOK_SECRET = "test-secret-key"
def verify_webhook_signature(
payload: str, signature: str, delivery_id: str, timestamp: str
) -> bool:
if not signature or not signature.startswith(f"{WEBHOOK_SIGNATURE_VERSION},"):
return False
# Signature format: delivery_id.timestamp.payload
signed_content = f"{delivery_id}.{timestamp}.{payload}"
expected_signature = hmac.new(
WEBHOOK_SECRET.encode("utf-8"), signed_content.encode("utf-8"), hashlib.sha256
).digest()
expected_signature_b64 = base64.b64encode(expected_signature).decode("utf-8")
provided_signature = signature.removeprefix(f"{WEBHOOK_SIGNATURE_VERSION},")
return hmac.compare_digest(expected_signature_b64, provided_signature)
@app.post("/secure-webhook")
async def secure_webhook(request: Request):
body = await request.body()
signature = request.headers.get(WEBHOOK_SIGNATURE_HEADER)
timestamp = request.headers.get(WEBHOOK_TIMESTAMP_HEADER)
delivery_id = request.headers.get(WEBHOOK_DELIVERY_ID_HEADER)
if not signature:
error_data = {
"endpoint": "/secure-webhook",
"headers": dict(request.headers),
"status_code": 400,
"payload": None,
"error": "Missing signature header",
}
with LOG_FILE.open("a") as f:
f.write(json.dumps(error_data) + "\n")
raise HTTPException(status_code=400, detail="Missing signature header")
if not timestamp:
error_data = {
"endpoint": "/secure-webhook",
"error": "Missing timestamp header",
"status_code": 400,
"headers": dict(request.headers),
}
with LOG_FILE.open("a") as f:
f.write(json.dumps(error_data) + "\n")
raise HTTPException(status_code=400, detail="Missing timestamp header")
if not delivery_id:
error_data = {
"endpoint": "/secure-webhook",
"error": "Missing delivery ID header",
"status_code": 400,
"headers": dict(request.headers),
}
with LOG_FILE.open("a") as f:
f.write(json.dumps(error_data) + "\n")
raise HTTPException(status_code=400, detail="Missing delivery ID header")
if not verify_webhook_signature(body.decode("utf-8"), signature, delivery_id, timestamp):
error_data = {
"endpoint": "/secure-webhook",
"headers": dict(request.headers),
"status_code": 401,
"payload": None,
"error": "Invalid signature",
}
with LOG_FILE.open("a") as f:
f.write(json.dumps(error_data) + "\n")
raise HTTPException(status_code=401, detail="Invalid signature")
payload = json.loads(body)
# Extract the data field from webhook payload
actual_payload = payload.get("data", payload)
webhook_data = {
"endpoint": "/secure-webhook",
"payload": actual_payload,
"headers": dict(request.headers),
"status_code": 200,
"error": None,
}
with LOG_FILE.open("a") as f:
f.write(json.dumps(webhook_data) + "\n")
return {"status": "received", "signature": "verified"}
# Create separate counters for each endpoint using itertools.count
flaky_counter = itertools.count(1)
rate_limited_counter = itertools.count(1)
@app.post("/flaky-webhook")
async def flaky_webhook(request: Request):
"""Endpoint that fails initially but succeeds after retries"""
attempt = next(flaky_counter)
payload = await request.json()
actual_payload = payload.get("data", payload)
# Log the attempt
webhook_data = {
"endpoint": "/flaky-webhook",
"payload": actual_payload,
"headers": dict(request.headers),
"attempt": attempt,
"error": None,
}
# Fail on first two attempts with 500 error
if attempt <= 2:
webhook_data["status_code"] = 500
webhook_data["error"] = "Server error (will retry)"
with LOG_FILE.open("a") as f:
f.write(json.dumps(webhook_data) + "\n")
raise HTTPException(status_code=500, detail="Internal server error")
# Succeed on third attempt
webhook_data["status_code"] = 200
with LOG_FILE.open("a") as f:
f.write(json.dumps(webhook_data) + "\n")
return {"status": "received", "attempt": attempt}
@app.post("/rate-limited-webhook")
async def rate_limited_webhook(request: Request):
"""Endpoint that returns 429 with Retry-After header"""
attempt = next(rate_limited_counter)
payload = await request.json()
actual_payload = payload.get("data", payload)
# Log the attempt
webhook_data = {
"endpoint": "/rate-limited-webhook",
"payload": actual_payload,
"headers": dict(request.headers),
"attempt": attempt,
"error": None,
}
# Return 429 on first attempt
if attempt == 1:
webhook_data["status_code"] = 429
webhook_data["error"] = "Rate limited"
with LOG_FILE.open("a") as f:
f.write(json.dumps(webhook_data) + "\n")
# Return 429 with Retry-After header
response = fastapi.Response(content="Rate limited", status_code=429)
response.headers["Retry-After"] = "2"
return response
# Succeed on second attempt
webhook_data["status_code"] = 200
with LOG_FILE.open("a") as f:
f.write(json.dumps(webhook_data) + "\n")
return {"status": "received", "attempt": attempt}
if __name__ == "__main__":
port = sys.argv[1]
uvicorn.run(app, host="0.0.0.0", port=int(port))