Skip to content

Commit 0a249e5

Browse files
ThomasAitkenauvipy
andauthored
Redis: deliver rotating StreamingCredentialProvider tokens to long-lived BRPOP and pub/sub connections (#2563)
* Implement fix to flush tokens onto the BRPOP and Pub/sub LISTEN connections * robustify * changes made in response to review --------- Co-authored-by: Asif Saif Uddin {"Auvi":"অভি"} <auvipy@gmail.com>
1 parent db995b9 commit 0a249e5

3 files changed

Lines changed: 537 additions & 3 deletions

File tree

docs/reference/kombu.transport.redis.rst

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,57 @@
3535
.. versionadded:: 5.7.0
3636
Supports Queue TTL
3737

38+
Streaming credentials / automatic re-authentication
39+
----------------------------------------------------
40+
.. versionadded:: 5.7.0
41+
42+
The transport supports rotating credentials supplied by a
43+
``redis.credentials.StreamingCredentialProvider`` (for example the
44+
Microsoft Entra ID provider from ``redis-entraid``, or an AWS ElastiCache
45+
IAM provider). Configure it like any other credential provider:
46+
47+
.. code-block:: python
48+
49+
app.conf.broker_transport_options = {
50+
"credential_provider": my_streaming_credential_provider,
51+
}
52+
53+
Such providers emit a fresh authentication token in the background before
54+
the current one expires. redis-py delivers these tokens to *pooled*
55+
connections when they are released back to the pool, but the Redis
56+
transport keeps two connections busy for the entire lifetime of the
57+
worker — the ``BRPOP`` connection (used to consume regular queues) and the
58+
pub/sub ``LISTEN`` connection (used to consume fanout queues) — so they are
59+
never released and would otherwise never receive a rotated token. The
60+
broker then severs them once the original credentials expire (e.g. AWS
61+
ElastiCache with IAM auth enforces a hard 12-hour connection limit),
62+
causing redelivered messages, interrupted in-flight tasks and brief worker
63+
unavailability.
64+
65+
To avoid this, the transport periodically flushes any pending token onto
66+
those long-lived connections from the event loop:
67+
68+
* the ``BRPOP`` connection is re-authenticated in place with an ``AUTH``
69+
command, sent only when no blocking pop is in flight;
70+
* the pub/sub ``LISTEN`` connection cannot process ``AUTH`` while
71+
subscribed under RESP2, so it is transparently reconnected (and
72+
re-subscribed) to pick up the new credentials. Under RESP3, redis-py
73+
re-authenticates pub/sub connections itself and the transport leaves
74+
them untouched.
75+
76+
How often the flush runs is controlled by the ``reauth_check_interval``
77+
transport option (seconds, default ``10``):
78+
79+
.. code-block:: python
80+
81+
app.conf.broker_transport_options = {
82+
"credential_provider": my_streaming_credential_provider,
83+
"reauth_check_interval": 10,
84+
}
85+
86+
When no streaming credential provider is configured this machinery is a
87+
cheap no-op, so it is always safe to leave enabled.
88+
3889
Queue arguments
3990
---------------
4091
The following queue argument is supported. Pass it per-queue via

kombu/transport/redis.py

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@
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.
@@ -113,6 +118,11 @@
113118

114119
DEFAULT_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+
116126
PRIORITY_STEPS = [0, 3, 6, 9]
117127

118128
error_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

Comments
 (0)