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

44 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-09 11:03 +0100

1#!/usr/bin/env python3 

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

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

4 

5Copyright 2025 

6SPDX-License-Identifier: Apache-2.0 

7Authors: Reeve Barreto, Mihai Criveti 

8 

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

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

11 

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

13connections with a configurable interval and number of attempts. 

14 

15Exit 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 

22Features 

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 

28Environment variables 

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

30These 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 

41Usage examples 

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

43Shell :: 

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 

49Python :: 

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 

58import argparse 

59import asyncio 

60import logging 

61import os 

62import sys 

63import time 

64from typing import Any, Optional 

65 

66# First-Party 

67# First Party imports 

68from mcpgateway.config import settings 

69 

70# Environment variables 

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

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

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

74 

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

76 

77 

78def 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 

167def _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 

189def 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 

223if __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)