Why the container synced fine while the host kept hitting 429s
Shipped
local-fitness v0.15.2 is a one-commit fix: the daily Garmin pull now passes an explicit token-store path to login(), so it resumes a cached session instead of doing a full SSO login on every run. Repeated logins were tripping Garmin’s login rate limit, and the morning scheduled job kept logging Mobile login returned 429. The part worth a post is why only one of two environments hit it. The container had been fine all along; the host, running the same code, re-authenticated every single run. This guide walks through making session caching explicit instead of leaning on a library’s env-var fallback, then pinning the fix with a test.
How the same code behaves differently in two places
Login endpoints are usually rate-limited harder than data endpoints, because they’re the ones credential-stuffing attacks hammer. When you cross the line, the server answers with 429 Too Many Requests, the status RFC 6585 defines for rate limiting, optionally with a Retry-After header saying how long to wait (MDN). A scheduled job that authenticates from scratch on every run can trip that limit on the logins alone, before it fetches a single row.
Client libraries know this, which is why most of them will cache a session token on disk and resume it. OAuth is designed around the same idea: RFC 6749 gives clients a refresh token precisely so they can get new access tokens without re-involving the user. You should pay for a full login once, then resume.
The catch is how the library decides where the cache lives. In this case it fell back to an environment variable when the code didn’t pass a path. The container image set that variable, so the container quietly got session reuse. The host never set it, so the same login() call got a None token store, never loaded a session, never persisted one, and did a fresh SSO login on every pull. Two runs close together, the scheduled job plus a manual sync, were enough for a 429.
That’s the transferable lesson: a library’s env-var fallback is configuration your code never sees. It can make the exact same call correct in one environment and quietly expensive in another, and nothing in your codebase shows the difference. The fix is to resolve the path in your own code and pass it explicitly, keeping the env var as a deliberate override rather than a load-bearing accident.
Start with a stand-in client you can watch
You can build and verify the whole pattern without touching a real API. Drop this stand-in in myapi.py; the login(tokenstore) shape matches what real session-caching clients expose:
"""Stand-in API client so you can watch the caching behavior before wiring
in your real library. Swap this for garminconnect, garth, or whatever your
service uses; the login(tokenstore) shape is the common pattern."""
import json
from pathlib import Path
class Client:
def __init__(self, email: str, password: str):
self.email = email
self.password = password
def login(self, tokenstore: str | None = None):
if tokenstore and Path(tokenstore).exists():
print(f"resumed session from {tokenstore}")
return
print("FULL LOGIN (the call that gets rate-limited)")
if tokenstore:
Path(tokenstore).parent.mkdir(parents=True, exist_ok=True)
Path(tokenstore).write_text(json.dumps({"token": "cached"}))
No token file means a full login that writes one; a token file means a resume. That’s the behavior you’re trying to guarantee your job gets.
Resolve the path yourself, then pass it
Now the actual pattern, in sync.py. One function owns the answer to “where does the session live”: an explicit env override first, then a default that works on a fresh clone with no setup. The client constructor gets your credentials; the login call gets the path.
import os
from pathlib import Path
from myapi import Client # your real API client library goes here
def token_store_path() -> str:
"""Where the session token lives. Env override first, then a default
that works on a fresh clone with no setup."""
override = os.environ.get("API_TOKENSTORE")
if override:
return override
return str(Path.home() / ".myapi" / "tokens.json")
def make_client() -> Client:
client = Client(os.environ["API_EMAIL"], os.environ["API_PASSWORD"])
# The whole fix is this argument. With it, the client resumes the
# cached session; without it, every run pays for a fresh login.
client.login(token_store_path())
return client
Watch it work. Two calls in a row should cost one login:
API_EMAIL=[email protected] API_PASSWORD=pw API_TOKENSTORE=./tokens.json \
python -c "import sync; sync.make_client(); sync.make_client()"
FULL LOGIN (the call that gets rate-limited)
resumed session from ./tokens.json
The first call paid, wrote the token file, and the second resumed. In production your scheduled job calls make_client() at the top and works from the returned client; every run after the first resumes the saved session.
Pin the seam so it can’t regress
The bug was a missing argument, so the test should assert on that argument. Record what login() receives and fail if it ever goes back to bare. In test_sync.py:
import sync
class LoginRecorder:
"""Records the tokenstore argument passed to login(). That argument is
the seam the whole fix lives on."""
def __init__(self, *args, **kwargs):
self.calls = []
def login(self, tokenstore=None):
self.calls.append(tokenstore)
def test_login_gets_default_tokenstore(monkeypatch, tmp_path):
# Clear the override first: a shell- or CI-set variable would otherwise
# win and make this test assert the wrong path.
monkeypatch.delenv("API_TOKENSTORE", raising=False)
monkeypatch.setenv("API_EMAIL", "[email protected]")
monkeypatch.setenv("API_PASSWORD", "pw")
monkeypatch.setattr(sync.Path, "home", lambda: tmp_path)
rec = LoginRecorder()
monkeypatch.setattr(sync, "Client", lambda *a, **k: rec)
sync.make_client()
# Fails if make_client regresses to a bare login().
assert rec.calls == [str(tmp_path / ".myapi" / "tokens.json")]
def test_login_gets_env_override(monkeypatch):
monkeypatch.setenv("API_TOKENSTORE", "/custom/tokens.json")
monkeypatch.setenv("API_EMAIL", "[email protected]")
monkeypatch.setenv("API_PASSWORD", "pw")
rec = LoginRecorder()
monkeypatch.setattr(sync, "Client", lambda *a, **k: rec)
sync.make_client()
assert rec.calls == ["/custom/tokens.json"]
Everything env-shaped goes through pytest’s monkeypatch fixture, which undoes every change after each test, so nothing leaks into your shell or other tests. The delenv in the first test matters more than it looks: without it, an API_TOKENSTORE set by CI or your shell wins the resolution and the test asserts the wrong path on some machines and not others, which is the same class of bug you’re fixing.
Run it, then break it on purpose
python -m pytest test_sync.py -q
.. [100%]
2 passed in 0.01s
Now confirm the tests earn their keep. Edit make_client() to call client.login() with no argument, exactly the original bug, and rerun:
FAILED test_sync.py::test_login_gets_default_tokenstore - AssertionError: ass...
FAILED test_sync.py::test_login_gets_env_override - AssertionError: assert [N...
2 failed in 0.03s
Both fail, which is the point. A refactor that drops the argument, or hardcodes the default and ignores the override, can’t land quietly. Restore the argument and you’re back to green.
Gotchas
These all come from this release’s own history, not hypotheticals.
- The env-var fallback split-brain is the original bug. Symptom: identical code rate-limited in one environment and clean in another, with nothing in the diff to explain it. If a client library reads config from the environment when you don’t pass a value, treat every place that value isn’t set explicitly as broken until proven otherwise. The escape is exactly this post: resolve it in your code.
- Don’t tighten permissions on a token file two things share. The token file here sits on a bind mount read by both the host and a container running as a different uid. A
chmod 600-style hardening step was considered and deliberately dropped, because owner-only permissions would lock the other uid out. The failure is nasty: the locked-out side can’t read the cache, silently falls back to a full login every run, and the 429 you just fixed comes back with no error pointing at file permissions. - The first login usually has to be interactive. If the service uses MFA, a non-interactive scheduled job can’t answer the prompt. Seed the token store once from a terminal, then let the job resume it. The cached token also expires eventually, with no fixed TTL you can plan around, so when the job starts failing on auth again, the remedy is one more interactive run, not a code change.
- The job and the seeding shell must agree on
HOME. A default built onPath.home()resolves from theHOMEenv var, and schedulers like launchd or cron don’t always run with the environment your terminal has. If they disagree, the job looks for the token in a different directory than the one you seeded and never finds it. Either guarantee the sameHOMEor set the explicit override in the job’s environment.
Sources
- RFC 6585: Additional HTTP Status Codes — defines 429 Too Many Requests for rate limiting and the optional Retry-After header.
- 429 Too Many Requests (MDN) — practical semantics of the 429 response and Retry-After.
- RFC 6749: The OAuth 2.0 Authorization Framework — refresh tokens let a client obtain new access tokens without re-involving the user.
- How to monkeypatch/mock modules and environments (pytest) — setenv/delenv/setattr for safely patching env vars and attributes, auto-undone per test.
Changelog
- fix(ingest): reuse Garmin session token to stop per-pull login 429s (fce1ee9)