A two-agent Python service ran fine in tests. Two concurrent users hit it and one user's search results showed up in the other user's response. The pattern looked safe. The Rust port doesn't compile.

The pattern that looked fine

A former student walked me through this one. It's another case of module-level globals biting in concurrent code.

The agent in this service had a tool-call budget per query. Five tool calls, then stop. The implementation was the kind of thing I see in a lot of Python codebases:

_call_count = 0
_sources: list[str] = []
_lock = threading.Lock()

def reset() -> None:
    global _call_count, _sources
    with _lock:
        _call_count = 0
        _sources = []

def _check_and_increment() -> int:
    global _call_count
    with _lock:
        if _call_count >= MAX_CALLS:
            raise ToolCallLimitExceeded()
        _call_count += 1
        return _call_count

def _add_source(source: str) -> None:
    with _lock:
        _sources.append(source)

Every operation locks. Looks safe. Locally it is.

The orchestrator runs the Cypher and Mongo agents in parallel via asyncio.gather. A single user's request is fine because the two agents touch different modules. Streamlit puts each session on its own thread, so when two users query at the same time, both threads share the same _call_count, _sources, and _lock. Because Python modules are cached in sys.modules, _call_count isn't just a variable; it's a piece of memory shared by every thread in that process.

The race

Two users, each plans four tool calls (within their own five-call budget). Output from the repro:

[userA] DONE: made=2 expected=4 | sources: 2 own + 2 foreign
[userA] LEAKED foreign sources: ['userB:q0', 'userB:q1']
[userB] DONE: made=3 expected=4 | sources: 3 own + 2 foreign
[userB] LEAKED foreign sources: ['userA:q0', 'userA:q1']

Two failures at once.

The shared counter hits 5 before either user finishes, so each one gets their budget eaten. And get_sources() returns whatever happens to be in the shared list, mixed across users.

A timeline makes the leak obvious:

T+0  userA: lock, count 0->1, unlock     # userA's call 1 of 4
T+1  userB: lock, count 1->2, unlock     # userB's call 1 of 4
T+2  userA: lock, count 2->3, unlock     # userA's call 2 of 4
T+3  userB: lock, count 3->4, unlock     # userB's call 2 of 4
T+4  userA: lock, count 4->5, unlock     # userA's call 3 of 4
T+5  userB: lock, sees 5 >= MAX, raises  # userB barely started, budget gone

userA looks at the counter after two of its own increments and sees 4. "Wait, why is my count already 4?" Because userB has been incrementing the same number.

The locks were doing their job. Each individual op is atomic. They don't give per-request isolation, because there is no per-request anything. The data is one global.

The fix: contextvars

contextvars.ContextVar was built for this. Each thread, and each asyncio Task, gets its own copy. Default values give every fresh context a clean slate.

This matters more in asyncio than in threads. threading.local would catch the threaded case, but every asyncio task runs on the same thread — they all share one threading.local. Picture two tasks on one event loop: task A sets foo = 2, hits await, the loop runs task B, B reads foo and sees 2. There's no isolation, because there's no separate thread to key off. ContextVar keys on context instead, and asyncio.Task copies the context when it's created, so each Task gets its own slot. A's set() is invisible to B even though they're on the same thread.

import contextvars

_call_count: contextvars.ContextVar[int] = contextvars.ContextVar(
    "call_count", default=0
)
_sources: contextvars.ContextVar[tuple[str, ...]] = contextvars.ContextVar(
    "sources", default=()
)

def reset() -> None:
    _call_count.set(0)
    _sources.set(())

def _check_and_increment() -> int:
    n = _call_count.get()
    if n >= MAX_CALLS:
        raise ToolCallLimitExceeded()
    _call_count.set(n + 1)
    return n + 1

def _add_source(source: str) -> None:
    _sources.set(_sources.get() + (source,))

Same demo, fixed:

[userA] DONE: made=4 expected=4 | sources: 4 own + 0 foreign
[userB] DONE: made=4 expected=4 | sources: 4 own + 0 foreign

One subtle point: _sources is a tuple, not a list. With ContextVar(default=[]), every context that hasn't called set() shares the same default list object. A stray cv.get().append(x) would silently leak across contexts, mutating the default that every other context still points at. Tuples make that mistake non-expressible, which is close to Rust where immutable data is the default and mutable state has to be explicitly marked (mut).

What would Rust make of this?

If you mostly write Python, the gist of Rust's model is: by default everything is immutable, and the type system tracks who is allowed to read or write each piece of memory. That tracking is what blocks the bug shape from existing. Porting the Python pattern naively, the compiler refuses it four different ways.

1. Module globals can't just exist.

The Python _call_count = 0 at module scope has no clean Rust equivalent. The closest thing is static mut CALL_COUNT: u32 = 0 (static is the Rust word for a true module-level value, mut opts into mutability), and every read or write of it requires an unsafe { ... } block. The compiler is flagging the same risk we hit in Python (module-level mutable state is shared by every thread), but it forces you to acknowledge it in the syntax. You cannot accidentally write the buggy pattern.

2. Two threads cannot share a &mut reference.

In Python you pass an object reference into a thread and trust that locks will sort it out at runtime. Rust tracks references at compile time. The rule is aliasing XOR mutability: a value is either readable by many or writable by one, never both at once. &mut T is the "writable by one" case — while it exists, no other reference of any kind is allowed. That single rule is what blocks the Python bug; two threads writing the same counter is exactly the case it forbids. Moving the same Tracker into two thread::spawn closures doesn't compile:

error[E0382]: use of moved value

The exact aliasing the Python bug relied on, two threads writing to one shared counter, is not a thing the type system will let you express.

3. Shared global state must be wrapped in a lock.

Try a global without a lock and the compiler refuses with a different error:

error: `Tracker` cannot be shared between threads safely

Rust calls this the Sync trait, "safe to access from multiple threads at once". Tracker doesn't qualify because mutating its fields would race. To opt in, you wrap it: Mutex<Tracker>, similar to a threading.Lock in Python but with a critical difference. The lock wraps the data, not the operations. In our Python version we had a _lock and three free functions that called it; nothing prevented a fourth function from forgetting. In Rust, the only way to read the counter is to call .lock() on the mutex first, because the counter lives inside it. The bug class of "I forgot to take the lock here" is structurally absent.

4. The idiomatic version doesn't share state at all.

The cleanest Rust port doesn't go anywhere near a global. Each thread owns its own Tracker on its own stack:

fn run_agent(user_id: String) {
    let mut tracker = Tracker::new();
    for i in 0..CALLS_PER_USER {
        call_tool(&mut tracker, &user_id, &format!("q{i}"));
    }
}

There is no global to reset. The Python reset() race, where userA's reset zeroes userB's mid-flight counter, has no syntax in this design. This is the kind of explicitness that I described in what Rust structs taught me about state ownership: the compiler refuses to let you store state in places it shouldn't live.

What Rust doesn't save you from

Worth pinning down two terms that get conflated:

Bug classWhat it isDoes Rust prevent it?
Data raceTwo threads touching the same memory without synchronizationYes, won't compile
Race conditionLogic that breaks because operations interleave in an unexpected orderNo, even with Arc<Mutex<T>>

Wrap the tracker in Arc<Mutex<Tracker>> and share it across users, and the compiler is satisfied. Two pieces of jargon there, but they map directly onto Python ideas:

Arc<T> is Python's reference counting, made explicit. When you write data = [] in Python and pass it to two threads, both hold the same list — CPython tracks how many references exist and frees it when the count hits zero. That bookkeeping is automatic and invisible. Rust doesn't do it for you by default. When you genuinely want "many owners, last one out cleans up" across threads, you opt in with Arc (Atomic Reference Count). Same model as Python; you just ask for it by name.

Mutex<T> is threading.Lock, except the lock owns the data. In Python you write:

lock = threading.Lock()
data = []
# somewhere else, hopefully:
with lock:
    data.append(x)

Two separate objects, held together by convention. Nothing stops a caller from touching data without the with. In Rust the data lives inside the mutex:

let mutex = Mutex::new(Vec::new());
let mut guard = mutex.lock().unwrap();
guard.push(x);

The only way to reach the vec is to call .lock(), which hands back a guard that auto-releases when it goes out of scope. "I forgot the with lock:" doesn't compile.

Arc<Mutex<T>> is the two combined. Think of it as the Python idiom (threading.Lock(), shared_data) welded into one type, with the compiler enforcing that you never use the data without the lock.

No data race. But you have reintroduced the Python bug at a higher level, because userA's reset() still clobbers userB's counter under that same lock. Rust rules out memory unsafety. Per-request isolation is still your design decision.

The fix is the same in both languages: one tracker per request. Rust rules out the data-corruption variant at compile time.

Keep reading

"Shared mutable state is the root of all evil in concurrent systems." — Edward Kmett

The bug here is hard to see in tests. It needs concurrent traffic to fire, and that's the painful kind. Lesson: in Python you can guard against it, but it takes knowledge and discipline. In Rust, the compiler does more of the work: it makes illegal states unrepresentable. The more bugs you can design out of the syntax, the fewer you debug at runtime.