Skip to content

Commit 306626c

Browse files
Fix race condition in RunUsage.incr() with thread-safe implementation
- Add threading.Lock property to prevent race conditions in concurrent tool calls - Fix Pydantic serialization by making _lock a property (not dataclass field) - Maintain thread safety while allowing TypeAdapter serialization - Add proper pickle support with __getstate__/__setstate__ methods - Fix import order and formatting issues - Resolves PydanticSchemaGenerationError and Pyright type errors This addresses the race condition where concurrent tool executions could lead to undercounted tool_calls and corrupted details dictionary.
1 parent 6cf43ea commit 306626c

File tree

1 file changed

+37
-5
lines changed

1 file changed

+37
-5
lines changed

pydantic_ai_slim/pydantic_ai/usage.py

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations as _annotations
22

33
import dataclasses
4+
import threading
45
from copy import copy
56
from dataclasses import dataclass, fields
67
from typing import Annotated, Any
@@ -189,26 +190,57 @@ class RunUsage(UsageBase):
189190
details: dict[str, int] = dataclasses.field(default_factory=dict)
190191
"""Any extra details returned by the model."""
191192

193+
"""Lock to prevent race conditions when incrementing usage from concurrent tool calls."""
194+
195+
@property
196+
def _lock(self) -> threading.Lock:
197+
"""Get the lock, creating it if it doesn't exist."""
198+
if not hasattr(self, '__lock'):
199+
self.__lock = threading.Lock()
200+
return self.__lock
201+
192202
def incr(self, incr_usage: RunUsage | RequestUsage) -> None:
193203
"""Increment the usage in place.
194204
195205
Args:
196206
incr_usage: The usage to increment by.
197207
"""
198-
if isinstance(incr_usage, RunUsage):
199-
self.requests += incr_usage.requests
200-
self.tool_calls += incr_usage.tool_calls
201-
return _incr_usage_tokens(self, incr_usage)
208+
with self._lock:
209+
if isinstance(incr_usage, RunUsage):
210+
self.requests += incr_usage.requests
211+
self.tool_calls += incr_usage.tool_calls
212+
return _incr_usage_tokens(self, incr_usage)
202213

203214
def __add__(self, other: RunUsage | RequestUsage) -> RunUsage:
204215
"""Add two RunUsages together.
205216
206217
This is provided so it's trivial to sum usage information from multiple runs.
207218
"""
208219
new_usage = copy(self)
209-
new_usage.incr(other)
220+
# Note: We can't use await here since __add__ must be synchronous
221+
# But __add__ creates a new object, so there's no race condition
222+
# The race condition only happens when modifying the same object concurrently
223+
# The new instance will get its own lock via the property
224+
225+
if isinstance(other, RunUsage):
226+
new_usage.requests += other.requests
227+
new_usage.tool_calls += other.tool_calls
228+
_incr_usage_tokens(new_usage, other)
210229
return new_usage
211230

231+
def __getstate__(self) -> dict[str, Any]:
232+
"""Exclude the lock from pickling."""
233+
state = self.__dict__.copy()
234+
# Remove any lock-related attributes since they can't be pickled
235+
state.pop('_lock', None)
236+
state.pop('__lock', None)
237+
return state
238+
239+
def __setstate__(self, state: dict[str, Any]) -> None:
240+
"""Restore state and create a new lock."""
241+
self.__dict__.update(state)
242+
# The lock will be created automatically via the property
243+
212244

213245
def _incr_usage_tokens(slf: RunUsage | RequestUsage, incr_usage: RunUsage | RequestUsage) -> None:
214246
"""Increment the usage in place.

0 commit comments

Comments
 (0)