4848* ``queue_order_strategy``
4949* ``max_connections``
5050* ``health_check_interval``
51+ * ``reauth_check_interval``: (int) How often, in seconds, to flush pending
52+ streaming re-authentication tokens (emitted by a
53+ ``redis.credentials.StreamingCredentialProvider`` such as the Entra ID /
54+ IAM providers) onto the long-lived BRPOP and pub/sub connections held by
55+ the transport. Defaults to ``10``. See :meth:`Channel.maybe_reauth`.
5156* ``retry_on_timeout``
5257* ``priority_steps``
5358* ``client_name``: (str) The name to use when connecting to Redis server.
113118
114119DEFAULT_HEALTH_CHECK_INTERVAL = 25
115120
121+ #: How often (in seconds) to flush pending streaming re-authentication
122+ #: tokens onto the long-lived BRPOP and pub/sub connections. See
123+ #: :meth:`Channel.maybe_reauth` for why this is needed.
124+ DEFAULT_REAUTH_CHECK_INTERVAL = 10
125+
116126PRIORITY_STEPS = [0 , 3 , 6 , 9 ]
117127
118128error_classes_t = namedtuple ('error_classes_t' , (
@@ -600,6 +610,20 @@ def maybe_check_subclient_health(self):
600610 )
601611 return
602612
613+ def maybe_reauth (self ):
614+ for channel in self ._channels :
615+ try :
616+ channel .maybe_reauth ()
617+ except channel .connection_errors :
618+ # Connection is broken; skip this cycle and retry next tick.
619+ # Stop iterating to avoid repeated exceptions/log spam when
620+ # the broker is down (channels share the broken connection).
621+ logger .debug (
622+ 'maybe_reauth: connection error, '
623+ 'will retry on next cycle' , exc_info = True
624+ )
625+ return
626+
603627 def on_readable (self , fileno ):
604628 chan_type = self ._fd_to_chan .get (fileno )
605629 if chan_type is None :
@@ -1033,13 +1057,154 @@ def _brpop_read(self, **options):
10331057 raise Empty ()
10341058 finally :
10351059 self ._in_poll = None
1060+ # The BRPOP connection is now idle (its reply has been fully
1061+ # consumed and no new BRPOP has been issued yet): this is the
1062+ # safe moment to flush any pending streaming re-auth token.
1063+ self ._flush_brpop_reauth ()
10361064
10371065 def _poll_error (self , type , ** options ):
10381066 if type == 'LISTEN' :
10391067 self .subclient .parse_response ()
10401068 else :
10411069 self .client .parse_response (self .client .connection , type )
10421070
1071+ @staticmethod
1072+ def _pending_reauth_token (connection ):
1073+ """Return a streaming re-auth token stored on ``connection``, if any.
1074+
1075+ redis-py's :class:`~redis.event.RegisterReAuthForPooledConnections`
1076+ listener calls ``Connection.set_re_auth_token`` for every *in-use*
1077+ pooled connection whenever a
1078+ :class:`~redis.credentials.StreamingCredentialProvider` emits a fresh
1079+ token. The token is stored on the connection and only turned into an
1080+ actual ``AUTH`` command when the connection is released back to the
1081+ pool. We key off this attribute so that we do not have to couple to
1082+ the credential provider itself; it is only ever set when streaming
1083+ re-authentication is in effect.
1084+ """
1085+ if connection is None :
1086+ return None
1087+ return getattr (connection , '_re_auth_token' , None )
1088+
1089+ @staticmethod
1090+ def _pubsub_reauth_handled_by_redis (connection ):
1091+ """Whether redis-py re-authenticates ``connection`` itself.
1092+
1093+ redis-py only re-authenticates *subscribed* connections in place when
1094+ the RESP3 protocol has been negotiated (see
1095+ ``redis.event.RegisterReAuthForPubSub``); a RESP2 subscriber
1096+ connection cannot process an ``AUTH`` command at all. When RESP3 is in
1097+ use we therefore leave the pub/sub connection to redis-py and avoid
1098+ interfering with it.
1099+ """
1100+ get_protocol = getattr (connection , 'get_protocol' , None )
1101+ if get_protocol is not None :
1102+ protocol = get_protocol ()
1103+ else :
1104+ protocol = getattr (connection , 'protocol' , 2 )
1105+ return str (protocol ) == '3'
1106+
1107+ def maybe_reauth (self ):
1108+ """Flush pending streaming re-auth tokens onto long-lived connections.
1109+
1110+ The transport holds two connections for the lifetime of the worker
1111+ that are never released back to the pool: the ``BRPOP`` connection
1112+ (used to consume from ordinary queues) and the pub/sub ``LISTEN``
1113+ connection (used to consume from fanout queues). Because they are
1114+ never released, redis-py's release-triggered re-authentication never
1115+ fires for them, so a streaming credential provider's rotated tokens
1116+ never reach them and the broker eventually severs the connections when
1117+ the original credentials expire (e.g. the 12h limit imposed by AWS
1118+ ElastiCache with IAM auth).
1119+
1120+ This is called periodically from the event loop (a single thread), so
1121+ it can safely flush the tokens without racing the socket reads.
1122+ """
1123+ self ._flush_brpop_reauth ()
1124+ self ._flush_listen_reauth ()
1125+
1126+ def _flush_brpop_reauth (self ):
1127+ """Send a pending re-auth token's ``AUTH`` on the BRPOP connection.
1128+
1129+ Only safe to do while no ``BRPOP`` command is in flight, otherwise the
1130+ ``AUTH`` reply would interleave with the blocking pop's reply. We are
1131+ called both from the periodic timer and from :meth:`_brpop_read` (right
1132+ after a reply has been fully consumed), so the token is flushed at the
1133+ first idle moment after it is emitted.
1134+ """
1135+ if self ._in_poll :
1136+ # A BRPOP is outstanding; retry at the next idle opportunity.
1137+ return
1138+ client = self .__dict__ .get ('client' ) # only if property is cached
1139+ connection = getattr (client , 'connection' , None )
1140+ if self ._pending_reauth_token (connection ) is None :
1141+ return
1142+ if getattr (connection , '_sock' , None ) is None :
1143+ # The socket is already down (e.g. a BRPOP connection error just
1144+ # disconnected it, or a previous flush failed). ``re_auth`` would
1145+ # transparently reconnect a *fresh* socket via ``send_command``,
1146+ # but behind the poller's back: ``_register_BRPOP`` would then skip
1147+ # re-registering it (its ``_chan_to_sock`` entry survives the
1148+ # disconnect and ``_sock`` is no longer ``None``), so the new fd is
1149+ # never handed to the event loop and the channel silently stalls.
1150+ # Leave it to ``_register_BRPOP`` to reconnect *and* re-register;
1151+ # ``on_connect`` re-authenticates with the current credentials.
1152+ return
1153+ try :
1154+ connection .re_auth ()
1155+ except self .connection_errors + (self .ResponseError ,):
1156+ # The AUTH failed: a network/auth error (ConnectionError,
1157+ # AuthenticationError, ...) or a rejected token surfacing as a
1158+ # ResponseError. Drop the socket so the next poll reconnects and
1159+ # authenticates with fresh credentials from the credential
1160+ # provider. Also clear the stored token: ``re_auth`` only clears
1161+ # it on success, and once the socket is dropped the reconnect
1162+ # applies the current credentials via ``on_connect`` — re-sending
1163+ # this same (possibly expired/rejected) token in place would just
1164+ # fail again on every tick. This runs from ``_brpop_read``'s
1165+ # ``finally`` block, so it must never raise.
1166+ warning ('Redis streaming re-auth failed on BRPOP connection; '
1167+ 'reconnecting' , exc_info = True )
1168+ try :
1169+ connection .set_re_auth_token (None )
1170+ except AttributeError :
1171+ pass
1172+ try :
1173+ connection .disconnect ()
1174+ except self .connection_errors :
1175+ pass
1176+
1177+ def _flush_listen_reauth (self ):
1178+ """Refresh credentials on the long-lived pub/sub (LISTEN) connection.
1179+
1180+ A subscribed RESP2 connection cannot process an ``AUTH`` command, so
1181+ the stored re-auth token can never be flushed in place. Instead we
1182+ drop the connection; the poller reconnects and re-subscribes on the
1183+ next tick, authenticating with the current credentials from the
1184+ credential provider. Fanout delivery is best-effort, so the brief
1185+ reconnect is far less disruptive than a broker-forced disconnect.
1186+
1187+ Under RESP3, redis-py re-authenticates pub/sub connections itself, so
1188+ we leave those untouched.
1189+ """
1190+ subclient = self .__dict__ .get ('subclient' ) # only if property cached
1191+ connection = getattr (subclient , 'connection' , None )
1192+ if self ._pending_reauth_token (connection ) is None :
1193+ return
1194+ if self ._pubsub_reauth_handled_by_redis (connection ):
1195+ return
1196+ logger .info ('Refreshing Redis pub/sub connection to apply rotated '
1197+ 'streaming credentials' )
1198+ # Clear the stored token first so we do not reconnect again on the
1199+ # next cycle, then drop the socket. The poller re-registers the
1200+ # LISTEN connection and re-subscribes on the next tick.
1201+ try :
1202+ connection .set_re_auth_token (None )
1203+ except AttributeError :
1204+ pass
1205+ self ._in_listen = None
1206+ connection .disconnect ()
1207+
10431208 def _get (self , queue ):
10441209 with self .conn_or_acquire () as client :
10451210 for pri in self .priority_steps :
@@ -1536,7 +1701,8 @@ def on_poll_start():
15361701 # registering new ones. Without this, each reconnect accumulates
15371702 # an extra entry in hub.timer._queue; they all fire against the
15381703 # same cycle and can crash the event loop during reconnect.
1539- for attr in ('_restore_messages_tref' , '_subclient_health_tref' ):
1704+ for attr in ('_restore_messages_tref' , '_subclient_health_tref' ,
1705+ '_reauth_tref' ):
15401706 old_tref = getattr (cycle , attr , None )
15411707 if old_tref is not None :
15421708 old_tref .cancel ()
@@ -1552,6 +1718,14 @@ def on_poll_start():
15521718 health_check_interval ,
15531719 cycle .maybe_check_subclient_health
15541720 )
1721+ reauth_check_interval = connection .client .transport_options .get (
1722+ 'reauth_check_interval' ,
1723+ DEFAULT_REAUTH_CHECK_INTERVAL
1724+ )
1725+ cycle ._reauth_tref = loop .call_repeatedly (
1726+ reauth_check_interval ,
1727+ cycle .maybe_reauth
1728+ )
15551729
15561730 def on_readable (self , fileno ):
15571731 """Handle AIO event for one of our file descriptors."""
0 commit comments