fix(api): a token hiccup is not "reconnect"; reorders and approvals are one unit each
Deep review of the debt burn: - `refusal_reason` told the user to reconnect on ANY token-endpoint failure — a 5xx, a 429, a misconfigured client — while the grant stayed and the account still read as connected, with no connect button. Only `invalid_grant` (the one that drops the grant) means reconnect now. - `pins.reorder` writes only the rows that change, so two overlapping reorders each skipped rows that matched in their own stale snapshot and interleaved into two pins on one slot. It locks the user's pins first; the second waits and deals from the first's result. - Adding a whitelist entry committed the invite and the activation it implies separately, and its audit row was written before a new invite had an id (target NULL). One transaction, flushed for the id. - The mutation convention test skipped async routes and dicts returned by name: request-access, purge-discovery and the subscription sync answered without `ok`. They do now, and the test covers both. The helper-commit check looked only at `_`-names; it reads every non-route function, which listed two deliberate committers (upsert_pending_invite, ensure_welcome) — now allowlisted with their reasons.
This commit is contained in:
@@ -84,7 +84,10 @@ def reorder(db: Session, user: User, ids: list[int]) -> None:
|
||||
ones jumped about when they came back. So the pins left out keep their slots, and the listed ones
|
||||
are dealt into the slots the listed set occupies. Ids that are not this user's, or repeated, are
|
||||
ignored rather than failing a stale replay wholesale. Flushes; the route commits."""
|
||||
current = list_pins(db, user)
|
||||
# Locked: the rows it leaves alone are part of the answer too. Two overlapping reorders each
|
||||
# skipped the rows that already matched in their own snapshot and interleaved into two pins on
|
||||
# one slot; the second now waits and deals from the first one's result.
|
||||
current = list_pins(db, user, lock=True)
|
||||
owned = {p.id for p in current}
|
||||
listed: list[int] = []
|
||||
for i in ids:
|
||||
@@ -100,16 +103,13 @@ def reorder(db: Session, user: User, ids: list[int]) -> None:
|
||||
db.flush()
|
||||
|
||||
|
||||
def list_pins(db: Session, user: User) -> list[ComfortPin]:
|
||||
return list(
|
||||
db.execute(
|
||||
select(ComfortPin)
|
||||
.where(ComfortPin.user_id == user.id)
|
||||
.order_by(ComfortPin.position.asc(), ComfortPin.id.asc())
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
def list_pins(db: Session, user: User, *, lock: bool = False) -> list[ComfortPin]:
|
||||
stmt = (
|
||||
select(ComfortPin)
|
||||
.where(ComfortPin.user_id == user.id)
|
||||
.order_by(ComfortPin.position.asc(), ComfortPin.id.asc())
|
||||
)
|
||||
return list(db.execute(stmt.with_for_update() if lock else stmt).scalars().all())
|
||||
|
||||
|
||||
def next_position(db: Session, user: User) -> int:
|
||||
|
||||
@@ -264,10 +264,11 @@ def add_invite(
|
||||
inv.status = "approved"
|
||||
inv.decided_at = datetime.now(timezone.utc)
|
||||
inv.decided_by = admin.email
|
||||
db.flush() # a new invite's id, for the audit row — before it, the row named no target
|
||||
audit.record(
|
||||
db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email
|
||||
)
|
||||
db.commit()
|
||||
# One transaction: the whitelisting and the activation it implies land together or not at all.
|
||||
_activate_user_by_email(db, email)
|
||||
db.commit()
|
||||
return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
|
||||
@@ -855,4 +856,4 @@ def purge_discovery(admin: User = Depends(admin_user), db: Session = Depends(get
|
||||
after=result,
|
||||
)
|
||||
db.commit()
|
||||
return result
|
||||
return {"ok": True, **result}
|
||||
|
||||
@@ -656,13 +656,13 @@ async def request_access(
|
||||
if not valid_email(email):
|
||||
raise refused(400, "invalid_email")
|
||||
if not _request_access_limiter.allow(client_key(request)):
|
||||
return {"status": "pending"}
|
||||
return {"ok": True, "status": "pending"}
|
||||
if is_allowed(db, email):
|
||||
return {"status": "pending"} # already allowed — don't reveal it
|
||||
return {"ok": True, "status": "pending"} # already allowed — don't reveal it
|
||||
req = upsert_pending_invite(db, email, body.lang)
|
||||
if req.is_new:
|
||||
notify_admins_of_request(db, background, email)
|
||||
return {"status": "pending"}
|
||||
return {"ok": True, "status": "pending"}
|
||||
|
||||
|
||||
@router.post("/demo")
|
||||
|
||||
@@ -42,7 +42,7 @@ def sync_subscriptions(user: User = Depends(require_human), db: Session = Depend
|
||||
raise youtube_refusal(exc)
|
||||
result["quota_used_estimate"] = m.used
|
||||
result["quota_remaining_today"] = m.remaining
|
||||
return result
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
|
||||
@@ -109,18 +109,26 @@ def is_quota_refusal(exc: YouTubeError) -> bool:
|
||||
return any(r in _QUOTA_REASONS for r in exc.reason.split(","))
|
||||
|
||||
|
||||
# The token endpoint's answer for a revoked or expired grant — the one refusal a reconnect fixes.
|
||||
_DEAD_GRANT = "invalid_grant"
|
||||
|
||||
# What a refused YouTube action means to the person who asked for it — the three things they can
|
||||
# act on differently: wait for tomorrow's budget, reconnect the account, or try again later.
|
||||
YouTubeRefusal = Literal["quota", "reconnect", "unavailable"]
|
||||
|
||||
|
||||
def refusal_reason(exc: YouTubeError) -> YouTubeRefusal:
|
||||
"""Sort a failure by what the user can do about it. The token step failing (Google refused the
|
||||
refresh, or there is no usable token at all — raised before any endpoint answered, so with no
|
||||
path and no transport reason) means the account must be reconnected; retrying cannot help."""
|
||||
"""Sort a failure by what the user can do about it. A dead grant (Google refused the refresh
|
||||
with `invalid_grant`, which also drops the grant — see `_drop_dead_grant`) or no usable token at
|
||||
all (raised before any endpoint answered, so with no path and no transport reason) means the
|
||||
account must be reconnected; retrying cannot help. Any other token-endpoint failure — a 5xx, a
|
||||
429, a misconfigured client — leaves the grant as it was: reconnecting would not fix it, and
|
||||
the account would still read as connected, with no connect button to press."""
|
||||
if is_quota_refusal(exc):
|
||||
return "quota"
|
||||
if exc.path == "oauth2/token" or (not exc.path and exc.reason != "transport"):
|
||||
if exc.path == "oauth2/token":
|
||||
return "reconnect" if exc.reason == _DEAD_GRANT else "unavailable"
|
||||
if not exc.path and exc.reason != "transport":
|
||||
return "reconnect"
|
||||
return "unavailable"
|
||||
|
||||
@@ -199,7 +207,7 @@ class YouTubeClient:
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
exc = _upstream_failure("POST", "oauth2/token", resp)
|
||||
if exc.reason == "invalid_grant":
|
||||
if exc.reason == _DEAD_GRANT:
|
||||
self._drop_dead_grant(tok)
|
||||
raise exc
|
||||
data = resp.json()
|
||||
|
||||
Reference in New Issue
Block a user