Files
TimeTracker/app/utils/api_idempotency.py
T
Dries Peeters 5df748b30e feat(api): v1 CSV import, bulk time entries, idempotency, and rate limits
- Add POST /api/v1/time-entries/import-csv and POST /api/v1/time-entries/bulk.
- Support Idempotency-Key on POST /api/v1/time-entries with 24h replay per token.
- Enforce per-token minute/hour limits (Redis when available, local fallback otherwise).
- Extract bulk and CSV import logic into dedicated services; trim legacy api route surface.
2026-04-05 08:39:14 +02:00

78 lines
2.2 KiB
Python

"""Idempotency-Key header handling for API v1 writes."""
from __future__ import annotations
import hashlib
import json
import logging
from datetime import datetime, timedelta
from typing import Any, Optional, Tuple
from flask import Response, jsonify
from app import db
from app.models.api_idempotency_key import ApiIdempotencyKey
from app.utils.db import safe_commit
logger = logging.getLogger(__name__)
IDEMPOTENCY_TTL_HOURS = 24
MAX_KEY_LEN = 128
SCOPE_POST_TIME_ENTRY = "post:time_entries"
def _hash_key(raw: str) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def normalize_idempotency_key(header_value: Optional[str]) -> Optional[str]:
if not header_value or not isinstance(header_value, str):
return None
s = header_value.strip()
if not s or len(s) > MAX_KEY_LEN:
return None
return s
def lookup_idempotent_response(api_token_id: int, scope: str, key: str) -> Optional[Tuple[int, str]]:
row = ApiIdempotencyKey.query.filter_by(
api_token_id=api_token_id,
scope=scope,
key_hash=_hash_key(key),
).first()
if not row:
return None
cutoff = datetime.utcnow() - timedelta(hours=IDEMPOTENCY_TTL_HOURS)
if row.created_at < cutoff:
try:
db.session.delete(row)
safe_commit("idempotency_expired_cleanup", {})
except Exception as e:
logger.debug("Idempotency cleanup: %s", e)
return None
return row.response_status, row.response_body
def store_idempotent_response(api_token_id: int, scope: str, key: str, status_code: int, body_dict: Any) -> None:
body_json = json.dumps(body_dict, default=str)
row = ApiIdempotencyKey(
api_token_id=api_token_id,
scope=scope,
key_hash=_hash_key(key),
response_status=status_code,
response_body=body_json,
)
db.session.add(row)
if not safe_commit("api_idempotency_store", {"scope": scope}):
logger.warning("Failed to store idempotency key for token %s", api_token_id)
def replay_response(status_code: int, body_json: str) -> Response:
try:
data = json.loads(body_json)
except Exception:
data = {"message": body_json}
resp = jsonify(data)
resp.status_code = status_code
return resp