"""
resilience.py — production resilience helpers (Knowledge Module 07 §2).

Azure OpenAI returns 429 when you exceed TPM/RPM; a banking service MUST handle
it (and any transient 5xx) gracefully, and MUST be idempotent for any
state-mutating call so a retried 'transfer' can't double-execute.
"""
from __future__ import annotations

import random
import time
from typing import Callable


class RateLimited(Exception):
    """Stand-in for an Azure OpenAI 429, carrying a Retry-After hint (seconds)."""
    def __init__(self, retry_after: float = 0.0):
        self.retry_after = retry_after
        super().__init__("429 Too Many Requests")


def retry_with_backoff(fn: Callable, *, max_attempts: int = 4, base: float = 0.01,
                       sleep: Callable[[float], None] = time.sleep):
    """Exponential backoff with jitter, honoring Retry-After. `sleep` is injected
    so tests run instantly. Returns fn()'s result or raises after max_attempts."""
    for attempt in range(max_attempts):
        try:
            return fn()
        except RateLimited as e:
            if attempt == max_attempts - 1:
                raise
            delay = max(e.retry_after, base * (2 ** attempt)) + random.uniform(0, base)
            sleep(delay)


class IdempotencyCache:
    """Maps an idempotency key -> the response produced the first time. A retried
    request with the same key returns the cached response instead of re-executing
    — essential for money-moving endpoints (K07 §2)."""
    def __init__(self) -> None:
        self._seen: dict[str, dict] = {}

    def get(self, key: str) -> dict | None:
        return self._seen.get(key)

    def put(self, key: str, response: dict) -> None:
        self._seen[key] = response
