Skip to content

Own the Robinhood session: refresh-token first, password login last - #10

Merged
trogers1052 merged 1 commit into
mainfrom
fix/durable-session-auth
Aug 16, 2026
Merged

Own the Robinhood session: refresh-token first, password login last#10
trogers1052 merged 1 commit into
mainfrom
fix/durable-session-auth

Conversation

@trogers1052

Copy link
Copy Markdown
Owner

The problem

robin_stocks 3.4.0 loses the login session in three compounding ways, and this service has paid for all three — down 2026-06-01 → 2026-07-24, then a 205-restart crash-loop against Robinhood's login endpoint on 2026-08-01 (still stopped since).

  1. The refresh token is stored and never used. login() saves refresh_token into its pickle, then only ever replays the access token. When Robinhood expires it, there's no refresh path — it goes straight to a full password login.
  2. A password login mints a brand-new random device_token. The pickle's token is reused only if the pickle loads cleanly, so a missing pickle means presenting as a new device → Robinhood's approval workflow → a headless Pi can't answer it.
  3. The pickle is the only copy, in a container volume, rewritten with open(..., 'wb') on every attempt — a challenge response instead of tokens truncates it on the way to a KeyError.

The fix

robinhood_sync/session.py owns authentication end to end:

persisted session (Redis + file mirror)
        │
        ├─ resume ─────────────► valid?          ─► done (no network login)
        │                          │ no
        ├─ refresh_token grant ────┘              ─► done (rotated token persisted)
        │        │ rejected / no session
        └─ password login ────────────────────────► may raise a device challenge
  • Refresh at half-life — checked at startup and before every sync cycle, so the password login is effectively never reached.
  • Pinned device_token, persisted separately from the tokens. A wiped store still presents the same trusted device.
  • Redis + atomic 0600 file mirror, newest-copy-wins. Rotated refresh tokens persist before anything else can fail — losing one breaks the chain.
  • A 429 never escalates to a password login. Rate limiting means back off, not re-authenticate. This is the specific guard against the crash-loop.
  • rh.login() is out of the picture; _validate_sherrif_id stays patched for the challenge flow.

prime_session.py is the one interactive step — run it where a human is present, approve on your phone (or type an SMS code; auth_patch now takes an optional code provider and stays headless-by-default), and it writes the session straight into the Redis the Pi reads.

Verification

  • 587 tests pass, 95% coverage (session.py 92%, prime_session.py 99%). Ruff clean on new files.
  • tests/conftest.py blocks real HTTP through robin_stocks' session — the first run of the new code POSTed to api.robinhood.com because the old tests mocked only rh. Can't recur.
  • Offline lifecycle: cold start → restart resumes with zero token requests → past half-life refreshes via grant_type=refresh_token with rotation persisted → expired+429 returns rate_limited without attempting a login → store wiped with token pinned still logs in as the same device.

Not verified: the refresh grant against live Robinhood. The endpoint and payload match what rh.login() already uses, but it needs real credentials to confirm. Priming proves it; the first refresh lands ~12h later.

🤖 Generated with Claude Code

robin_stocks 3.4.0 loses the login in three compounding ways, and this
service has paid for all three (down 2026-06-01 to 2026-07-24, then a
205-restart crash-loop against Robinhood's login endpoint on 2026-08-01):

1. it stores the OAuth refresh_token but never uses it, so an expired
   access token falls straight back to a full password login;
2. a password login mints a NEW random device_token whenever its pickle
   can't be read, so Robinhood sees an unknown device and issues an
   approval challenge a headless Pi cannot answer;
3. the pickle is the only copy of the session, lives in a container
   volume, and is rewritten with open(...,'wb') on every attempt.

session.py takes ownership of all three:

- resume -> refresh -> password login, in that order. The access token is
  refreshed at half its life (startup + before each sync cycle), so the
  password login -- the only path that can raise a device challenge -- is
  effectively never reached.
- the device_token is pinned and persisted independently of the tokens,
  so even a wiped store presents the same trusted device.
- the bundle is persisted to Redis with an atomic 0600 file mirror,
  newest-copy-wins, so container recreation or a wiped volume costs
  nothing. Rotated refresh tokens are persisted before anything else can
  fail.
- a 429 never escalates to a password login. Rate limiting means back
  off, not re-authenticate -- the specific guard against the crash-loop.

prime_session.py is the one interactive step: run it where a human is
present, approve the prompt (or type the SMS code -- auth_patch now takes
an optional code provider, still headless-by-default), and it writes the
session straight into the Redis the Pi reads.

Tests: conftest blocks real HTTP through robin_stocks' session. The first
run of the new code POSTed to api.robinhood.com because the old tests
mocked only `rh`; that can't recur now. 587 pass, 95% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@trogers1052
trogers1052 merged commit 9429789 into main Aug 16, 2026
2 checks passed
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.

1 participant