Spaces:
Build error
Build error
Commit ·
ea4be14
1
Parent(s): 390a689
Upload folder using huggingface_hub
Browse files- app/core/usage_tracker.py +119 -47
- app/main.py +47 -2
app/core/usage_tracker.py
CHANGED
|
@@ -104,6 +104,14 @@ class UsageTracker:
|
|
| 104 |
Extended to support tenant isolation: each API key is linked to a tenant.
|
| 105 |
"""
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
def __init__(self, db_path: str = "arf_usage.db",
|
| 108 |
redis_url: Optional[str] = None,
|
| 109 |
pepper: Optional[str] = None):
|
|
@@ -181,30 +189,77 @@ class UsageTracker:
|
|
| 181 |
Rows come back as dict-like objects (row["col"]) via RealDictCursor,
|
| 182 |
matching the sqlite3.Row access pattern used elsewhere in this file.
|
| 183 |
|
| 184 |
-
Retries the initial connect with backoff
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
if not hasattr(self._local, "pg_conn") or self._local.pg_conn.closed:
|
| 193 |
-
|
| 194 |
-
for attempt in range(_PG_CONNECT_MAX_ATTEMPTS):
|
| 195 |
-
try:
|
| 196 |
-
self._local.pg_conn = psycopg2.connect(
|
| 197 |
-
self._pg_dsn, cursor_factory=psycopg2.extras.RealDictCursor)
|
| 198 |
-
last_exc = None
|
| 199 |
-
break
|
| 200 |
-
except psycopg2.OperationalError as exc:
|
| 201 |
-
last_exc = exc
|
| 202 |
-
if attempt < _PG_CONNECT_MAX_ATTEMPTS - 1:
|
| 203 |
-
time.sleep(_PG_CONNECT_BACKOFF_BASE * (2 ** attempt))
|
| 204 |
-
if last_exc is not None:
|
| 205 |
-
raise last_exc
|
| 206 |
yield self._local.pg_conn
|
| 207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
@staticmethod
|
| 209 |
def _pg_execute(conn, sql: str, params: tuple = ()):
|
| 210 |
"""Run a query against a Postgres connection and return the cursor,
|
|
@@ -214,9 +269,13 @@ class UsageTracker:
|
|
| 214 |
cur.execute(sql, params)
|
| 215 |
return cur
|
| 216 |
|
| 217 |
-
def
|
| 218 |
"""Idempotently ensure the Postgres api_keys table/index exist.
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
The canonical schema is the Alembic migration
|
| 221 |
(alembic/versions/*_create_api_keys_table.py) -- but nothing in this
|
| 222 |
codebase runs `alembic upgrade head` automatically on deploy (a
|
|
@@ -226,33 +285,46 @@ class UsageTracker:
|
|
| 226 |
already uses for its SQLite tables keeps both services (and tests)
|
| 227 |
working whether or not the migration has actually been applied.
|
| 228 |
Column set/types must stay in sync with that migration."""
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
conn.commit()
|
| 252 |
|
| 253 |
def _init_db(self):
|
| 254 |
-
"""Initialise SQLite tables for usage_log/monthly_counts/idempotency_keys.
|
| 255 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
with self._get_conn() as conn:
|
| 257 |
conn.execute("""
|
| 258 |
CREATE TABLE IF NOT EXISTS usage_log (
|
|
|
|
| 104 |
Extended to support tenant isolation: each API key is linked to a tenant.
|
| 105 |
"""
|
| 106 |
|
| 107 |
+
# Whether the Postgres api_keys schema check has already run in this
|
| 108 |
+
# process. Class-level, not per-instance: the schema is a property of
|
| 109 |
+
# the database, not of a tracker object, and every instance in a
|
| 110 |
+
# process points at the same DATABASE_URL. Guarded by a lock because
|
| 111 |
+
# _get_pg_conn is called from request threads.
|
| 112 |
+
_pg_schema_ready: bool = False
|
| 113 |
+
_pg_schema_lock = threading.Lock()
|
| 114 |
+
|
| 115 |
def __init__(self, db_path: str = "arf_usage.db",
|
| 116 |
redis_url: Optional[str] = None,
|
| 117 |
pepper: Optional[str] = None):
|
|
|
|
| 189 |
Rows come back as dict-like objects (row["col"]) via RealDictCursor,
|
| 190 |
matching the sqlite3.Row access pattern used elsewhere in this file.
|
| 191 |
|
| 192 |
+
Retries the initial connect with backoff, but how patiently depends
|
| 193 |
+
on who is asking:
|
| 194 |
+
|
| 195 |
+
- **Startup** (`warm_up`, `retries=True`): a short DNS blip during a
|
| 196 |
+
cold container boot has been observed on Render, so a few seconds
|
| 197 |
+
of retrying is worth it to come up cleanly.
|
| 198 |
+
- **Request path** (the default, `retries=False`): fails fast. A
|
| 199 |
+
request thread that blocks for 15s on a database outage doesn't
|
| 200 |
+
make the request succeed; it holds a worker thread hostage, and
|
| 201 |
+
under any concurrency the pool is exhausted and the whole service
|
| 202 |
+
stops responding -- including its health endpoint. A prompt 503 is
|
| 203 |
+
strictly better than a slow one.
|
| 204 |
+
|
| 205 |
+
Genuine connection errors (bad credentials, wrong host) still raise
|
| 206 |
+
either way, preserving fail-closed."""
|
| 207 |
if not hasattr(self._local, "pg_conn") or self._local.pg_conn.closed:
|
| 208 |
+
self._connect_pg(retries=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
yield self._local.pg_conn
|
| 210 |
|
| 211 |
+
def _connect_pg(self, retries: bool) -> None:
|
| 212 |
+
"""Open this thread's Postgres connection, optionally retrying."""
|
| 213 |
+
attempts = _PG_CONNECT_MAX_ATTEMPTS if retries else 1
|
| 214 |
+
last_exc: Optional[psycopg2.OperationalError] = None
|
| 215 |
+
for attempt in range(attempts):
|
| 216 |
+
try:
|
| 217 |
+
self._local.pg_conn = psycopg2.connect(
|
| 218 |
+
self._pg_dsn, cursor_factory=psycopg2.extras.RealDictCursor)
|
| 219 |
+
last_exc = None
|
| 220 |
+
break
|
| 221 |
+
except psycopg2.OperationalError as exc:
|
| 222 |
+
last_exc = exc
|
| 223 |
+
if attempt < attempts - 1:
|
| 224 |
+
time.sleep(_PG_CONNECT_BACKOFF_BASE * (2 ** attempt))
|
| 225 |
+
if last_exc is not None:
|
| 226 |
+
raise last_exc
|
| 227 |
+
self._ensure_pg_schema(self._local.pg_conn)
|
| 228 |
+
|
| 229 |
+
def warm_up(self) -> bool:
|
| 230 |
+
"""Best-effort startup connection, with retries.
|
| 231 |
+
|
| 232 |
+
Returns True if Postgres is reachable and the api_keys schema is
|
| 233 |
+
ready. Returns False -- rather than raising -- when it isn't, so a
|
| 234 |
+
caller can log the degradation and still start serving. That
|
| 235 |
+
asymmetry is the point: an unreachable database at boot should cost
|
| 236 |
+
api_keys-backed functionality, not the entire service.
|
| 237 |
+
"""
|
| 238 |
+
try:
|
| 239 |
+
self._connect_pg(retries=True)
|
| 240 |
+
return True
|
| 241 |
+
except psycopg2.OperationalError:
|
| 242 |
+
return False
|
| 243 |
+
|
| 244 |
+
def _ensure_pg_schema(self, conn) -> None:
|
| 245 |
+
"""Run the api_keys schema check once per process, on the first
|
| 246 |
+
connection that actually succeeds.
|
| 247 |
+
|
| 248 |
+
Guarded by a process-wide flag rather than done in __init__ so a
|
| 249 |
+
database that is unreachable at startup doesn't prevent the tracker
|
| 250 |
+
from existing -- it just means the first request that needs
|
| 251 |
+
Postgres pays for the schema check, and requests before that get a
|
| 252 |
+
clean 503 from enforce_quota instead of the whole service being
|
| 253 |
+
down. The statements are all IF NOT EXISTS, so re-running them on a
|
| 254 |
+
later process is harmless."""
|
| 255 |
+
if UsageTracker._pg_schema_ready:
|
| 256 |
+
return
|
| 257 |
+
with UsageTracker._pg_schema_lock:
|
| 258 |
+
if UsageTracker._pg_schema_ready:
|
| 259 |
+
return
|
| 260 |
+
self._create_pg_schema(conn)
|
| 261 |
+
UsageTracker._pg_schema_ready = True
|
| 262 |
+
|
| 263 |
@staticmethod
|
| 264 |
def _pg_execute(conn, sql: str, params: tuple = ()):
|
| 265 |
"""Run a query against a Postgres connection and return the cursor,
|
|
|
|
| 269 |
cur.execute(sql, params)
|
| 270 |
return cur
|
| 271 |
|
| 272 |
+
def _create_pg_schema(self, conn):
|
| 273 |
"""Idempotently ensure the Postgres api_keys table/index exist.
|
| 274 |
|
| 275 |
+
Takes an already-open connection rather than acquiring one, because
|
| 276 |
+
its only caller is _ensure_pg_schema, which runs *from inside*
|
| 277 |
+
_get_pg_conn -- acquiring another connection here would recurse.
|
| 278 |
+
|
| 279 |
The canonical schema is the Alembic migration
|
| 280 |
(alembic/versions/*_create_api_keys_table.py) -- but nothing in this
|
| 281 |
codebase runs `alembic upgrade head` automatically on deploy (a
|
|
|
|
| 285 |
already uses for its SQLite tables keeps both services (and tests)
|
| 286 |
working whether or not the migration has actually been applied.
|
| 287 |
Column set/types must stay in sync with that migration."""
|
| 288 |
+
self._pg_execute(conn, """
|
| 289 |
+
CREATE TABLE IF NOT EXISTS api_keys (
|
| 290 |
+
id SERIAL PRIMARY KEY,
|
| 291 |
+
tenant_id VARCHAR(64) NOT NULL,
|
| 292 |
+
tier VARCHAR(32) NOT NULL,
|
| 293 |
+
created_at TIMESTAMP NOT NULL,
|
| 294 |
+
last_used_at TIMESTAMP,
|
| 295 |
+
is_active BOOLEAN NOT NULL DEFAULT true,
|
| 296 |
+
salt VARCHAR(64) NOT NULL,
|
| 297 |
+
key_hash VARCHAR(64) NOT NULL,
|
| 298 |
+
lookup_hash VARCHAR(64) NOT NULL
|
| 299 |
+
)
|
| 300 |
+
""")
|
| 301 |
+
self._pg_execute(conn, """
|
| 302 |
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_api_keys_lookup_hash
|
| 303 |
+
ON api_keys (lookup_hash)
|
| 304 |
+
""")
|
| 305 |
+
self._pg_execute(conn, """
|
| 306 |
+
CREATE INDEX IF NOT EXISTS ix_api_keys_tenant_id
|
| 307 |
+
ON api_keys (tenant_id)
|
| 308 |
+
""")
|
| 309 |
+
conn.commit()
|
|
|
|
| 310 |
|
| 311 |
def _init_db(self):
|
| 312 |
+
"""Initialise SQLite tables for usage_log/monthly_counts/idempotency_keys.
|
| 313 |
+
|
| 314 |
+
Deliberately does NOT touch Postgres. The api_keys schema is
|
| 315 |
+
ensured lazily on the first successful Postgres connection instead
|
| 316 |
+
(see _ensure_pg_schema) so that constructing a UsageTracker never
|
| 317 |
+
depends on the database being reachable *at that instant*.
|
| 318 |
+
|
| 319 |
+
This is the difference between a degraded service and no service.
|
| 320 |
+
Every other subsystem in main.py's lifespan already degrades
|
| 321 |
+
gracefully when Postgres is unreachable -- the Beta-state loader
|
| 322 |
+
logs a warning and continues -- but init_tracker raised, and
|
| 323 |
+
main.py turns that into RuntimeError, killing the process. On
|
| 324 |
+
Render that produced a crash loop that outlived the port-detection
|
| 325 |
+
window, so a DNS failure lasting seconds took the whole deploy
|
| 326 |
+
down and left the previous release serving.
|
| 327 |
+
"""
|
| 328 |
with self._get_conn() as conn:
|
| 329 |
conn.execute("""
|
| 330 |
CREATE TABLE IF NOT EXISTS usage_log (
|
app/main.py
CHANGED
|
@@ -270,8 +270,45 @@ async def lifespan(app: FastAPI):
|
|
| 270 |
db_path=os.getenv("ARF_USAGE_DB_PATH", "arf_usage.db"),
|
| 271 |
redis_url=os.getenv("ARF_REDIS_URL"),
|
| 272 |
)
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
api_keys_json = os.getenv("ARF_API_KEYS", "{}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
try:
|
| 276 |
api_keys = json.loads(api_keys_json)
|
| 277 |
for key, tier_str in api_keys.items():
|
|
@@ -302,8 +339,16 @@ async def lifespan(app: FastAPI):
|
|
| 302 |
"ARF_API_KEYS environment variable is not valid JSON; skipping seeding."
|
| 303 |
)
|
| 304 |
app.state.usage_tracker = tracker
|
| 305 |
-
|
|
|
|
|
|
|
|
|
|
| 306 |
except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
logger.critical(f"Failed to initialise usage tracker: {e}")
|
| 308 |
raise RuntimeError("Usage tracker initialisation failed") from e
|
| 309 |
else:
|
|
|
|
| 270 |
db_path=os.getenv("ARF_USAGE_DB_PATH", "arf_usage.db"),
|
| 271 |
redis_url=os.getenv("ARF_REDIS_URL"),
|
| 272 |
)
|
| 273 |
+
|
| 274 |
+
# Constructing the tracker no longer touches Postgres, so warm
|
| 275 |
+
# it deliberately here: a few seconds of retrying is worth it to
|
| 276 |
+
# come up with the api_keys schema ready.
|
| 277 |
+
#
|
| 278 |
+
# A failure is logged and survived, NOT raised. Everything above
|
| 279 |
+
# in this lifespan already degrades that way -- the Beta-state
|
| 280 |
+
# loader logs a warning on the identical error and continues --
|
| 281 |
+
# and the asymmetry here is what turned a database blip into a
|
| 282 |
+
# total outage: raising crash-loops the process, which on Render
|
| 283 |
+
# exhausts the port-detection window, fails the deploy, and
|
| 284 |
+
# leaves the previous release serving. Starting degraded means
|
| 285 |
+
# the health endpoint answers, the deploy succeeds, and API
|
| 286 |
+
# requests get a clean 503 from enforce_quota until the database
|
| 287 |
+
# is reachable -- at which point they recover with no redeploy.
|
| 288 |
+
postgres_ready = tracker.warm_up()
|
| 289 |
+
if not postgres_ready:
|
| 290 |
+
logger.error(
|
| 291 |
+
"Usage tracker started WITHOUT a Postgres connection: api_keys "
|
| 292 |
+
"is unreachable, so API-key validation and quota enforcement "
|
| 293 |
+
"will fail (503) until it recovers. Check that DATABASE_URL's "
|
| 294 |
+
"host resolves from this service, and that the database is "
|
| 295 |
+
"running and in the same region."
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
# Seed initial API keys from environment variable (for testing
|
| 299 |
+
# / demo). Skipped entirely when Postgres is unreachable: every
|
| 300 |
+
# get_or_create_api_key below is a write to api_keys, so
|
| 301 |
+
# attempting it would raise straight back into the handler that
|
| 302 |
+
# kills the process -- reintroducing the crash loop the warm-up
|
| 303 |
+
# above exists to prevent.
|
| 304 |
api_keys_json = os.getenv("ARF_API_KEYS", "{}")
|
| 305 |
+
if not postgres_ready and api_keys_json not in ("", "{}"):
|
| 306 |
+
logger.warning(
|
| 307 |
+
"Skipping ARF_API_KEYS seeding: Postgres is unreachable. "
|
| 308 |
+
"Seeded keys will not exist until the database recovers "
|
| 309 |
+
"and the service is restarted."
|
| 310 |
+
)
|
| 311 |
+
api_keys_json = "{}"
|
| 312 |
try:
|
| 313 |
api_keys = json.loads(api_keys_json)
|
| 314 |
for key, tier_str in api_keys.items():
|
|
|
|
| 339 |
"ARF_API_KEYS environment variable is not valid JSON; skipping seeding."
|
| 340 |
)
|
| 341 |
app.state.usage_tracker = tracker
|
| 342 |
+
if postgres_ready:
|
| 343 |
+
logger.info("✅ Usage tracker ready.")
|
| 344 |
+
else:
|
| 345 |
+
logger.warning("⚠️ Usage tracker started in degraded mode (no Postgres).")
|
| 346 |
except Exception as e:
|
| 347 |
+
# Still fail closed on genuine configuration errors -- a missing
|
| 348 |
+
# or too-short ARF_KEY_PEPPER, an unset DATABASE_URL. Those never
|
| 349 |
+
# self-resolve, and starting without them would mean serving with
|
| 350 |
+
# broken API-key hashing. Database *reachability* is handled
|
| 351 |
+
# above and no longer reaches here.
|
| 352 |
logger.critical(f"Failed to initialise usage tracker: {e}")
|
| 353 |
raise RuntimeError("Usage tracker initialisation failed") from e
|
| 354 |
else:
|