Own the Robinhood session: refresh-token first, password login last - #10
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).
login()savesrefresh_tokeninto 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.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.open(..., 'wb')on every attempt — a challenge response instead of tokens truncates it on the way to aKeyError.The fix
robinhood_sync/session.pyowns authentication end to end:device_token, persisted separately from the tokens. A wiped store still presents the same trusted device.rh.login()is out of the picture;_validate_sherrif_idstays patched for the challenge flow.prime_session.pyis the one interactive step — run it where a human is present, approve on your phone (or type an SMS code;auth_patchnow takes an optional code provider and stays headless-by-default), and it writes the session straight into the Redis the Pi reads.Verification
session.py92%,prime_session.py99%). Ruff clean on new files.tests/conftest.pyblocks real HTTP through robin_stocks' session — the first run of the new code POSTed toapi.robinhood.combecause the old tests mocked onlyrh. Can't recur.grant_type=refresh_tokenwith rotation persisted → expired+429 returnsrate_limitedwithout 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