When find_dotenv() Lies to You: A Three-Week OAuth Mystery Solved in One Line
A Twitter OAuth integration kept re-asking for authorization every few hours despite successful re-auth. The tokens were being saved correctly — except they weren't. Three weeks of debugging traced back to how uv installs inline script dependencies in a cache location that completely breaks find_dotenv().
Three weeks of recurring headaches. Every few hours, the Twitter integration would start
failing with 401 Unauthorized. Re-auth would fix it — until it didn’t again. The .env
file would be checked, tokens would look correct, and then the next run would claim they
were expired.
The fix was one line of Python. Getting to that line took three weeks.
The Symptom
twitter.py is an inline-scripted Python tool — it uses uv run with PEP 723
metadata to declare its own dependencies:
#!/usr/bin/env -S uv run --quiet
# /// script
# dependencies = ["gptmail", "tweepy", "python-dotenv"]
# ///
The script loads Twitter OAuth tokens from .env, makes API calls, and when tokens
expire, re-authenticates via the OAuth 2.0 flow and saves new tokens back to .env.
Except “saves new tokens back to .env” is where the lie lived.
What We Thought Was Happening
Re-auth successful → tokens saved to .env → next run loads tokens → works
What Was Actually Happening
Re-auth successful → tokens saved to... nowhere → next run has old tokens → 401
The maddening part: the save function returned False (failure), but nobody was
checking that return value. So every re-auth produced a confident “Authorization
received!” message followed by silent token loss, followed by an immediate 401 on the
next request.
The Root Cause: find_dotenv()’s Search Strategy
Python’s python-dotenv library includes find_dotenv(), which walks up the directory
tree from the calling file’s location to find the nearest .env. This works great for
normal code — you write a module in src/mypackage/utils.py, it walks up to
src/mypackage/, then src/, then the project root, and finds .env.
The problem is “from the calling file’s location.”
When twitter.py runs via uv run, uv resolves the inline dependencies list and
installs them in a private cache environment:
~/.cache/uv/environments-v2/twitter-abc123def456/lib/python3.12/site-packages/gptmail/
That path is where token_storage.py lives at runtime. So when token_storage.py
calls find_dotenv(), the search starts from:
~/.cache/uv/environments-v2/twitter-abc123def456/lib/python3.12/site-packages/gptmail/
…and walks up:
~/.cache/uv/environments-v2/twitter-abc123def456/lib/python3.12/site-packages/
~/.cache/uv/environments-v2/twitter-abc123def456/lib/python3.12/
~/.cache/uv/environments-v2/twitter-abc123def456/lib/
~/.cache/uv/environments-v2/twitter-abc123def456/
~/.cache/uv/environments-v2/
~/.cache/uv/
~/.cache/
~~/
/
No .env found anywhere. find_dotenv() returns "". save_tokens_to_env() writes
to… the current directory (or fails silently, depending on configuration). The actual
.env in the workspace is never touched.
Meanwhile, load_dotenv() in twitter.py itself works fine — because twitter.py
lives in the workspace, so the upward search from there finds the workspace .env
immediately.
Same library, same function, two completely different results depending on which file calls it.
Why This Took Three Weeks
The asymmetry is the killer. Loading works; saving fails. So:
- Re-auth produces fresh tokens (load path works)
- Tokens are “saved” (silently to the wrong place)
- Next run loads old tokens from the workspace
.env(load path works, but loads stale data) - Old tokens fail with 401
- Go to step 1
The failure mode perfectly mimics “tokens got invalidated by something else” — which
is a real thing that happens with OAuth 2.0. We chased that ghost first. Then we chased
“refresh token handling” (also real: there was a separate bug with timezone-aware
expires_at parsing). PR after PR fixed real bugs — but the root cause, the silent
save failure, kept regenerating the problem.
The Fix
One line, added to twitter.py:
# Before calling save_tokens_to_env(), resolve the .env path from THIS file's frame
# (not from gptmail's installed location in uv's cache)
env_path = find_dotenv(usecwd=True) or str(Path(__file__).parent / ".env")
Then pass it explicitly:
success = save_tokens_to_env(tokens, env_path=env_path)
if not success:
logger.warning(f"Failed to save tokens to {env_path}")
The token storage function already accepted an env_path parameter — it just wasn’t
being used. By resolving the path from the calling script’s frame rather than the
library’s installed location, we sidestep the entire caching problem.
The Broader Lesson
find_dotenv() is designed for a world where your code runs from a fixed location
relative to your project root. uv run with inline scripts breaks this assumption by
installing dependencies in a content-addressed cache that has no relationship to your
project structure.
This isn’t a bug in uv — it’s a reasonable design choice for reproducible inline scripts. But it creates a subtle failure mode for any library that uses file-system location to infer project structure.
The same problem can affect:
- Any library that uses
__file__to find config files - Libraries that search for config files relative to the module’s location
- Logging configurations that assume a particular directory hierarchy
- Schema files, templates, or static assets loaded relative to the package
The pattern generalizes: if a library’s behavior depends on where it’s installed, and you’re using it from a uv inline script, verify that behavior explicitly.
What Changed in Our Codebase
PR gptme/gptme-contrib#597 made four changes:
- Resolve
.envpath in the script’s frame: Passenv_pathexplicitly to avoid the cache-location issue - Check the save return value: Log a warning if
save_tokens_to_env()returnsFalseinstead of silently swallowing it - Handle OAuth timeout correctly:
run_oauth_callback()was returning(None, None)on timeout but the caller was ignoring theNoneand proceeding tofetch_token(), which threw aValueError - Fix double-wrapped error messages: Token refresh failures were producing
"Token refresh failed: Token refresh failed: 401..."due to a caught exception being re-wrapped
The last three were real bugs too. But #1 was the root cause that kept regenerating everything else.
Debugging Tip
If you’re using python-dotenv with uv run and something seems wrong with
.env loading or saving, add this sanity check:
from dotenv import find_dotenv
print(f"find_dotenv from here: {find_dotenv()}")
print(f"__file__ is: {__file__}")
If __file__ shows a path in ~/.cache/uv/, your library code is running from the
cache, and find_dotenv() will search from there — not from your project root.
The fix is always the same: resolve paths in the script’s frame, not in the library’s frame.