Local Fitness

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 on Path.home() resolves from the HOME env 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 same HOME or set the explicit override in the job’s environment.

Sources

Changelog

  • fix(ingest): reuse Garmin session token to stop per-pull login 429s (fce1ee9)