| Server IP : 3.147.158.171 / Your IP : 216.73.216.88 Web Server : Apache/2.4.67 (Amazon Linux) OpenSSL/3.5.5 System : Linux ip-172-31-2-178.us-east-2.compute.internal 6.1.172-216.329.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Wed May 20 06:31:34 UTC 2026 x86_64 User : ec2-user ( 1000) PHP Version : 8.4.21 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /tsai/www/html/api.turmansolutions.ai/app/services/ |
Upload File : |
"""Credit enforcement for metered operations.
Gates expensive/automated work (article generation, image studio, AMA research)
on a site's remaining Scike Credits. The store owns the balance and the
recharge/cap decision; this guard converts the API's usage log into a spend
level, asks the store for a verdict, and caches it briefly to keep hot paths
cheap.
Design invariants:
- Fail open. If the store is unreachable / unconfigured, or the site has no
credit account, operations proceed — billing infra must never hard-block
generation.
- The store is the decision-maker; this module never decides recharge/cap on
its own.
"""
import logging
import time
from datetime import datetime
from typing import Any, Dict, Optional
from . import credits_client, usage_history
# Short in-process caches. Per-worker; that's fine — the store stays the source
# of truth and re-evaluates whenever a cache entry expires.
_STATUS_TTL = 60.0
_VERDICT_TTL = 20.0
_status_cache: Dict[str, tuple] = {}
_verdict_cache: Dict[str, tuple] = {}
# Verdict returned when we can't reach the store — allow, but mark it so callers
# can tell an explicit "you're fine" from a fail-open.
_FAIL_OPEN = {'allowed': True, 'remaining': None, 'reason': 'unavailable', 'status': 'unknown'}
class CreditsExhausted(Exception):
"""Raised by ensure_allowed() when a site is out of credits and can't recharge."""
def __init__(self, slug: str, reason: Optional[str], remaining: Optional[float] = None):
self.slug = slug
self.reason = reason or 'no_credits'
self.remaining = remaining
super().__init__(f"Site '{slug}' is out of credits (paused: {self.reason})")
async def _cached_status(slug: str) -> Optional[Dict[str, Any]]:
hit = _status_cache.get(slug)
if hit and (time.monotonic() - hit[0]) < _STATUS_TTL:
return hit[1]
status = await credits_client.status(slug)
_status_cache[slug] = (time.monotonic(), status)
return status
async def check_credits(slug: str, operation: str = 'operation') -> Dict[str, Any]:
"""Return the current verdict for a site (non-raising).
Verdict shape: {allowed: bool, remaining: float|None, reason: str|None,
status: str, recharged?: bool}.
"""
if not slug or not credits_client.is_configured():
return dict(_FAIL_OPEN)
cached = _verdict_cache.get(slug)
if cached and (time.monotonic() - cached[0]) < _VERDICT_TTL:
return cached[1]
status = await _cached_status(slug)
if not status:
# No account (legacy/manual site) or store unavailable → fail open.
return dict(_FAIL_OPEN)
period_start = status.get('periodStart')
try:
since = datetime.fromtimestamp(int(period_start)) if period_start else datetime.now()
except (TypeError, ValueError, OSError):
since = datetime.now()
used = usage_history.used_credits_since(slug, since)
verdict = await credits_client.check(slug, used)
if verdict is None:
return dict(_FAIL_OPEN)
_verdict_cache[slug] = (time.monotonic(), verdict)
# A recharge or setting change invalidates the cached status balance.
if verdict.get('recharged'):
_status_cache.pop(slug, None)
return verdict
async def ensure_allowed(slug: str, operation: str = 'operation') -> None:
"""Raise CreditsExhausted if the site can't run a paid `operation`."""
verdict = await check_credits(slug, operation)
if not verdict.get('allowed', True):
logging.info(
"Credits guard denied %s for %s (reason=%s, remaining=%s)",
operation, slug, verdict.get('reason'), verdict.get('remaining'),
)
raise CreditsExhausted(slug, verdict.get('reason'), verdict.get('remaining'))
def invalidate(slug: str) -> None:
"""Drop cached state for a slug (call after a settings change)."""
_status_cache.pop(slug, None)
_verdict_cache.pop(slug, None)