Skip to content

fix(asyncio): raise on EOF in can_read() so pools replace server-closed connections - #4260

Merged
petyaslavova merged 3 commits into
redis:masterfrom
Brumbelow:master
Aug 17, 2026
Merged

petyaslavova merged 3 commits into
redis:masterfrom
Brumbelow:master

Conversation

@Brumbelow

@Brumbelow Brumbelow commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Description of change

Fixes #4252.

In redis.asyncio, a pooled connection that the server closed while it sat idle (idle timeout, CLIENT KILL, server-side reap) is handed out to the next command instead of being replaced, and the command fails with ConnectionError: Connection closed by server.. Regression in 8.1.0 under RESP3 (the default); 8.0.1 recovers transparently.

Root cause: the and not self.maint_notifications_enabled() guard that #4177 added to ensure_connection()'s checkout probe. The async parsers' non-destructive can_read() (#4063, shipped in 8.0.1, which recovers correctly) reports a server-closed stream by returning True — EOF folded into "has readable data" — and in 8.0.1 that True unconditionally hits the Connection has data branch, which disconnects and reconnects. With RESP3 over TCP the pool auto-creates MaintNotificationsConfig(enabled="auto"), so since #4177 the exemption is on by default and the reconnect branch is never entered: the dead connection passes checkout. RESP2 connections get no maintenance-notifications handler, which is why RESP2 still recovers.

The sync side doesn't have this problem: SocketBuffer.can_read() raises ConnectionError(SERVER_CLOSED_CONNECTION_ERROR) on EOF, which the pool's except (ConnectionError, TimeoutError, OSError) branch turns into disconnect + reconnect regardless of any exemption.

This change makes the async parsers (_AsyncRESPBase, _AsyncHiredisParser) behave the same way: raise ConnectionError on at_eof() instead of returning True, with already-buffered data still reported first (matching the sync parsers' buffer-first ordering, so a pending reply or push notification is delivered rather than discarded). ensure_connection()'s existing reconnect branch then replaces the dead connection whether or not the pending-push-data exemption applies — the closed-connection signal can no longer be swallowed by that guard, rather than only reordering the guard itself. The async parser.can_read() is reached only through Connection.can_read(), whose only caller is the pool checkout path, so the blast radius is that seam.

Verified with the reproduction from #4252 (server closes the idle pooled connection, health check due): before this change RESP3 raises and RESP2 recovers; after it both recover, and 8.0.1 behavior is restored.

One additional observation from the issue's repro, out of scope here: it passes the sync redis.retry.Retry into the async pool. Awaiting the sync call_with_retry silently degrades it to a single attempt with no failure callback, which is why the health-check PING failure surfaced unretried. With redis.asyncio.retry.Retry the client recovers even without this fix (at the cost of a failed attempt and reconnect churn). Filed separately as #4262.

Pull Request check-list

  • Do tests and lints pass with this change?
  • Do the CI tests pass with this change (enable it first in your forked repo and wait for the github action build to finish)?
  • Is the new or changed code fully tested?
  • [N/A] Is a documentation update included (if this change modifies existing APIs, or introduces new ones)?
  • [N/A] Is there an example added to the examples folder (if applicable)?

…ls reconnect them

The async parsers folded EOF into can_read()'s True return. Since redis#4177,
ensure_connection() skips the pending-data check when maintenance
notifications are enabled (pending push messages are legitimate), which
also swallowed the EOF signal: a connection the server closed while idle
in the pool was handed out, and the next command failed with
'Connection closed by server.' instead of being transparently replaced.

Raise ConnectionError on EOF instead, matching what the sync
SocketBuffer has always done, so ensure_connection()'s existing
reconnect branch handles it regardless of the push-data exemption.

Fixes redis#4252

@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: 0ea0a16dd0

ℹ️ 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/_parsers/hiredis.py Outdated
Match the sync parsers' ordering: a reply or push notification that is
already buffered (hiredis reader / parser buffer) stays readable even if
the server has since closed the connection, so checkout doesn't discard
a pending MOVING/maintenance notification. EOF with nothing buffered
still raises, which is the redis#4252 case.

@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 @Brumbelow, thank you for your contribution!

A few things need to be addressed before we can merge this PR:

  1. Please correct the root-cause attribution in the description. #4063 shipped in 8.0.1, which recovers correctly with the same "EOF ⇒ True" parser code — the async can_read() implementations are byte-identical between v8.0.1 and master, so it isn't a co-cause. The regression is only the and not self.maint_notifications_enabled() guard added in #4177: with RESP3 over TCP the pool auto-creates MaintNotificationsConfig(enabled="auto"), so the exemption is on by default and the reconnect branch is never entered. RESP2 gets no handler, which is why it still recovers.
  2. The comment in tests/test_asyncio/test_scenario/test_maint_notifications.py that explains its except RedisConnectionError still describes the old behaviour ("can_read() reports True on EOF ... so the drain enters read_response()"). Please update it.
  3. Parametrize the new pool test over _AsyncHiredisParser as well, and add a real-server regression test for #4252 using client_kill_filter(_id=...) on an idle pooled connection — the current test asserts the branch, not end-to-end recovery.

And yes, please do file the sync-Retry-in-an-async-pool degradation separately — good catch, and it deserves its own guard.

… comment

- Parametrize the pool checkout test over _AsyncHiredisParser (skipped
  when hiredis is not installed), stubbing the reader that on_connect()
  would normally create.
- Add an end-to-end regression test for redis#4252: kill an idle pooled
  connection server-side via client_kill_filter(_id=...), with retries
  disabled so recovery can only come from pool checkout. Fails on
  pre-fix master, passes with the fix (verified against a local
  redis-server 7.0.15, RESP3 default).
- Update the drain-loop comment in the async maint-notifications
  scenario test that still described the old EOF-returns-True behavior.
@Brumbelow

Copy link
Copy Markdown
Contributor Author

Hello @petyaslavova! TY for those catches. This should be good to go now, updated description and tests

@Brumbelow
Brumbelow requested a review from petyaslavova August 13, 2026 15:29

@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.

LGTM.

@petyaslavova
petyaslavova merged commit 0d2ce3b into redis:master Aug 17, 2026
1452 of 1457 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unexpected Connection closed by server behavior on redis==8.1.0

2 participants