Skip to content

FEAT: add optional RetryPolicy for transient failures on connect() (GH-682) - #751

Open
om singhal (Om-singhaI) wants to merge 22 commits into
microsoft:mainfrom
Om-singhaI:om/feat/retry-policy
Open

om singhal (Om-singhaI) wants to merge 22 commits into
microsoft:mainfrom
Om-singhaI:om/feat/retry-policy

Conversation

@Om-singhaI

@Om-singhaI om singhal (Om-singhaI) commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Work Item / Issue Reference

GitHub Issue: #682


Summary

First of two PRs for #682, connect() scope only; cursor and execute() retry follow separately. Adds mssql_python.RetryPolicy and retry_policy= on connect() / Connection(), with the constructor shape from the issue. The issue's backoff="none" is spelled backoff="fixed", base_delay=0 here. Without a policy nothing changes.

How it works

  • The loop wraps only the native connect in Connection.__init__, below connection string parsing and any token acquired on the Python side, so every attempt reuses the same inputs.
  • The SQLSTATE is read from the SQLSTATE:XXXXX:message the C++ layer already throws. exceptions.py is untouched, so this does not preempt FEAT: Expose SQLSTATE (and native error number) as attributes on exception objects #581.
  • Retriable code with attempts left: a warning line (attempt, SQLSTATE, delay), sleep, retry. Otherwise _raise_connection_error runs exactly as today, same exception type, nothing rewrapped. If at least one retry happened, one more warning says which attempt failed last and with what SQLSTATE, so the give up shows in the logs too.
  • Default set is the seven transient SQLSTATEs from the Learn retry page: HYT00 HYT01 08001 08S01 08007 40001 40003. 08004 stays out, the page lists it under never retry. retriable_sqlstates= replaces the set.
  • max_attempts is total tries including the first, as in the issue. The Learn sample counts retries instead.
  • Every invalid setting raises ValueError. base_delay and max_delay top out at 86400 seconds, so a policy that validates can't fail inside time.sleep halfway through a retry.
  • The stub now declares token_provider ahead of retry_policy on connect() and Connection(). It was missing upstream, which put retry_policy in its positional slot.

Out of scope

Azure SQL throttling. Those are engine error numbers, and the native number is dropped in SQLCheckError_Wrap. Plumbing it out is #581's territory, so it goes with the second PR.

Validation

  • tests/test_027_retry_policy.py: 74 passed, no server. The native constructor is faked as in test_006_exceptions.py, and retry._sleep / _random are patched so delay sequences are asserted exactly. No policy is one attempt and the same OperationalError; two transient failures then success is three calls with sleeps [1.0, 2.0]; exhaustion keeps the mapped type; 28000, 08004, 42000 and a message with no SQLSTATE fail once; a transient failure followed by 28000 or no SQLSTATE logs one retry line and one give up line; an error of another type after a retry, like one from a deferred token factory, keeps its own type and still logs the give up; a token_provider token is acquired once across three attempts; bad settings, including huge ints, delays over a day and SQLSTATEs that aren't five ASCII letters or digits, raise ValueError; log lines never contain the connection string.
  • tests/test_028_stub_signature_parity.py: 3 passed. It parses the stub and the runtime with ast, needs no native module, and fails if connect() or Connection.__init__ drift in name, order, kind or default.
  • test_006_exceptions.py server free tests: 20 passed. black and flake8 with the CI flags clean.
  • Not run against a live server. The failure path is the unchanged _raise_connection_error.

…icrosoftGH-682)

I added mssql_python.retry.RetryPolicy and retry_policy= on connect() and
Connection(); cursor and execute() scope follow in a second PR. The loop wraps
only the native connect, below connection string parsing and any token acquired
on the Python side, so those run once; a deferred token factory is still
invoked by native on each attempt. It retries the seven transient SQLSTATEs
from the driver's retry logic page on Learn; without a policy nothing changes.
Copilot AI lite review requested due to automatic review settings September 3, 2026 22:56
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes the core connection-establishment path (native connect invocation and retry timing/logging), which merits final human verification against real-world/native-layer behaviors beyond the included server-free tests.

Pull request overview

Adds an opt-in RetryPolicy API to the pure-Python DB-API surface so connect() / Connection(...) can automatically retry native connect failures classified as transient by SQLSTATE, without changing default behavior for existing callers.

Changes:

  • Introduces mssql_python.retry.RetryPolicy (configurable attempts, backoff, jitter, SQLSTATE allowlist) and exports it from the package.
  • Wraps the native ddbc_bindings.Connection(...) call in Connection.__init__ with retry + warning logs, leaving the existing exception mapping path intact on final failure.
  • Adds server-free tests covering retry behavior, delay sequences, non-retriable failures, token-acquisition reuse, and log redaction; updates stubs and changelog.
File summaries
File Description
tests/test_027_retry_policy.py Adds server-free unit tests validating connect-scope retry behavior, delay computation, and logging expectations.
mssql_python/retry.py Implements RetryPolicy, default transient SQLSTATE set, and deterministic seams for sleep/random in tests.
mssql_python/mssql_python.pyi Extends public type stubs with RetryPolicy and the new retry_policy parameters.
mssql_python/db_connection.py Plumbs retry_policy through the public connect() wrapper and documents the new parameter.
mssql_python/connection.py Adds SQLSTATE extraction helper and wraps native connect with policy-driven retry + warning logs.
mssql_python/__init__.py Exports RetryPolicy and includes it in __all__.
CHANGELOG.md Documents the new opt-in retry policy feature and its default semantics.
Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

# Conflicts:
#	CHANGELOG.md
#	mssql_python/connection.py
@Om-singhaI

Copy link
Copy Markdown
Contributor Author

Sumit Sarabhai (@sumitmsft) this is the connect() half of #682. Nothing has run on it beyond the CLA check, so I think it needs someone to kick off the pipelines.

The statement scope half is built on top of this branch and I've been holding it back rather than stacking two open PRs on the same issue. Happy to open it as soon as this one lands, or sooner if you'd rather review them together.

One thing I'd flag while you're in here: max_attempts counts total tries, so 1 means no retry. The docs page counts retries instead. I went with the issue, but say if you'd rather match the docs and I'll change it.

Copilot AI review requested due to automatic review settings September 8, 2026 17:29
@bewithgaurav

Copy link
Copy Markdown
Collaborator

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes the core connection-establishment path and depends on native error formatting behavior, but lacks live-server validation to confidently confirm real-world retry classification and timing.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@github-actions

github-actions Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

Code Coverage Report

Diff coverage Overall coverage Lines covered
99% 84% 9467 of 11150

Files needing attention

mssql_python.pybind.performance_counter.hpp: 0.7%
mssql_python.pybind.logger_bridge.cpp: 57.9%
mssql_python.pybind.ddbc_bindings.h: 62.6%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.pybind.ddbc_bindings.cpp: 79.1%
mssql_python.__init__.py: 81.2%
mssql_python.pybind.connection.connection_pool.cpp: 82.3%
mssql_python.pybind.connection.connection.cpp: 83.1%
mssql_python.logging.py: 86.9%
mssql_python.pooling.py: 90.1%

View Azure DevOps build

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this addresses the connection-retry scope. I'd like capped retries to stay spread out before this lands; the other comments are cleanup suggestions. requesting changes.

Comment thread mssql_python/retry.py Outdated
doublings -= 1
delay = min(delay, self.max_delay)
if self.jitter:
delay = min(delay * (0.5 + _random()), self.max_delay)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: clients retrying the same outage lose part of the intended spread once the delay reaches max_delay. every random draw at or above 0.5 becomes exactly the same wait because of the second cap.

in a controlled sweep of 10,000 draws at a 30-second cap, 5,000 returned exactly 30 seconds. this is a delay calculation result, not a concurrent load measurement.

can we use full jitter over the already-capped delay instead?

Suggested change
delay = min(delay * (0.5 + _random()), self.max_delay)
delay *= _random()

this deliberately changes the documented behavior and allows shorter waits, including zero. please update the jitter docstrings and assertions together, including a case where the backoff has reached the cap.

Comment thread mssql_python/mssql_python.pyi Outdated
) -> Dict[str, Any]: ...

# Retry Policy for transient failures at connect() time
class RetryPolicy:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: can we re-export the annotated RetryPolicy instead of maintaining a second copy of its constructor, properties and methods here?

from .retry import RetryPolicy as RetryPolicy

this keeps the public type and read-only properties without duplicating the policy API. the FrozenSet import can go once the copied class block is removed.

Comment thread tests/test_027_retry_policy.py Outdated
Comment on lines +94 to +109
def driver_log():
"""Attach a recording handler to the driver logger for the duration of a test.

The underlying stdlib logger sits at CRITICAL until setup_logging() is called, so its level
is lowered to WARNING here and restored afterwards; nothing else about logging is changed.
"""
stdlib_logger = logging.getLogger("mssql_python")
previous_level = stdlib_logger.level
stdlib_logger.setLevel(logging.WARNING)
handler = RecordingHandler()
mssql_python.logging.logger.addHandler(handler)
try:
yield handler
finally:
mssql_python.logging.logger.removeHandler(handler)
stdlib_logger.setLevel(previous_level)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: can we reuse caplog here and drop RecordingHandler plus the manual log-level restoration?

the driver logger doesn't propagate, so attach caplog.handler directly, use caplog.at_level(logging.WARNING, logger="mssql_python"), and remove the handler in finally. the assertions can use caplog.records.

…dler

Jitter scaled the delay by a factor in [0.5, 1.5) and then clamped to max_delay,
so once backoff reached the cap every draw at or above the midpoint produced
exactly max_delay. At a 30 second cap that was 49.7 percent of draws landing on
the same number, which is the point at which spreading clients out matters most.
It now scales down by a factor in [0, 1), so a capped delay lands anywhere in
[0, max_delay). Waits can be shorter than base_delay and can be zero, and the
docstrings and assertions say so. Added a test that a capped delay never returns
max_delay and does not pile up in any tenth of the range.

The type stub kept a hand written copy of the RetryPolicy constructor, properties
and methods. It re-exports the annotated class instead, so there is one source of
truth, and the FrozenSet import goes with it.

The logging test used a hand rolled handler and restored the logger level by hand.
It uses caplog with at_level now, attaching caplog.handler directly because the
driver logger does not propagate.
Copilot AI review requested due to automatic review settings September 9, 2026 04:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The change is opt-in, localized to connect-time behavior, and is backed by thorough server-free unit tests asserting retries, delays, and logging.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@Om-singhaI

Copy link
Copy Markdown
Contributor Author

All three done.

Jitter's full now, delay *= _random(). I ran your case before touching it and got 49.7% landing on exactly the cap over 100k draws at 30 seconds, so your 5000 in 10000 holds. Docstrings say the wait can be shorter than base_delay and can be zero, and there's a new test that a capped delay never comes back as max_delay and doesn't bunch up in any tenth of the range.

The stub imports RetryPolicy from retry now instead of keeping a copy. FrozenSet went with it.

Took the caplog one too. RecordingHandler and the manual level restore are gone.

Copilot AI review requested due to automatic review settings September 10, 2026 00:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The retry behavior is opt-in, narrowly scoped to native connect, and is covered by comprehensive server-free tests that validate correctness and logging expectations.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 11, 2026 07:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The type-stub parameter mismatch and retry-policy validation issues must be corrected before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (3)

mssql_python/mssql_python.pyi:366

  • The top-level stub has the same positional mismatch with db_connection.connect, whose runtime signature includes token_provider before retry_policy (mssql_python/db_connection.py:13-21). A positional policy can type-check against this declaration but is bound to token_provider at runtime, causing the connection to fail before retrying. Add token_provider before retry_policy here as well.
    retry_policy: Optional[RetryPolicy] = None,
    **kwargs: Any,

mssql_python/retry.py:35

  • An arbitrarily large integer reaches math.isfinite and can raise OverflowError during float conversion instead of the documented ValueError for an out-of-range delay. Catch this conversion overflow (or otherwise perform a safe finite-number check) so invalid base_delay/max_delay values consistently use the constructor's documented exception type.
def _is_finite_number(value: object) -> bool:
    """Return True for a finite int or float that is not a bool."""
    return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)

mssql_python/retry.py:58

  • A non-iterable value such as RetryPolicy(retriable_sqlstates=123) reaches this loop and leaks the incidental TypeError: 'int' object is not iterable. The constructor and _normalize_sqlstates document ValueError for invalid setting types, so validate the iterable boundary and raise a deliberate ValueError instead.
    for code in codes:
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread mssql_python/mssql_python.pyi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #751: No actionable findings in the reviewed changes. Retries remain opt-in and limited to connection establishment, with bounded attempts and capped jitter. Statements are not replayed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The changed code and supporting tests consistently implement the documented opt-in connect-only retry behavior.

Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@Om-singhaI

Copy link
Copy Markdown
Contributor Author

Pushed fixes for both of today's comments.

test_028: _signature now tags posonlyargs and args separately, so a stub that drops the / no longer matches the runtime. Defaults still line up across both groups. The new case compares def f(a, /, b=1) with def f(a, b=1): the old helper reported them as matching, the new one doesn't.

test_027: the spread test fed _random from a seeded random.Random, which is what devskim flagged. It now gets an evenly spaced sweep over [0, 1), so the test has no random source and doesn't depend on a seed. The old clamping jitter still fails both assertions under the sweep.

The Debian ARM64 failure is test_concurrent_connections_with_same_token_provider in test_008_auth.py. It fails inside conn.close() with the native connect mocked, and without a retry_policy the connect loop makes a single attempt. This branch doesn't touch close() or that file, so I don't think it comes from this change, but a rerun would show whether it repeats.

Sumit Sarabhai (@sumitmsft) Gaurav Sharma (@bewithgaurav) the workflow runs on the new commits are waiting for approval again. Could one of you approve them and /azp run?

@bewithgaurav

Copy link
Copy Markdown
Collaborator

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Copilot AI review requested due to automatic review settings September 18, 2026 09:29
@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

PR Performance Report

✅ No regression detected

No consistent slowdowns detected across all 2 environments.

0 IMPROVEMENTS 0 SLOWDOWNS 2/2 ENVIRONMENTS

Coverage: 2 of 2 environments completed. Advisory result; does not block merging.

Performance diagnostics

Phase times are inclusive diagnostics and must not be added together. They identify where measured time changed, not why it changed.

No affected phases or call-count changes were recorded.

All database tasks and timings

Unix / SQL Server 2022

Database task Before After Paired change Result
Connection opening 9.973 ms 10.305 ms +3.3% no signal
SELECT queries 1.098 ms 1.226 ms +4.0% no signal
Row insertion 34.280 ms 33.952 ms -1.0% no signal
Executemany inserts 155.802 ms 153.445 ms -1.6% no signal
Fetch-all queries 119.041 ms 120.239 ms -0.5% no signal
Row-by-row fetching 14.155 ms 14.137 ms -1.6% no signal
Batched row fetching 116.085 ms 116.391 ms -0.1% no signal
Transaction commit and rollback 112.564 ms 111.849 ms -0.2% no signal
Arrow row fetching 92.750 ms 94.295 ms +1.2% no signal
100,000-row insertion 431.607 ms 433.146 ms +0.4% no signal
Row fetching in batches of 100 122.631 ms 122.391 ms +0.7% no signal
Row fetching in batches of 10,000 133.889 ms 137.681 ms +2.8% no signal
Repeated positional queries 33.338 ms 33.560 ms +0.4% no signal
Repeated named-parameter queries 34.595 ms 35.844 ms +1.7% no signal
Legacy 100,000-row insertion 341.625 ms 342.572 ms +1.0% no signal
Insertion with explicit input sizes 497.338 ms 478.557 ms -3.5% no signal
Joined aggregation queries 179.734 ms 180.040 ms +0.7% no signal
Large joined-result fetching 177.007 ms 175.197 ms -1.3% no signal
1.2-million-row fetching 3422.269 ms 3443.948 ms +1.0% no signal
Common table expression queries 5.394 ms 5.360 ms +0.9% no signal
256 KiB VARCHAR(MAX) / fetchall() 1.247 ms 1.293 ms +3.0% no signal

Unix / SQL Server 2025

Database task Before After Paired change Result
Connection opening 96.815 ms 96.750 ms -0.2% no signal
SELECT queries 1.096 ms 1.134 ms -0.6% no signal
Row insertion 37.052 ms 33.141 ms -5.2% no signal
Executemany inserts 152.397 ms 150.762 ms -2.2% no signal
Fetch-all queries 118.606 ms 119.157 ms -0.0% no signal
Row-by-row fetching 14.348 ms 14.130 ms -2.8% no signal
Batched row fetching 116.955 ms 116.148 ms +0.1% no signal
Transaction commit and rollback 113.265 ms 110.953 ms -3.1% no signal
Arrow row fetching 92.117 ms 93.950 ms +1.2% no signal
100,000-row insertion 438.271 ms 430.562 ms -3.2% no signal
Row fetching in batches of 100 119.744 ms 120.561 ms -0.2% no signal
Row fetching in batches of 10,000 123.471 ms 137.270 ms +13.0% no signal
Repeated positional queries 32.569 ms 32.493 ms +0.3% no signal
Repeated named-parameter queries 34.673 ms 35.017 ms +0.4% no signal
Legacy 100,000-row insertion 349.651 ms 352.521 ms -2.7% no signal
Insertion with explicit input sizes 471.981 ms 497.790 ms -0.3% no signal
Joined aggregation queries 160.755 ms 158.580 ms -1.7% no signal
Large joined-result fetching 178.350 ms 182.148 ms -1.8% no signal
1.2-million-row fetching 3553.539 ms 3492.181 ms -1.7% no signal
Common table expression queries 5.134 ms 5.097 ms -0.9% no signal
256 KiB VARCHAR(MAX) / fetchall() 1.452 ms 1.471 ms +2.0% no signal
Build and measurement details

ADO build 178162

PR head: 063f0ea2a57e02f3df497615484dff0bb58021d6
Base: 30893611a5858942b4a5c8576e433e8b2a3913a7
Measured merge: f2cb9db82a4652929f4a795224247dafe7ef67bc

  • Unix / SQL Server 2022: Python 3.12.3, x86_64, SQL 16.0.4295.3; 5 paired comparisons and 1 warmup.
  • Unix / SQL Server 2025: Python 3.12.3, x86_64, SQL 17.0.5005.3; 5 paired comparisons and 1 warmup.

A consistent change requires more than 20% median paired movement, at least 1 ms between the median runtimes, and at least 80% of pairs exceeding the relative threshold in the same direction. A slowdown without enough pair agreement is reported as inconsistent.

The displayed change is the median of paired before-and-after ratios. It is not recalculated from the two displayed median runtimes.

Both revisions use profiling-enabled builds on the same agent and database, with alternating order and discarded warmups. Results are diagnostic and do not represent production-wheel latency.

Raw samples and logs are attached to the ADO run as profiler-* artifacts.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The retry behavior is narrowly scoped, thoroughly tested, and preserves default connection and exception behavior.

Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 18, 2026 10:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The change affects connection establishment, authentication, pooling, and retry timing, and was not validated against a live SQL Server.

Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 18, 2026 17:45
auto-merge was automatically disabled September 18, 2026 17:45

Head branch was pushed to by a user without write access

@Om-singhaI

Copy link
Copy Markdown
Contributor Author

Merged main again to clear the conflict from #613. RowMapping and RetryPolicy both go into __all__, and nothing else changed. Gaurav Sharma (@bewithgaurav) the runs on the new head will need approving again. The coverage report on the last head timed out waiting for the Azure build, so it'll need /azp run too.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The implementation, integration, API typing, logging, and focused tests were reviewed with no blocking defects found.

Review details
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 25, 2026 08:54
@bewithgaurav

Copy link
Copy Markdown
Collaborator

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Invalid retry-policy validation can cause an AttributeError cleanup warning before initialization completes.

Review effort: Lite
Findings: None

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants