33Events publish to channels derived from their event type. Redis Pub/Sub provides
44live, at-most-once fan-out: it has no replay, acknowledgement, or durability. The
55transport owns clients it creates, while injected clients remain caller-owned.
6- redis-py's connection retry and Pub/Sub resubscription behavior handle transient
7- reconnections; terminal client errors propagate to the consumer.
6+ redis-py's configured connection retry and Pub/Sub resubscription behavior may
7+ handle transient reconnects. Eventful adds no retry loop: failures left by the
8+ client are surfaced, and publications are never replayed.
89"""
910
1011from __future__ import annotations
1112
1213import asyncio
14+ import sys
1315from collections .abc import AsyncIterator
1416from typing import Any
1517
@@ -29,7 +31,7 @@ def __init__(self, transport: RedisTransport) -> None:
2931 self ._closed = False
3032
3133 async def publish (self , event : Event ) -> None :
32- """Publish one event to its prefixed event-type channel ."""
34+ """Publish once, surfacing any failure not retried by redis-py ."""
3335 if self ._closed :
3436 raise TransportError ("Redis publisher is closed" )
3537 self ._transport ._ensure_open ()
@@ -72,11 +74,11 @@ def topic(self) -> str:
7274 return self ._topic
7375
7476 async def subscribe (self ) -> None :
75- """Eagerly establish the subscription when readiness must be explicit ."""
77+ """Subscribe once, surfacing any failure not recovered by redis-py ."""
7678 await self ._subscribe ()
7779
7880 async def consume (self ) -> AsyncIterator [Event ]:
79- """Yield live events sequentially until cancellation, failure, or close ."""
81+ """Yield live events, surfacing unrecovered reads and invalid payloads ."""
8082 async with self ._lifecycle_lock :
8183 if self ._closed :
8284 raise TransportError ("Redis consumer is closed" )
@@ -121,7 +123,16 @@ async def consume(self) -> AsyncIterator[Event]:
121123 finally :
122124 async with self ._lifecycle_lock :
123125 self ._consuming = False
124- await self .close ()
126+ active_failure = sys .exception ()
127+ try :
128+ await self .close ()
129+ except TransportError :
130+ # Cleanup must not replace cancellation, malformed-message, or
131+ # connection errors already escaping the iterator. GeneratorExit
132+ # denotes an explicit/normal iterator close, so its cleanup error
133+ # remains observable to the caller.
134+ if active_failure is None or isinstance (active_failure , GeneratorExit ):
135+ raise
125136
126137 async def close (self ) -> None :
127138 """Unsubscribe and close the Pub/Sub resource idempotently."""
@@ -131,13 +142,20 @@ async def close(self) -> None:
131142 self ._closed = True
132143 pubsub , self ._pubsub = self ._pubsub , None
133144 if pubsub is not None :
145+ failure : BaseException | None = None
134146 try :
135147 await pubsub .unsubscribe (self ._transport .channel (self .topic ))
148+ except Exception as exc :
149+ failure = exc
150+ try :
136151 await pubsub .aclose ()
137152 except Exception as exc :
153+ failure = failure or exc
154+ if failure is not None :
155+ self ._transport ._discard_consumer (self )
138156 raise TransportError (
139157 f"Redis consumer close failed for topic { self .topic !r} "
140- ) from exc
158+ ) from failure
141159 self ._transport ._discard_consumer (self )
142160
143161 async def _subscribe (self ) -> Any :
@@ -151,8 +169,21 @@ async def _subscribe(self) -> Any:
151169 pubsub = self ._transport .client .pubsub ()
152170 try :
153171 await pubsub .subscribe (self ._transport .channel (self .topic ))
154- except Exception as exc :
155- await pubsub .aclose ()
172+ except BaseException as exc :
173+ # Cancellation is deliberately included: a connection attempt may
174+ # be cancelled while redis-py is reconnecting, but its Pub/Sub
175+ # object must not retain a duplicate server-side subscription.
176+ try :
177+ await asyncio .shield (pubsub .aclose ())
178+ except BaseException as cleanup_exc :
179+ # In particular, task cancellation must remain cancellation
180+ # even when a broken connection also makes cleanup fail.
181+ exc .add_note (
182+ "Redis subscription cleanup also failed: "
183+ f"{ cleanup_exc !r} "
184+ )
185+ if isinstance (exc , asyncio .CancelledError ):
186+ raise exc
156187 raise TransportError (
157188 f"Redis subscribe failed for topic { self .topic !r} "
158189 ) from exc
0 commit comments