Skip to content

feat(client): 401 auto-refresh hook — drop the mid-session expiry-skew 401s - #90

Merged
nycomp merged 1 commit into
mainfrom
feat/unauthorized-refresh-hook
Oct 4, 2026
Merged

nycomp merged 1 commit into
mainfrom
feat/unauthorized-refresh-hook

Conversation

@nycomp

@nycomp nycomp commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Closes #89.

What

On a 401 response, CampusRequest can now invoke a one-shot refresh hook and retry the request once with a fresh bearer:

  • CampusRequest.set_unauthorized_hook(fn) — fn returns the new bearer token, or None to signal failure. Semantics:
    • Hook runs at most once per request; a second 401 on the retry is returned as-is.
    • A None return (or a hook raising APIError) leaves the original 401 response in place — failures surface exactly as before the hook existed.
    • Requests made from inside the hook skip the hook, so a refresh round-trip that itself 401s cannot recurse.
    • Opt-in on the raw client; the JsonClient ABC documents it as an optional capability (feature-detect with hasattr), per the issue's design note.
  • with_user_session(refresh_on_401=True) (default) installs the hook on both service clients: the closure force-refreshes via _get_token_from_session(force_refresh=True) — running the round-trip under client Basic auth (revoke_session()), the same known-good mode session establishment uses, since auth routes accept both but the session's stale bearer must not be presented mid-rotation — then restores the new bearer on both clients via use_token(). Hooks are cleared when the session closes. The proactive refresh stays; the hook only covers the expiry-skew race that used to surface as a mid-request 401 (the exact gap feat(client): optional 401 auto-refresh hook so user-session apps can drop proactive is_expired() checks #89 describes for campus-classroom).

Why the retry is safe for POSTs

Campus services authenticate in a before_request authenticator (campus/auth/routes/__init__.py), so a 401 is produced by the auth layer before any handler runs — there is no partial work to replay. This resolves the "POSTs are not idempotent" design question from the issue: retry-after-401 cannot double-execute a handler.

Design questions from the issue, resolved

  • Replay safety — covered above (auth-layer rejection precedes dispatch).
  • Refresh credential source — user sessions: wired via with_user_session; public clients: bring your own hook (campus.auth.refresh(stored, client_id=...) from feat(auth): refresh grant + device-flow parity for public clients #88 returns the rotated pair — README shows the snippet).
  • ABC placement — hook lives on the concrete CampusRequest; JsonClient docstring documents it as optional.

Tests

229 passing (was 218): retry-once semantics (hook called once, second 401 not re-tried, None keeps the original 401, hook cleared), reentrancy guard, wire-format preservation across verbs, and the with_user_session wiring (force-refresh call args, both clients re-bearered, failure restores pre-hook state, refresh_on_401=False opt-out).

…ion wiring (#89)

- CampusRequest.set_unauthorized_hook(hook): on a 401 response the hook
  runs once; a returned bearer token refreshes the Authorization header
  and the request is retried once. A retry that 401s again, or a hook
  returning None, surfaces the original 401 — strictly a safety net.
  Requests made from inside the hook skip the hook (no recursion).
  The retry is safe for non-idempotent requests: Campus services
  authenticate in before_request, before any handler runs.
- with_user_session(refresh_on_401=True, default) wires the hook on the
  auth+api clients: mid-session 401s (bearer expired after the skew
  window) force-refresh the session token — round-trip authenticating
  as the client (Basic), like session establishment — and restore the
  new bearer on both clients; failures restore the pre-hook state.
  refresh_on_401=False keeps the old behaviour.
- Verbs refactored through a shared _send(); request wire format
  unchanged (Session.request with the same kwargs).
- 229 tests (was 218).
@nycomp
nycomp merged commit e85cf61 into main Oct 4, 2026
2 checks passed
@nycomp
nycomp deleted the feat/unauthorized-refresh-hook branch October 4, 2026 07:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(client): optional 401 auto-refresh hook so user-session apps can drop proactive is_expired() checks

2 participants