Skip to content

Commit 3217575

Browse files
authored
fix: event handler methods are not thread-safe (#329)
The _client_handlers dictionary allowed modifications during iteration without proper concurrency control. I added some reentrant locks to manage concurrent access to the _global_handlers and _client_handlers data structures. See #326 Signed-off-by: Federico Bond <federicobond@gmail.com>
1 parent c3ad697 commit 3217575

File tree

2 files changed

+56
-15
lines changed

2 files changed

+56
-15
lines changed

openfeature/_event_support.py

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

3+
import threading
34
from collections import defaultdict
45
from typing import TYPE_CHECKING, Dict, List
56

@@ -15,7 +16,10 @@
1516
from openfeature.client import OpenFeatureClient
1617

1718

19+
_global_lock = threading.RLock()
1820
_global_handlers: Dict[ProviderEvent, List[EventHandler]] = defaultdict(list)
21+
22+
_client_lock = threading.RLock()
1923
_client_handlers: Dict[OpenFeatureClient, Dict[ProviderEvent, List[EventHandler]]] = (
2024
defaultdict(lambda: defaultdict(list))
2125
)
@@ -24,41 +28,47 @@
2428
def run_client_handlers(
2529
client: OpenFeatureClient, event: ProviderEvent, details: EventDetails
2630
) -> None:
27-
for handler in _client_handlers[client][event]:
28-
handler(details)
31+
with _client_lock:
32+
for handler in _client_handlers[client][event]:
33+
handler(details)
2934

3035

3136
def run_global_handlers(event: ProviderEvent, details: EventDetails) -> None:
32-
for handler in _global_handlers[event]:
33-
handler(details)
37+
with _global_lock:
38+
for handler in _global_handlers[event]:
39+
handler(details)
3440

3541

3642
def add_client_handler(
3743
client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler
3844
) -> None:
39-
handlers = _client_handlers[client][event]
40-
handlers.append(handler)
45+
with _client_lock:
46+
handlers = _client_handlers[client][event]
47+
handlers.append(handler)
4148

4249
_run_immediate_handler(client, event, handler)
4350

4451

4552
def remove_client_handler(
4653
client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler
4754
) -> None:
48-
handlers = _client_handlers[client][event]
49-
handlers.remove(handler)
55+
with _client_lock:
56+
handlers = _client_handlers[client][event]
57+
handlers.remove(handler)
5058

5159

5260
def add_global_handler(event: ProviderEvent, handler: EventHandler) -> None:
53-
_global_handlers[event].append(handler)
61+
with _global_lock:
62+
_global_handlers[event].append(handler)
5463

5564
from openfeature.api import get_client
5665

5766
_run_immediate_handler(get_client(), event, handler)
5867

5968

6069
def remove_global_handler(event: ProviderEvent, handler: EventHandler) -> None:
61-
_global_handlers[event].remove(handler)
70+
with _global_lock:
71+
_global_handlers[event].remove(handler)
6272

6373

6474
def run_handlers_for_provider(
@@ -72,9 +82,10 @@ def run_handlers_for_provider(
7282
# run the global handlers
7383
run_global_handlers(event, details)
7484
# run the handlers for clients associated to this provider
75-
for client in _client_handlers:
76-
if client.provider == provider:
77-
run_client_handlers(client, event, details)
85+
with _client_lock:
86+
for client in _client_handlers:
87+
if client.provider == provider:
88+
run_client_handlers(client, event, details)
7889

7990

8091
def _run_immediate_handler(
@@ -91,5 +102,7 @@ def _run_immediate_handler(
91102

92103

93104
def clear() -> None:
94-
_global_handlers.clear()
95-
_client_handlers.clear()
105+
with _global_lock:
106+
_global_handlers.clear()
107+
with _client_lock:
108+
_client_handlers.clear()

tests/test_client.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import time
2+
import uuid
3+
from concurrent.futures import ThreadPoolExecutor
14
from unittest.mock import MagicMock
25

36
import pytest
@@ -356,3 +359,28 @@ def test_provider_event_late_binding():
356359

357360
# Then
358361
spy.provider_configuration_changed.assert_called_once_with(details)
362+
363+
364+
def test_client_handlers_thread_safety():
365+
provider = NoOpProvider()
366+
set_provider(provider)
367+
368+
def add_handlers_task():
369+
def handler(*args, **kwargs):
370+
time.sleep(0.005)
371+
372+
for _ in range(10):
373+
time.sleep(0.01)
374+
client = get_client(str(uuid.uuid4()))
375+
client.add_handler(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, handler)
376+
377+
def emit_events_task():
378+
for _ in range(10):
379+
time.sleep(0.01)
380+
provider.emit_provider_configuration_changed(ProviderEventDetails())
381+
382+
with ThreadPoolExecutor(max_workers=2) as executor:
383+
f1 = executor.submit(add_handlers_task)
384+
f2 = executor.submit(emit_events_task)
385+
f1.result()
386+
f2.result()

0 commit comments

Comments
 (0)