diff --git a/accounts/api_accounts.py b/accounts/api_accounts.py index d9ddfff..9a5cf2f 100644 --- a/accounts/api_accounts.py +++ b/accounts/api_accounts.py @@ -91,7 +91,10 @@ async def create_subscription( if not label: # Cache-only lookup — never block the event loop on Nominatim. The frontend # normally supplies the label from /api/v2/place; this is just a fallback. - _, label = climate.reverse_geocode_cached(cell["center_lat"], cell["center_lon"]) + # Still a sync DB read under the hood, so keep it off the event loop too. + _, label = await run_in_threadpool( + climate.reverse_geocode_cached, cell["center_lat"], cell["center_lon"] + ) sub = Subscription( user_id=user.id, diff --git a/accounts/users.py b/accounts/users.py index 80a1e16..a08fb46 100644 --- a/accounts/users.py +++ b/accounts/users.py @@ -9,6 +9,7 @@ import secrets import uuid from fastapi import Depends, Request +from fastapi.concurrency import run_in_threadpool from fastapi_users import BaseUserManager, FastAPIUsers, InvalidPasswordException, UUIDIDMixin from fastapi_users.authentication import AuthenticationBackend, CookieTransport from fastapi_users.authentication.strategy.db import DatabaseStrategy @@ -89,7 +90,10 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): if not mailer.enabled(): return link = f"{BASE_URL}{BASE}/?verify_token={token}" - mailer.send( + # mailer.send() opens a blocking SMTP connection — fires on every + # register/resend-verification request, so keep it off the event loop. + await run_in_threadpool( + mailer.send, user.email, "Confirm your Thermograph account", "Welcome to Thermograph! Confirm your email address to finish setting up " diff --git a/notifications/digest.py b/notifications/digest.py index 9e557d7..9ac5433 100644 --- a/notifications/digest.py +++ b/notifications/digest.py @@ -13,6 +13,7 @@ import re import time from fastapi import Request, Response +from fastapi.concurrency import run_in_threadpool from fastapi.responses import JSONResponse from sqlalchemy import select @@ -67,7 +68,9 @@ async def _store(email: str, place: str | None, source: str | None) -> None: # Nothing is sent until a real mail backend is configured; the console # backend logs it so the flow can be verified end-to-end without a server. if mailer.enabled(): - mailer.send( + # Blocking SMTP connect+send — keep it off the event loop. + await run_in_threadpool( + mailer.send, email, "Confirm your Thermograph digest", "Thanks for signing up for the Thermograph monthly digest.\n\n" @@ -103,7 +106,8 @@ async def signup(request: Request) -> Response: await _store(email, place, source) except Exception: # noqa: BLE001 - never surface storage detail to the form pass - metrics.record_event( + await run_in_threadpool( + metrics.record_event, "home.digest_signup", referer=request.headers.get("referer"), host=request.headers.get("host"), diff --git a/web/app.py b/web/app.py index 35f664e..29c1062 100644 --- a/web/app.py +++ b/web/app.py @@ -12,6 +12,7 @@ import time import httpx from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response +from fastapi.concurrency import run_in_threadpool from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import RedirectResponse @@ -249,12 +250,16 @@ async def revalidate_static(request, call_next): path = request.url.path try: cat = metrics.classify_inbound(path, BASE) - metrics.record_inbound(cat, response.status_code) + # Both are blocking (sync DB write, sync file write) — run off the event + # loop so one request's instrumentation can't stall every other request + # this worker is serving concurrently. + await run_in_threadpool(metrics.record_inbound, cat, response.status_code) # Retain per-request client IPs for later analysis; skip static assets and the # dashboard's own metrics polling to keep the log to real, meaningful traffic. if cat not in ("static", "metrics", "event"): - audit.log_access({"ip": _client_ip(request), "method": request.method, - "path": path, "status": response.status_code, "cat": cat}) + await run_in_threadpool(audit.log_access, { + "ip": _client_ip(request), "method": request.method, + "path": path, "status": response.status_code, "cat": cat}) except Exception: # noqa: BLE001 - never let instrumentation break a response pass pages = (BASE, f"{BASE}/", f"{BASE}/calendar", f"{BASE}/day", f"{BASE}/score", @@ -724,7 +729,8 @@ async def api_event(request: Request) -> Response: except Exception: # noqa: BLE001 - a junk body is just a no-op event pass if name: - metrics.record_event( + await run_in_threadpool( + metrics.record_event, name, referer=request.headers.get("referer"), host=request.headers.get("host"), @@ -810,7 +816,10 @@ async def discord_interactions(request: Request) -> Response: """Discord posts each slash-command interaction here. Verification runs over the RAW body, so this must read bytes, not request.json().""" raw = await request.body() - status, payload = discord_interactions_mod.handle( + # handle() does real work for /grade (sync parquet/DB reads + grading math), + # not just the cheap signature check — keep it off the event loop. + status, payload = await run_in_threadpool( + discord_interactions_mod.handle, raw, request.headers.get("x-signature-ed25519", ""), request.headers.get("x-signature-timestamp", ""),