petter2025 commited on
Commit
ea4be14
·
1 Parent(s): 390a689

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. app/core/usage_tracker.py +119 -47
  2. 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 -- Render's internal
185
- "dpg-*" hostname has been observed unresolvable for the first
186
- couple of seconds after a cold container boot (2026-08-25 incident:
187
- two consecutive "could not translate host name" crashes, then a
188
- clean connect on the third restart with no config change). A DNS
189
- race should not require a crash loop to self-heal; other genuine
190
- connection errors (bad credentials, wrong host) still raise after
191
- the attempts below are exhausted, preserving fail-closed."""
 
 
 
 
 
 
 
192
  if not hasattr(self._local, "pg_conn") or self._local.pg_conn.closed:
193
- last_exc: Optional[psycopg2.OperationalError] = None
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 _init_pg_db(self):
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
- with self._get_pg_conn() as conn:
230
- self._pg_execute(conn, """
231
- CREATE TABLE IF NOT EXISTS api_keys (
232
- id SERIAL PRIMARY KEY,
233
- tenant_id VARCHAR(64) NOT NULL,
234
- tier VARCHAR(32) NOT NULL,
235
- created_at TIMESTAMP NOT NULL,
236
- last_used_at TIMESTAMP,
237
- is_active BOOLEAN NOT NULL DEFAULT true,
238
- salt VARCHAR(64) NOT NULL,
239
- key_hash VARCHAR(64) NOT NULL,
240
- lookup_hash VARCHAR(64) NOT NULL
241
- )
242
- """)
243
- self._pg_execute(conn, """
244
- CREATE UNIQUE INDEX IF NOT EXISTS uq_api_keys_lookup_hash
245
- ON api_keys (lookup_hash)
246
- """)
247
- self._pg_execute(conn, """
248
- CREATE INDEX IF NOT EXISTS ix_api_keys_tenant_id
249
- ON api_keys (tenant_id)
250
- """)
251
- conn.commit()
252
 
253
  def _init_db(self):
254
- """Initialise SQLite tables for usage_log/monthly_counts/idempotency_keys."""
255
- self._init_pg_db()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Seed initial API keys from environment variable (for testing / demo)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- logger.info("✅ Usage tracker ready.")
 
 
 
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: