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
« 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
5Copyright 2025
6SPDX-License-Identifier: Apache-2.0
7Authors: Reeve Barreto, Mihai Criveti
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.
12It can be used both **synchronously** or **asynchronously**, and will retry
13connections with a configurable interval and number of attempts.
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``).
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**.
28Environment variables
29---------------------
30These environment variables can be used to configure retry behavior and Redis connection.
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+-----------------------------+-----------------------------------------------+-----------------------------+
41Usage examples
42--------------
43Shell ::
45 python redis_isready.py
46 python redis_isready.py --redis-url "redis://localhost:6379/0" \
47 --max-retries 5 --retry-interval-ms 500
49Python ::
51 from redis_isready import wait_for_redis_ready
53 await wait_for_redis_ready() # asynchronous
54 wait_for_redis_ready(sync=True) # synchronous / blocking
55"""
57# Standard
58import argparse
59import asyncio
60import logging
61import os
62import sys
63import time
64from typing import Any, Optional
66# First-Party
67# First Party imports
68from mcpgateway.config import settings
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"))
75LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
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.
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.
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.
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 )
117 if max_retries < 1 or retry_interval_ms <= 0:
118 raise RuntimeError("Invalid max_retries or retry_interval_ms values")
120 log.info(f"Probing Redis at {redis_url} (interval={retry_interval_ms}ms, max_retries={max_retries})")
122 def _probe(*_: Any) -> None:
123 """
124 Inner synchronous probe running in either the current or a thread.
126 Args:
127 *_: Ignored arguments (for compatibility with run_in_executor).
129 Returns:
130 None - the function exits successfully once Redis answers.
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)
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")
155 if sync:
156 _probe()
157 else:
158 loop = asyncio.get_event_loop()
159 loop.run_until_complete(loop.run_in_executor(None, _probe))
162# ---------------------------------------------------------------------------
163# CLI helpers
164# ---------------------------------------------------------------------------
167def _parse_cli() -> argparse.Namespace:
168 """Parse command-line arguments for the *redis_isready* CLI wrapper.
170 Returns:
171 Parsed :class:`argparse.Namespace` holding all CLI options.
172 """
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()
189def main() -> None: # pragma: no cover
190 """CLI entry-point.
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:
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()
205 log = logging.getLogger("redis_isready")
206 log.setLevel(cli_args.log_level.upper())
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)
220 sys.exit(0)
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)