recipe: harden auth/metrics, add rate limiting and tests
This commit is contained in:
+72
-65
@@ -1,19 +1,24 @@
|
||||
import json
|
||||
import os
|
||||
import hmac
|
||||
import time
|
||||
|
||||
import requests
|
||||
from flask import Flask, jsonify, request
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
try:
|
||||
from recipe_service.metrics import initialize, record, request_context, usage_json
|
||||
from recipe_service.metrics import count_since, initialize, record, request_context, usage_json
|
||||
except ModuleNotFoundError:
|
||||
from metrics import initialize, record, request_context, usage_json
|
||||
from metrics import count_since, initialize, record, request_context, usage_json
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
|
||||
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
PROXY_URL = "http://127.0.0.1:8768/gemini"
|
||||
RATE_LIMIT_REQUESTS_PER_MINUTE = 20
|
||||
|
||||
initialize()
|
||||
|
||||
@@ -31,7 +36,18 @@ def settings() -> dict:
|
||||
def authorized() -> bool:
|
||||
expected = os.environ.get("RECIPE_API_TOKEN")
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
return bool(expected and authorization == f"Bearer {expected}")
|
||||
return bool(expected and hmac.compare_digest(authorization, f"Bearer {expected}"))
|
||||
|
||||
|
||||
def minute_start_utc(epoch_seconds: float) -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:00Z", time.gmtime(epoch_seconds))
|
||||
|
||||
|
||||
def is_rate_limited(client_ip: str, now_epoch: float) -> bool:
|
||||
if not client_ip:
|
||||
return False
|
||||
window_start = minute_start_utc(now_epoch)
|
||||
return count_since(client_ip=client_ip, started_at_from=window_start) >= RATE_LIMIT_REQUESTS_PER_MINUTE
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -49,54 +65,58 @@ def recipe():
|
||||
image_mime = None
|
||||
image_bytes = None
|
||||
prompt_chars = None
|
||||
status_code = 500
|
||||
response_bytes = None
|
||||
usage = {}
|
||||
error = None
|
||||
if not authorized():
|
||||
status_code = 401
|
||||
error = "unauthorized"
|
||||
response = jsonify(error=error)
|
||||
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
|
||||
user_agent=request.user_agent.string, method=request.method, path=request.path,
|
||||
image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars,
|
||||
status_code=status_code, duration_ms=int((__import__('time').monotonic() - started_monotonic) * 1000),
|
||||
response_bytes=len(response.get_data()), usage_json=usage_json(usage), error=error)
|
||||
client_ip = request.remote_addr
|
||||
|
||||
def finalize(response, status_code: int, error: str | None):
|
||||
duration_ms = int((time.monotonic() - started_monotonic) * 1000)
|
||||
response_bytes = len(response.get_data())
|
||||
record(
|
||||
request_id=request_id,
|
||||
started_at=started_at,
|
||||
client_ip=client_ip,
|
||||
user_agent=request.user_agent.string,
|
||||
method=request.method,
|
||||
path=request.path,
|
||||
image_mime=image_mime,
|
||||
image_bytes=image_bytes,
|
||||
prompt_chars=prompt_chars,
|
||||
status_code=status_code,
|
||||
duration_ms=duration_ms,
|
||||
response_bytes=response_bytes,
|
||||
usage_json=usage_json(usage),
|
||||
error=error,
|
||||
)
|
||||
return response, status_code
|
||||
|
||||
if not authorized():
|
||||
return finalize(jsonify(error="unauthorized"), 401, "unauthorized")
|
||||
|
||||
if is_rate_limited(client_ip=client_ip or "", now_epoch=time.time()):
|
||||
return finalize(
|
||||
jsonify(error="too many requests", code="rate_limited"),
|
||||
429,
|
||||
"rate_limited",
|
||||
)
|
||||
|
||||
image = request.files.get("image")
|
||||
prompt = request.form.get("prompt")
|
||||
if image is None or not prompt:
|
||||
status_code = 400
|
||||
error = "image and prompt are required"
|
||||
response = jsonify(error=error)
|
||||
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
|
||||
user_agent=request.user_agent.string, method=request.method, path=request.path,
|
||||
status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()),
|
||||
usage_json=usage_json(usage), error=error)
|
||||
return response, status_code
|
||||
return finalize(jsonify(error="image and prompt are required"), 400, "image and prompt are required")
|
||||
|
||||
prompt_chars = len(prompt)
|
||||
|
||||
if image.mimetype not in ALLOWED_TYPES:
|
||||
status_code = 415
|
||||
error = "unsupported image type"
|
||||
response = jsonify(error=error)
|
||||
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
|
||||
user_agent=request.user_agent.string, method=request.method, path=request.path,
|
||||
image_mime=image.mimetype, prompt_chars=len(prompt), status_code=status_code,
|
||||
duration_ms=0, response_bytes=len(response.get_data()), usage_json=usage_json(usage), error=error)
|
||||
return response, status_code
|
||||
image_mime = image.mimetype
|
||||
return finalize(jsonify(error="unsupported image type"), 415, "unsupported image type")
|
||||
|
||||
image_data = image.read(MAX_IMAGE_BYTES + 1)
|
||||
if len(image_data) > MAX_IMAGE_BYTES:
|
||||
status_code = 413
|
||||
error = "image is too large"
|
||||
response = jsonify(error=error)
|
||||
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
|
||||
user_agent=request.user_agent.string, method=request.method, path=request.path,
|
||||
image_mime=image.mimetype, image_bytes=len(image_data), prompt_chars=len(prompt),
|
||||
status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()),
|
||||
usage_json=usage_json(usage), error=error)
|
||||
return response, status_code
|
||||
image_mime = image.mimetype
|
||||
image_bytes = len(image_data)
|
||||
prompt_chars = len(prompt)
|
||||
|
||||
if len(image_data) > MAX_IMAGE_BYTES:
|
||||
return finalize(jsonify(error="image is too large"), 413, "image is too large")
|
||||
|
||||
try:
|
||||
config = settings()
|
||||
response = requests.post(
|
||||
@@ -107,34 +127,21 @@ def recipe():
|
||||
)
|
||||
except (requests.RequestException, RuntimeError) as exc:
|
||||
error = str(exc) if isinstance(exc, RuntimeError) else "Gemini unavailable"
|
||||
status_code = 503
|
||||
response = jsonify(error=error)
|
||||
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
|
||||
user_agent=request.user_agent.string, method=request.method, path=request.path,
|
||||
image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars,
|
||||
status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()),
|
||||
usage_json=usage_json(usage), error=error)
|
||||
return response, status_code
|
||||
return finalize(jsonify(error=error), 503, error)
|
||||
|
||||
if response.status_code != 200:
|
||||
try:
|
||||
detail = response.json().get("error", {}).get("message", "Gemini request failed")
|
||||
except ValueError:
|
||||
detail = "Gemini request failed"
|
||||
error = detail
|
||||
status_code = 502
|
||||
response = jsonify(error=error)
|
||||
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
|
||||
user_agent=request.user_agent.string, method=request.method, path=request.path,
|
||||
image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars,
|
||||
status_code=status_code, duration_ms=0, response_bytes=len(response.get_data()),
|
||||
usage_json=usage_json(usage), error=error)
|
||||
return response, status_code
|
||||
app.logger.warning("upstream_failure request_id=%s detail=%s", request_id, detail)
|
||||
return finalize(
|
||||
jsonify(error="upstream recognition failed", code="upstream_error"),
|
||||
502,
|
||||
"upstream_error",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
usage = data.get("usage", {})
|
||||
result = jsonify(text=data.get("text"), usage=usage)
|
||||
record(request_id=request_id, started_at=started_at, client_ip=request.remote_addr,
|
||||
user_agent=request.user_agent.string, method=request.method, path=request.path,
|
||||
image_mime=image_mime, image_bytes=image_bytes, prompt_chars=prompt_chars,
|
||||
status_code=200, duration_ms=0, response_bytes=len(result.get_data()),
|
||||
usage_json=usage_json(usage), error=None)
|
||||
return result
|
||||
return finalize(result, 200, None)
|
||||
Reference in New Issue
Block a user