Skip to content

fix(connection): opt-in fast-fail on missing Unix socket (ENOENT) - #4558

Merged
petyaslavova merged 13 commits into
redis:masterfrom
BlueX888:takeover/4166-enoent-retry-checker
Nov 5, 2026
Merged

petyaslavova merged 13 commits into
redis:masterfrom
BlueX888:takeover/4166-enoent-retry-checker

Conversation

@BlueX888

@BlueX888 BlueX888 commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Takes over #4166 per @petyaslavova's open-for-takeover note. Builds on @sean-kim05's commits (author preserved — 97b25374 is a cherry-pick -x of 3459a64) and applies the review checklist. Links #4026 (kept open per the original PR's request; this covers the ENOENT case only).

Change

  • Opt-in retryability checker on Retry: AbstractRetry gains an optional is_retryable checker (default None = unchanged behavior), AND-composed with supported_errors and the per-call predicate in both the sync and async call_with_retry; preserved through _to_async_retry and deepcopy, and included in equality/hash.
  • Reusable ENOENT helper: is_retryable_connect_error returns False only when __cause__ is an OSError with errno == ENOENT — shipped as a helper for the checker, not as always-on behavior (the original PR's hardcoded default is removed).
  • Root cause preserved: raise ConnectionError(...) from e at the ConnectionError-from-OSError wrap sites in connection.py and asyncio/connection.py (connect / read / write / can_read). The async send path already used from None, and TimeoutError wraps are unchanged — both pre-existing on master.
  • The sync and async stacks mirror each other.

Behavior

  • Without the checker: identical to master (ENOENT is still retried; covered by tests).
  • With is_retryable=is_retryable_connect_error: a missing Unix socket fails after exactly one attempt.
  • Precision note: the helper inspects __cause__ only, so a raw OSError raised straight into the lazy-reconnect inner retry is still treated as retryable (conservative True, as discussed in the review). The default opt-in configuration is unaffected.

Tests

  • tests/test_retry.py / tests/test_asyncio/test_retry.py: checker composition with supported-errors and the per-call predicate, conversion through _to_async_retry, deepcopy, and default-behavior-unchanged; 19 new test cases.
  • tests/test_connection.py / tests/test_asyncio/test_connection.py: a real missing-Unix-socket fast-fail with the checker (exactly one attempt) versus without it (multiple attempts), plus __cause__ preservation. These are POSIX-only and skip on Windows — their first real run is Linux CI on this PR.
  • Local suites (Windows): base-versus-branch failure sets identical apart from the new passes/skips; ruff check, ruff format --check, and vulture are clean.

Red evidence: against ac8f8c1e the new tests fail collection (helper/checker absent); against the original commit alone, the default ENOENT behavior is the always-on fast-fail that the review asked to make opt-in.


Note

Medium Risk
Touches core connection retry and exception-wrapping paths in sync and async stacks; default behavior is unchanged but misconfigured checkers or reliance on old exception shapes could affect callers.

Overview
Adds an opt-in retry filter on Retry / AbstractRetry: an optional is_retryable callable runs after the usual supported_errors match and is AND-composed with any per-call is_retryable in call_with_retry. Default is None, so existing clients behave the same.

Ships is_retryable_connect_error, which returns False when the raised ConnectionError chains an OSError with ENOENT (missing Unix socket path), so callers can fail after one attempt instead of burning the full retry/backoff budget. Transient errors like ECONNREFUSED stay retryable.

To support that classification, sync and async connection code now raises ConnectionError from the underlying OSError on connect/read/write/can_read paths (with a connect-path cleanup to avoid traceback reference cycles). When the retry-level checker rejects an error, call_with_retry still invokes the failure handler before re-raising so pipeline WATCH cleanup and similar hooks run.

Documentation in docs/retry.rst describes the checker; _to_async_retry, equality, and deepcopy preserve is_retryable. Broad test coverage covers checker composition, ENOENT fast-fail vs default retries, cause chaining, and pipeline reset on checker rejection.

Reviewed by Cursor Bugbot for commit a424ff3. Bugbot is set up for automated code reviews on this repo. Configure here.

sean-kim05 and others added 3 commits November 2, 2026 20:59
connect() wrapped the whole connect+handshake in the retry policy and re-wrapped every OSError into a generic ConnectionError, so definitively unrecoverable failures such as a missing Unix socket path (ENOENT) were retried DEFAULT_RETRY_COUNT times with full exponential backoff.

Pass an is_retryable predicate from connect() that returns False for a conservative set of unrecoverable errnos (currently ENOENT), and preserve the original OSError as the exception cause (raise ConnectionError(...) from e) so the retry policy can classify it. Transient errors like ECONNREFUSED during server startup remain retryable, so the intentional handshake-retry behavior is unchanged.

Mirrored across the sync and async connection stacks, with tests in both.

(cherry picked from commit 3459a64)
Add an optional is_retryable callable to AbstractRetry and both Retry
implementations. When set, an error is retried only if its type is in
supported_errors AND the checker returns True, matching the existing
per-call checker semantics. The default (None) leaves behavior unchanged.

Ship is_retryable_connect_error as a reusable checker that returns False
only for a ConnectionError chained from an ENOENT OSError, so callers can
opt in to failing fast on a missing Unix socket path.

Preserve the checker when converting a sync Retry to the asyncio one and
when deep-copying a policy.
Chain the original OSError with `raise ... from e` at every retryable
wrap site in the sync and async connection stacks (connect, socket
write, socket read and can_read), so a cause-inspecting retry checker
can reach the root errno.

Drop the always-on ENOENT classification from the connection classes:
the reusable checker now lives on the Retry object, so callers opt in
and the default retry behavior is unchanged. Regression tests cover a
real missing Unix socket path (POSIX-only) with and without the checker,
the default retries for ENOENT and ECONNREFUSED, checker composition,
and cause preservation at the read, write and can_read wrap sites.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-05T12:34:03.617003Z a424ff3 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5da3fa6029

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread redis/connection.py Resolved
@BlueX888
BlueX888 force-pushed the takeover/4166-enoent-retry-checker branch from 3be360b to 8fd61c2 Compare November 3, 2026 03:17
@BlueX888
BlueX888 force-pushed the takeover/4166-enoent-retry-checker branch from 8fd61c2 to ce7bd76 Compare November 3, 2026 03:17

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12dd43d040

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread redis/retry.py Resolved

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e883bfbcdc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread redis/retry.py Resolved

@petyaslavova petyaslavova left a comment

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.

Hey @BlueX888, thank you for your contribution!

This follows the takeover checklist closely, and the design matches what I asked for in #4166. Two small test fixes are needed before merge:

  • In tests/test_asyncio/test_retry.py, the new tests were inserted in the middle of test_pool_converts_sync_retry, so its set_retry half now runs inside test_client_conversion_preserves_checker. Please restore the original test unchanged and keep the new test self-contained.
  • test_lazy_reconnect_uses_retry_checker (sync and async) makes _connect raise a chained ConnectionError, which the real _connect never does. On the real path, the raw OSError is not in supported_errors, so it passes through the inner retry. connect_check_health then wraps it, and the command-level retry stops it. Please replace this test with a client-level one, for example Redis(unix_socket_path=<missing path>, retry=Retry(NoBackoff(), 3, is_retryable=is_retryable_connect_error)).ping() asserting a single _connect call, or drop it.

Once those are in, this should be ready to merge.

Restore test_pool_converts_sync_retry unchanged and keep the new
conversion test self-contained. Replace the lazy-reconnect tests, whose
mock raised a chained ConnectionError that _connect never raises, with a
client-level test whose _connect raises the raw OSError the real path
produces; the client must still stop after a single attempt.
@BlueX888

BlueX888 commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the precise review — both points are fixed in 4e95fa8:

  • Restored test_pool_converts_sync_retry to its original form (including the set_retry half), and kept the new test_client_conversion_preserves_checker self-contained.
  • Replaced the two test_lazy_reconnect_uses_retry_checker tests with client-level test_client_connect_uses_retry_checker: Redis(unix_socket_path=..., retry=Retry(NoBackoff(), 3, is_retryable=is_retryable_connect_error)).ping() with UnixDomainSocketConnection._connect raising the raw OSError(ENOENT, ...) the real path produces. It asserts a single _connect call and that the original error is preserved as __cause__.

Verification: both new tests pass (sync and async); tests/test_asyncio/test_retry.py is 81 passed with only the pre-existing live-server test failing in an environment without Redis; ruff check and ruff format --check are clean. An independent reviewer confirmed the restored test is byte-identical to the base version and mutation-tested the new one (checker disabled -> 4 attempts -> test fails).

@BlueX888

BlueX888 commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

CI note on 4e95fa8: 123 checks pass, 2 skipped, and the only red job is the Docker Hub pull failure (502 Bad Gateway fetching redislabs/client-libs-test:7.2.14) in the Redis 7.2.14 / Python 3.15 / asyncio / legacy-responses matrix job — it fails at container pull, before the tests run. I don't have admin rights to rerun it, so it may need a maintainer rerun.

@petyaslavova petyaslavova left a comment

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.

Thanks for the quick update @BlueX888, both test fixes look good.

One more change is needed, from the Codex WATCH finding. When the Retry-level checker rejects an error, call_with_retry raises before fail runs. A watching pipeline relies on that callback (_disconnect_reset_raise_on_watching) to reset itself and raise WatchError. So with a checker that rejects, for example, TimeoutError, the pipeline stays watching=True. A caller that catches the timeout can then run MULTI/EXEC on a reconnected socket with no WATCH. Please make a rejection by the Retry-level checker call fail before raising, the same as an exhausted retry budget, and leave the per-call predicate unchanged. Please mirror this in the async Retry and add a watching-pipeline regression test on both sides.

I'm fine leaving the deepcopy finding as is, because backoff objects are already deep-copied the same way. Please reply on that thread so it can be resolved.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6b5201c301

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread redis/retry.py Resolved

@petyaslavova petyaslavova left a comment

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.

Thanks for the update @BlueX888, the fail call on a checker rejection and the watching-pipeline tests on both stacks are exactly what I asked for.

About the latest Codex PubSub finding: I agree with your reply. Running out of retries already calls fail (and so PubSub._reconnect()) on its final failure, so a rejection now matches that path. Splitting cleanup from reconnect is a separate design question and not needed for this PR. Please resolve that thread.

LGTM.

@petyaslavova petyaslavova added feature New feature and removed maintenance Maintenance (CI, Releases, etc) labels Nov 5, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f029ee8f3c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread redis/retry.py Resolved

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 948701f9c5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread redis/retry.py Resolved

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f9f7be652f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread redis/retry.py Resolved
@petyaslavova
petyaslavova merged commit 8476a0f into redis:master Nov 5, 2026
132 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants