Skip to content

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

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

om singhal (Om-singhaI) wants to merge 15 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
100% 83% 8063 of 9620

Files needing attention

mssql_python.pybind.logger_bridge.cpp: 57.9%
mssql_python.pybind.ddbc_bindings.h: 61.5%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.pybind.ddbc_bindings.cpp: 77.5%
mssql_python.row.py: 77.6%
mssql_python.__init__.py: 81.2%
mssql_python.pybind.connection.connection_pool.cpp: 82.2%
mssql_python.pybind.connection.connection.cpp: 84.4%
mssql_python.connection.py: 86.4%
mssql_python.logging.py: 86.9%

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.

base_delay and max_delay are now limited to one day. A huge int used to
escape as OverflowError from math.isfinite, and a delay above what
time.sleep accepts passed validation and then failed inside the retry loop.

A retriable_sqlstates value that is not iterable raises ValueError instead
of TypeError, and each code must be five ASCII letters or digits, so
upper() cannot change its length and every accepted code can match a
driver SQLSTATE.

Also corrects the jitter bound in the compute_delay docstring, seeds the
jitter spread test, and says in the CHANGELOG that full jitter is on by
default.
The stub imported TokenProvider without the redundant alias that marks a
stub import as public, under a comment that only described RetryPolicy.

test_028 parses connection.py, db_connection.py and the stub with ast and
fails if the kind, name, order or default of any parameter of
Connection.__init__ or connect() drifts between them. That drift is how
retry_policy ended up in token_provider's positional slot.
Adds three tests, each of which fails if the matching part of the loop
regresses:

- a wrong retry_policy type raises TypeError before a token_provider is
  asked for a token
- an exception from the native constructor that is not a RuntimeError,
  such as an InterfaceError from a deferred token factory, is raised once
  as its own type and not retried
- with Authentication=ActiveDirectoryMsi, every attempt gets the same
  token factory and the same non-empty pool key

The token_provider test now also asserts that every attempt gets identical
arguments. The comment on the first retry test no longer claims it covers a
pool key and factory that its connection string does not have.
The comment said the stored policy would later be picked up by cursor level retries, which this change does not ship. The attribute stays; only the forward looking sentence goes.
Issue microsoft#682 asks for a log record on each retry and on the final give up. Each retry already logged a warning; now, when a retried connect still fails, one more warning names the attempt that failed last, the attempt limit and its SQLSTATE before _raise_connection_error runs as before. A first try failure, with or without a policy, logs nothing new, and the exception is unchanged.
@Om-singhaI

Copy link
Copy Markdown
Contributor Author

Gaurav Sharma (@bewithgaurav) Sumit Sarabhai (@sumitmsft) I've addressed everything from the earlier rounds, including the stub order Copilot caught. Could you take another look when you get a chance? And could you approve the workflow runs too? They're stuck on approval since this comes from a fork.

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

Final retry-failure logging must also handle non-RuntimeError exceptions after a retry.

Review details

Suppressed comments (1)

mssql_python/connection.py:922

  • The loop only handles RuntimeError. If a deferred token factory (or another pybind callback) raises a non-RuntimeError after an earlier transient attempt has already been retried, that exception bypasses the attempt > 1 warning and is re-raised without the documented final give-up log. Preserve the original exception type, but emit the final warning (with SQLSTATE reported as none) for any failed attempt after a retry.
            except RuntimeError as e:
  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 12, 2026 23:14

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 connection retry path and public API changes warrant final human review.

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

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.

4 participants