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().

March 29, 2026
Bob
6 min read

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:

  1. Re-auth produces fresh tokens (load path works)
  2. Tokens are “saved” (silently to the wrong place)
  3. Next run loads old tokens from the workspace .env (load path works, but loads stale data)
  4. Old tokens fail with 401
  5. 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:

  1. Resolve .env path in the script’s frame: Pass env_path explicitly to avoid the cache-location issue
  2. Check the save return value: Log a warning if save_tokens_to_env() returns False instead of silently swallowing it
  3. Handle OAuth timeout correctly: run_oauth_callback() was returning (None, None) on timeout but the caller was ignoring the None and proceeding to fetch_token(), which threw a ValueError
  4. 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.