Coverage for mcpgateway/utils/redis_isready.py: 87%

1#!/usr/bin/env python3

2# -*- coding: utf-8 -*-

3"""redis_isready - Wait until Redis is ready and accepting connections

4

5 Copyright 2025

6 SPDX-License-Identifier: Apache-2.0

7 Authors: Reeve Barreto, Mihai Criveti

8

9 This helper blocks until the given **Redis** server (defined by a connection URL)

10 successfully responds to a `PING` command. It is intended to delay application startup until Redis is online.

11

12 It can be used both **synchronously** or **asynchronously**, and will retry

13 connections with a configurable interval and number of attempts.

14

15 Exit codes when executed as a script

16-----------------------------------

17* ``0`` - Redis ready.

18* ``1`` - all attempts exhausted / timed-out.

19* ``2`` - :pypi:`redis` is **not** installed.

20* ``3`` - invalid parameter combination (``max_retries``/``retry_interval_ms``).

21

22 Features

23--------

24* Supports any valid Redis URL supported by :pypi:`redis`.

25* Retry settings are configurable via *environment variables*.

26* Works both **synchronously** (blocking) and **asynchronously**.

27

28 Environment variables

29---------------------

30 These environment variables can be used to configure retry behavior and Redis connection.

31

32+-----------------------------+-----------------------------------------------+-----------------------------+

33| Name | Description | Default |

34+=============================+===============================================+=============================+

35| ``REDIS_URL`` | Redis connection URL | ``redis://localhost:6379/0``|

36| ``REDIS_MAX_RETRIES`` | Maximum retry attempts before failing | ``3`` |

37| ``REDIS_RETRY_INTERVAL_MS`` | Delay between retries *(milliseconds)* | ``2000`` |

38| ``LOG_LEVEL`` | Log verbosity when not set via ``--log-level``| ``INFO`` |

39+-----------------------------+-----------------------------------------------+-----------------------------+

40

41 Usage examples

42--------------

43 Shell ::

44

45 python redis_isready.py

46 python redis_isready.py --redis-url "redis://localhost:6379/0" \

47 --max-retries 5 --retry-interval-ms 500

48

49 Python ::

50

51 from redis_isready import wait_for_redis_ready

52

53 await wait_for_redis_ready() # asynchronous

54 wait_for_redis_ready(sync=True) # synchronous / blocking

55"""

56

57# Standard

58 import argparse

59 import asyncio

60 import logging

61 import os

62 import sys

63 import time

64 from typing import Any, Optional

65

66# First-Party

67# First Party imports

68 from mcpgateway.config import settings

69

70# Environment variables

71 REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")

72 REDIS_MAX_RETRIES = int(os.getenv("REDIS_MAX_RETRIES", "3"))

73 REDIS_RETRY_INTERVAL_MS = int(os.getenv("REDIS_RETRY_INTERVAL_MS", "2000"))

74

75 LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()

76

77

78 def wait_for_redis_ready(

79 *,

80 redis_url: str = REDIS_URL,

81 max_retries: int = REDIS_MAX_RETRIES,

82 retry_interval_ms: int = REDIS_RETRY_INTERVAL_MS,

83 logger: Optional[logging.Logger] = None,

84 sync: bool = False,

85) -> None:

86 """

87 Wait until a Redis server is ready to accept connections.

88

89 This function attempts to connect to Redis and issue a `PING` command,

90 retrying if the connection fails. It can run synchronously (blocking)

91 or asynchronously using an executor. Intended for use during service

92 startup to ensure Redis is reachable before proceeding.

93

94 Args:

95 redis_url : str

96 Redis connection URL. Defaults to the value of the `REDIS_URL` environment variable.

97 max_retries : int

98 Maximum number of connection attempts before failing.

99 retry_interval_ms : int

100 Delay between retry attempts, in milliseconds.

101 logger : logging.Logger, optional

102 Logger instance to use. If not provided, a default logger is configured.

103 sync : bool

104 If True, runs the probe synchronously. If False (default), runs it asynchronously.

105

106 Raises:

107 RuntimeError: If Redis does not respond successfully after all retry attempts.

108 """

109 log = logger or logging.getLogger("redis_isready")

110 if not log.handlers: # basicConfig **once** - respects *log.setLevel* later 110 ↛ 117line 110 didn't jump to line 117 because the condition on line 110 was always true

111 logging.basicConfig(

112 level=getattr(logging, LOG_LEVEL, logging.INFO),

113 format="%(asctime)s [%(levelname)s] %(message)s",

114 datefmt="%Y-%m-%dT%H:%M:%S",

115 )

116

117 if max_retries < 1 or retry_interval_ms <= 0:

118 raise RuntimeError("Invalid max_retries or retry_interval_ms values")

119

120 log.info(f"Probing Redis at {redis_url} (interval={retry_interval_ms}ms, max_retries={max_retries})")

121

122 def _probe(*_: Any) -> None:

123 """

124 Inner synchronous probe running in either the current or a thread.

125

126 Args:

127 *_: Ignored arguments (for compatibility with run_in_executor).

128

129 Returns:

130 None - the function exits successfully once Redis answers.

131

132 Raises:

133 RuntimeError: Forwarded after exhausting ``max_retries`` attempts.

134 """

135 try:

136 # Import redis here to avoid dependency issues if not used

137 # Third-Party

138 from redis import Redis

139 except ImportError: # pragma: no cover - handled at runtime for the CLI

140 sys.stderr.write("redis library not installed - aborting (pip install redis)\n")

141 sys.exit(2)

142

143 redis_client = Redis.from_url(redis_url)

144 for attempt in range(1, max_retries + 1):

145 try:

146 redis_client.ping()

147 log.info(f"Redis ready (attempt {attempt})")

148 return

149 except Exception as exc:

150 log.debug(f"Attempt {attempt}/{max_retries} failed ({exc}) - retrying in {retry_interval_ms} ms")

151 if attempt < max_retries: # Don't sleep on the last attempt

152 time.sleep(retry_interval_ms / 1000.0)

153 raise RuntimeError(f"Redis not ready after {max_retries} attempts")

154

155 if sync:

156 _probe()

157 else:

158 loop = asyncio.get_event_loop()

159 loop.run_until_complete(loop.run_in_executor(None, _probe))

160

161

162# ---------------------------------------------------------------------------

163# CLI helpers

164# ---------------------------------------------------------------------------

165

166

167 def _parse_cli() -> argparse.Namespace:

168 """Parse command-line arguments for the *redis_isready* CLI wrapper.

169

170 Returns:

171 Parsed :class:`argparse.Namespace` holding all CLI options.

172 """

173

174 parser = argparse.ArgumentParser(

175 description="Wait until Redis is ready and accepting connections.",

176 formatter_class=argparse.ArgumentDefaultsHelpFormatter,

177 )

178 parser.add_argument(

179 "--redis-url",

180 default=REDIS_URL,

181 help="Redis connection URL (env REDIS_URL)",

182 )

183 parser.add_argument("--max-retries", type=int, default=REDIS_MAX_RETRIES, help="Maximum connection attempts")

184 parser.add_argument("--retry-interval-ms", type=int, default=REDIS_RETRY_INTERVAL_MS, help="Delay between attempts in milliseconds")

185 parser.add_argument("--log-level", default=LOG_LEVEL, help="Logging level (DEBUG, INFO, ...)")

186 return parser.parse_args()

187

188

189 def main() -> None: # pragma: no cover

190 """CLI entry-point.

191

192 * Parses command-line options.

193 * Applies ``--log-level`` to the *redis_isready* logger **before** the first

194 message is emitted.

195 * Delegates the actual probing to :func:`wait_for_redis_ready`.

196 * Exits with:

197

198 * ``0`` - Redis became ready.

199 * ``1`` - connection attempts exhausted.

200 * ``2`` - redis library missing.

201 * ``3`` - invalid parameter combination.

202 """

203 cli_args = _parse_cli()

204

205 log = logging.getLogger("redis_isready")

206 log.setLevel(cli_args.log_level.upper())

207

208 try:

209 wait_for_redis_ready(

210 redis_url=cli_args.redis_url,

211 max_retries=cli_args.max_retries,

212 retry_interval_ms=cli_args.retry_interval_ms,

213 sync=True,

214 logger=log,

215 )

216 except RuntimeError as exc:

217 log.error(f"Redis unavailable: {exc}")

218 sys.exit(1)

219

220 sys.exit(0)

221

222

223 if __name__ == "__main__": # pragma: no cover

224 if settings.cache_type == "redis":

225 # Ensure Redis is ready before proceeding

226 main()

227 else:

228 # If not using Redis, just exit with success

229 sys.exit(0)