Skip to content

Commit 7d1af1c

Browse files
authored
PYTHON-5885 Client Backpressure with baseBackoffMS (#2877)
1 parent 60aa8cd commit 7d1af1c

14 files changed

Lines changed: 210 additions & 38 deletions

doc/changelog.rst

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
Changelog
22
=========
33

4-
Changes in Version 4.18.0
5-
-------------------------
4+
Changes in Version 4.18.0 (2026/XX/XX)
5+
--------------------------------------
6+
7+
PyMongo 4.18 brings a number of changes including:
68

79
- Improved TLS connection performance by reusing TLS sessions across connections
810
to the same server, avoiding a full handshake on each new connection.
911
Session resumption is supported on all Python versions for synchronous clients
1012
and on Python 3.11+ for async clients.
13+
- Improved performance for MongoDB 9.0's Intelligent Workload Management (IWM) by only retrying overload errors when doing so is expected to not worsen server conditions.
1114
- Redacted potentially sensitive authentication mechanism properties, including
1215
AWS session tokens, from the representations of
1316
:class:`~pymongo.synchronous.mongo_client.MongoClient` and

pymongo/asynchronous/helpers.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from typing import (
2727
Any,
2828
Callable,
29+
Optional,
2930
TypeVar,
3031
cast,
3132
)
@@ -84,10 +85,12 @@ async def inner(*args: Any, **kwargs: Any) -> Any:
8485

8586

8687
def _backoff(
87-
attempt: int, initial_delay: float = _BACKOFF_INITIAL, max_delay: float = _BACKOFF_MAX
88+
attempt: int,
89+
base_backoff: float,
90+
max_delay: float = _BACKOFF_MAX,
8891
) -> float:
8992
jitter = random.random() # noqa: S311
90-
return jitter * min(initial_delay * (2**attempt), max_delay)
93+
return jitter * min(base_backoff * (2**attempt), max_delay)
9194

9295

9396
class _RetryPolicy:
@@ -103,9 +106,13 @@ def __init__(
103106
self.backoff_initial = backoff_initial
104107
self.backoff_max = backoff_max
105108

106-
def backoff(self, attempt: int) -> float:
107-
"""Return the backoff duration for the given attempt."""
108-
return _backoff(max(0, attempt - 1), self.backoff_initial, self.backoff_max)
109+
def backoff(self, attempt: int, base_backoff: Optional[float] = None) -> float:
110+
"""Return the actual backoff duration for the given attempt and base backoff."""
111+
return _backoff(
112+
max(0, attempt),
113+
self.backoff_initial if base_backoff is None or base_backoff < 0 else base_backoff,
114+
self.backoff_max,
115+
)
109116

110117
async def should_retry(self, attempt: int, delay: float) -> bool:
111118
"""Return if we have retry attempts remaining and the next backoff would not exceed a timeout."""

pymongo/asynchronous/mongo_client.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2807,6 +2807,7 @@ def __init__(
28072807
self._attempt_number = 0
28082808
self._is_run_command = is_run_command
28092809
self._is_aggregate_write = is_aggregate_write
2810+
self._base_backoff_ms: Optional[float] = None
28102811

28112812
async def run(self) -> T:
28122813
"""Runs the supplied func() and attempts a retry
@@ -2856,26 +2857,29 @@ async def run(self) -> T:
28562857

28572858
# Execute specialized catch on read
28582859
if self._is_read:
2859-
if isinstance(exc, (ConnectionFailure, OperationFailure)):
2860+
if isinstance(exc_to_check, (ConnectionFailure, OperationFailure)):
28602861
# ConnectionFailures do not supply a code property
2861-
exc_code = getattr(exc, "code", None)
2862-
overloaded = exc.has_error_label("SystemOverloadedError")
2862+
exc_code = getattr(exc_to_check, "code", None)
2863+
overloaded = exc_to_check.has_error_label("SystemOverloadedError")
28632864
if overloaded:
28642865
self._max_retries = self._client.options.max_adaptive_retries
2865-
always_retryable = exc.has_error_label("RetryableError") and overloaded
2866+
self._base_backoff_ms = getattr(exc_to_check, "_base_backoff_ms", None)
2867+
always_retryable = (
2868+
exc_to_check.has_error_label("RetryableError") and overloaded
2869+
)
28662870
if not self._client.options.retry_reads or (
28672871
not always_retryable
28682872
and (
28692873
self._is_not_eligible_for_retry()
28702874
or (
2871-
isinstance(exc, OperationFailure)
2875+
isinstance(exc_to_check, OperationFailure)
28722876
and exc_code not in helpers_shared._RETRYABLE_ERROR_CODES
28732877
)
28742878
)
28752879
):
28762880
raise
28772881
self._retrying = True
2878-
self._last_error = exc
2882+
self._last_error = exc_to_check
28792883
self._attempt_number += 1
28802884

28812885
# Revert back to starting state only if the first
@@ -2902,6 +2906,7 @@ async def run(self) -> T:
29022906
overloaded = exc_to_check.has_error_label("SystemOverloadedError")
29032907
if overloaded:
29042908
self._max_retries = self._client.options.max_adaptive_retries
2909+
self._base_backoff_ms = getattr(exc_to_check, "_base_backoff_ms", None)
29052910
always_retryable = exc_to_check.has_error_label("RetryableError") and overloaded
29062911

29072912
# Always retry abortTransaction and commitTransaction up to once
@@ -2945,7 +2950,10 @@ async def run(self) -> T:
29452950

29462951
self._always_retryable = always_retryable
29472952
if overloaded:
2948-
delay = self._retry_policy.backoff(self._attempt_number)
2953+
delay = self._retry_policy.backoff(
2954+
self._attempt_number,
2955+
self._base_backoff_ms / 1000 if self._base_backoff_ms else None,
2956+
)
29492957
if not await self._retry_policy.should_retry(self._attempt_number, delay):
29502958
if exc_to_check.has_error_label("NoWritesPerformed") and self._last_error:
29512959
raise self._last_error from exc

pymongo/asynchronous/pool.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ async def _hello(
241241
cmd = self.hello_cmd()
242242
performing_handshake = not self.performed_handshake
243243
awaitable = False
244-
cmd["backpressure"] = True
244+
cmd["backpressure"] = "2"
245245
if performing_handshake:
246246
self.performed_handshake = True
247247
cmd["client"] = self.opts.metadata

pymongo/errors.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,12 +190,15 @@ def __init__(
190190
max_wire_version: Optional[int] = None,
191191
) -> None:
192192
error_labels = None
193+
base_backoff_ms = None
193194
if details is not None:
194195
error_labels = details.get("errorLabels")
196+
base_backoff_ms = details.get("baseBackoffMS")
195197
super().__init__(_format_detailed_error(error, details), error_labels=error_labels)
196198
self.__code = code
197199
self.__details = details
198200
self.__max_wire_version = max_wire_version
201+
self.__base_backoff_ms = base_backoff_ms
199202

200203
@property
201204
def _max_wire_version(self) -> Optional[int]:
@@ -222,6 +225,10 @@ def details(self) -> Optional[Mapping[str, Any]]:
222225
def timeout(self) -> bool:
223226
return self.__code in (50,)
224227

228+
@property
229+
def _base_backoff_ms(self) -> Optional[float]:
230+
return self.__base_backoff_ms
231+
225232

226233
class CursorNotFound(OperationFailure):
227234
"""Raised while iterating query results if the cursor is

pymongo/synchronous/helpers.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from typing import (
2727
Any,
2828
Callable,
29+
Optional,
2930
TypeVar,
3031
cast,
3132
)
@@ -84,10 +85,12 @@ def inner(*args: Any, **kwargs: Any) -> Any:
8485

8586

8687
def _backoff(
87-
attempt: int, initial_delay: float = _BACKOFF_INITIAL, max_delay: float = _BACKOFF_MAX
88+
attempt: int,
89+
base_backoff: float,
90+
max_delay: float = _BACKOFF_MAX,
8891
) -> float:
8992
jitter = random.random() # noqa: S311
90-
return jitter * min(initial_delay * (2**attempt), max_delay)
93+
return jitter * min(base_backoff * (2**attempt), max_delay)
9194

9295

9396
class _RetryPolicy:
@@ -103,9 +106,13 @@ def __init__(
103106
self.backoff_initial = backoff_initial
104107
self.backoff_max = backoff_max
105108

106-
def backoff(self, attempt: int) -> float:
107-
"""Return the backoff duration for the given attempt."""
108-
return _backoff(max(0, attempt - 1), self.backoff_initial, self.backoff_max)
109+
def backoff(self, attempt: int, base_backoff: Optional[float] = None) -> float:
110+
"""Return the actual backoff duration for the given attempt and base backoff."""
111+
return _backoff(
112+
max(0, attempt),
113+
self.backoff_initial if base_backoff is None or base_backoff < 0 else base_backoff,
114+
self.backoff_max,
115+
)
109116

110117
def should_retry(self, attempt: int, delay: float) -> bool:
111118
"""Return if we have retry attempts remaining and the next backoff would not exceed a timeout."""

pymongo/synchronous/mongo_client.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2798,6 +2798,7 @@ def __init__(
27982798
self._attempt_number = 0
27992799
self._is_run_command = is_run_command
28002800
self._is_aggregate_write = is_aggregate_write
2801+
self._base_backoff_ms: Optional[float] = None
28012802

28022803
def run(self) -> T:
28032804
"""Runs the supplied func() and attempts a retry
@@ -2847,26 +2848,29 @@ def run(self) -> T:
28472848

28482849
# Execute specialized catch on read
28492850
if self._is_read:
2850-
if isinstance(exc, (ConnectionFailure, OperationFailure)):
2851+
if isinstance(exc_to_check, (ConnectionFailure, OperationFailure)):
28512852
# ConnectionFailures do not supply a code property
2852-
exc_code = getattr(exc, "code", None)
2853-
overloaded = exc.has_error_label("SystemOverloadedError")
2853+
exc_code = getattr(exc_to_check, "code", None)
2854+
overloaded = exc_to_check.has_error_label("SystemOverloadedError")
28542855
if overloaded:
28552856
self._max_retries = self._client.options.max_adaptive_retries
2856-
always_retryable = exc.has_error_label("RetryableError") and overloaded
2857+
self._base_backoff_ms = getattr(exc_to_check, "_base_backoff_ms", None)
2858+
always_retryable = (
2859+
exc_to_check.has_error_label("RetryableError") and overloaded
2860+
)
28572861
if not self._client.options.retry_reads or (
28582862
not always_retryable
28592863
and (
28602864
self._is_not_eligible_for_retry()
28612865
or (
2862-
isinstance(exc, OperationFailure)
2866+
isinstance(exc_to_check, OperationFailure)
28632867
and exc_code not in helpers_shared._RETRYABLE_ERROR_CODES
28642868
)
28652869
)
28662870
):
28672871
raise
28682872
self._retrying = True
2869-
self._last_error = exc
2873+
self._last_error = exc_to_check
28702874
self._attempt_number += 1
28712875

28722876
# Revert back to starting state only if the first
@@ -2893,6 +2897,7 @@ def run(self) -> T:
28932897
overloaded = exc_to_check.has_error_label("SystemOverloadedError")
28942898
if overloaded:
28952899
self._max_retries = self._client.options.max_adaptive_retries
2900+
self._base_backoff_ms = getattr(exc_to_check, "_base_backoff_ms", None)
28962901
always_retryable = exc_to_check.has_error_label("RetryableError") and overloaded
28972902

28982903
# Always retry abortTransaction and commitTransaction up to once
@@ -2936,7 +2941,10 @@ def run(self) -> T:
29362941

29372942
self._always_retryable = always_retryable
29382943
if overloaded:
2939-
delay = self._retry_policy.backoff(self._attempt_number)
2944+
delay = self._retry_policy.backoff(
2945+
self._attempt_number,
2946+
self._base_backoff_ms / 1000 if self._base_backoff_ms else None,
2947+
)
29402948
if not self._retry_policy.should_retry(self._attempt_number, delay):
29412949
if exc_to_check.has_error_label("NoWritesPerformed") and self._last_error:
29422950
raise self._last_error from exc

pymongo/synchronous/pool.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ def _hello(
241241
cmd = self.hello_cmd()
242242
performing_handshake = not self.performed_handshake
243243
awaitable = False
244-
cmd["backpressure"] = True
244+
cmd["backpressure"] = "2"
245245
if performing_handshake:
246246
self.performed_handshake = True
247247
cmd["client"] = self.opts.metadata

test/asynchronous/test_client_backpressure.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,9 @@ async def test_01_operation_retry_uses_exponential_backoff(self, random_func):
223223
end1 = perf_counter()
224224

225225
# f. Compare the times between the two runs.
226-
# The sum of 2 backoffs is 0.3 seconds. There is a 0.3-second window to account for potential variance between the two
226+
# The sum of 2 backoffs is 0.6 seconds. There is a 0.6-second window to account for potential variance between the two
227227
# runs.
228-
self.assertTrue(abs((end1 - start1) - (end0 - start0 + 0.3)) < 0.3)
228+
self.assertTrue(abs((end1 - start1) - (end0 - start0 + 0.6)) < 0.6)
229229

230230
@async_client_context.require_failCommand_appName
231231
async def test_03_overload_retries_limited(self):
@@ -294,6 +294,70 @@ async def test_04_overload_retries_limited_configured(self):
294294
# 6. Assert that the total number of started commands is max_retries + 1.
295295
self.assertEqual(len(self.listener.started_events), max_retries + 1)
296296

297+
@unittest.skipIf(
298+
sys.platform == "darwin",
299+
"externalClientBaseBackoffMS is not supported on macOS",
300+
)
301+
@patch("random.random")
302+
@async_client_context.require_version_min(9, 0, 0, -1)
303+
@async_client_context.require_failCommand_appName
304+
async def test_05_overload_errors_with_basebackoffms_override_backoff(self, random_func):
305+
# Drivers should test that overload errors with `baseBackoffMS` override the default backoff duration.
306+
307+
# 1. Let `client` be a `MongoClient`.
308+
client = self.client
309+
310+
# 2. Let `coll` be a collection.
311+
coll = client.test.test
312+
313+
# 3. Configure the random number generator used for exponential backoff jitter to always return a number as
314+
# close as possible to `1`.
315+
random_func.return_value = 1
316+
317+
# 4. Configure the following failPoint:
318+
fail_point = dict(
319+
mode="alwaysOn",
320+
data=dict(
321+
failCommands=["insert"],
322+
errorCode=462,
323+
errorLabels=["SystemOverloadedError", "RetryableError"],
324+
appName=self.app_name,
325+
),
326+
)
327+
async with self.fail_point(fail_point):
328+
# 5. Insert the document `{ a: 1 }`. Expect that the command errors. Measure the duration of the command
329+
# execution.
330+
start0 = perf_counter()
331+
with self.assertRaises(OperationFailure):
332+
await coll.insert_one({"a": 1})
333+
end0 = perf_counter()
334+
exponential_backoff_time = end0 - start0
335+
336+
# 6. Run the following command to set up `baseBackoffMS` on overload errors.
337+
try:
338+
await client.admin.command("setParameter", 1, externalClientBaseBackoffMS=50)
339+
340+
# 7. Execute step 5 again.
341+
start1 = perf_counter()
342+
with self.assertRaises(OperationFailure) as ctx:
343+
await coll.insert_one({"a": 1})
344+
end1 = perf_counter()
345+
with_base_backoff_ms_time = end1 - start1
346+
347+
# 8. Assert the server attached `baseBackoffMS` to the error and the driver parsed it.
348+
self.assertEqual(ctx.exception._base_backoff_ms, 50)
349+
finally:
350+
# 9. Run the following command to disable `baseBackoffMS` on overload errors.
351+
await client.admin.command("setParameter", 1, externalClientBaseBackoffMS=0)
352+
353+
# 10. Assert absolute bounds on each run's duration.
354+
# A run can never be faster than the sum of its backoffs.
355+
# With jitter pinned to 1, the default backoffs are 0.2 + 0.4 = 0.6s
356+
# and the baseBackoffMS=50 backoffs are 0.1 + 0.2 = 0.3s.
357+
self.assertGreaterEqual(exponential_backoff_time, 0.6)
358+
self.assertGreaterEqual(with_base_backoff_ms_time, 0.3)
359+
self.assertLess(with_base_backoff_ms_time, 0.6)
360+
297361

298362
# Location of JSON test specifications.
299363
if _IS_SYNC:

test/asynchronous/test_client_metadata.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,8 @@ async def test_handshake_documents_include_backpressure(self):
229229
await client.admin.command("ping")
230230

231231
# Assert that for every handshake document intercepted:
232-
# the document has a field `backpressure` whose value is `true`.
233-
self.assertEqual(self.handshake_req["backpressure"], True)
232+
# the document has a field `backpressure` whose value is `"2"`.
233+
self.assertEqual(self.handshake_req["backpressure"], "2")
234234

235235

236236
if __name__ == "__main__":

0 commit comments

Comments
 (0)