redis-client-kit 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/CHANGELOG.md +14 -0
  2. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/PKG-INFO +12 -2
  3. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/README.md +11 -1
  4. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/pyproject.toml +4 -2
  5. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/__init__.py +1 -1
  6. redis_client_kit-0.1.4/redis_client_kit/__version__.py +1 -0
  7. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/aio/instrumented.py +38 -21
  8. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/providers/redis.py +42 -14
  9. redis_client_kit-0.1.4/redis_client_kit/providers/utils.py +71 -0
  10. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/settings/redis.py +9 -1
  11. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/sync/instrumented.py +32 -16
  12. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/utils.py +18 -9
  13. redis_client_kit-0.1.2/redis_client_kit/__version__.py +0 -1
  14. redis_client_kit-0.1.2/redis_client_kit/providers/utils.py +0 -52
  15. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/.gitignore +0 -0
  16. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/LICENSE +0 -0
  17. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/aio/__init__.py +0 -0
  18. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/aio/factory.py +0 -0
  19. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/aio/lifecycle.py +0 -0
  20. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/aio/types.py +0 -0
  21. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/config.py +0 -0
  22. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/metrics/__init__.py +0 -0
  23. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/metrics/_deps.py +0 -0
  24. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/metrics/redis.py +0 -0
  25. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/protocols.py +0 -0
  26. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/providers/__init__.py +0 -0
  27. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/providers/_deps.py +0 -0
  28. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/py.typed +0 -0
  29. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/settings/__init__.py +0 -0
  30. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/settings/_deps.py +0 -0
  31. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/sync/__init__.py +0 -0
  32. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/sync/factory.py +0 -0
  33. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/sync/lifecycle.py +0 -0
  34. {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/sync/types.py +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.4](https://github.com/bedrock-python/redis-client-kit/compare/redis-client-kit-v0.1.3...redis-client-kit-v0.1.4) (2026-09-07)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * hand redis-py a zero-retry Retry when retries are disabled ([#27](https://github.com/bedrock-python/redis-client-kit/issues/27)) ([02d4f55](https://github.com/bedrock-python/redis-client-kit/commit/02d4f5590b6bcdc26d8d13c81cfc80bfd3551f1a))
9
+
10
+ ## [0.1.3](https://github.com/bedrock-python/redis-client-kit/compare/redis-client-kit-v0.1.2...redis-client-kit-v0.1.3) (2026-09-06)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * fail startup on an unreachable Redis, and count translated errors as errors ([#23](https://github.com/bedrock-python/redis-client-kit/issues/23)) ([38187bb](https://github.com/bedrock-python/redis-client-kit/commit/38187bb202f14e8f893e7597926f0cb3cb750b80))
16
+
3
17
  ## [0.1.2](https://github.com/bedrock-python/redis-client-kit/compare/redis-client-kit-v0.1.1...redis-client-kit-v0.1.2) (2026-09-05)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: redis-client-kit
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Redis client with optional Pydantic, Prometheus, and Dishka support
5
5
  Project-URL: Repository, https://github.com/bedrock-python/redis-client-kit
6
6
  Project-URL: Documentation, https://bedrock-python.github.io/redis-client-kit/
@@ -249,6 +249,15 @@ Production-ready Redis client with optional Pydantic settings, Prometheus metric
249
249
  [![codecov](https://codecov.io/gh/bedrock-python/redis-client-kit/graph/badge.svg)](https://codecov.io/gh/bedrock-python/redis-client-kit)
250
250
  [![Docs](https://img.shields.io/badge/docs-online-blue)](https://bedrock-python.github.io/redis-client-kit/)
251
251
 
252
+ > [!TIP]
253
+ > **Building this with an AI assistant?** Hand it
254
+ > **[one page](https://bedrock-python.github.io/redis-client-kit/agents/)** instead of the
255
+ > whole site: the public API of both the async and the sync side, what each extra turns on,
256
+ > every setting with the default it actually has, the rules that break code when they are
257
+ > broken, the mistakes models make with this API, and a map of which page to fetch for the
258
+ > rest. Every docs page is also served as raw Markdown at its own URL, and a **Copy page**
259
+ > button at the top of each one hands it straight to a chat window.
260
+
252
261
  ## Features
253
262
 
254
263
  | Feature | Included | Extra Required |
@@ -530,10 +539,11 @@ settings = BaseRedisSettings(
530
539
 
531
540
  Full documentation: [bedrock-python.github.io/redis-client-kit](https://bedrock-python.github.io/redis-client-kit/)
532
541
 
542
+ - [For AI agents](https://bedrock-python.github.io/redis-client-kit/agents/) — the whole library on one page
533
543
  - [Quick Start Guide](https://bedrock-python.github.io/redis-client-kit/guide/quickstart/)
534
544
  - [Configuration Guide](https://bedrock-python.github.io/redis-client-kit/guide/configuration/)
535
545
  - [Advanced Usage](https://bedrock-python.github.io/redis-client-kit/guide/advanced/)
536
- - [API Reference](https://bedrock-python.github.io/redis-client-kit/reference/api/)
546
+ - [API Reference](https://bedrock-python.github.io/redis-client-kit/reference/)
537
547
 
538
548
  ## Contributing
539
549
 
@@ -9,6 +9,15 @@ Production-ready Redis client with optional Pydantic settings, Prometheus metric
9
9
  [![codecov](https://codecov.io/gh/bedrock-python/redis-client-kit/graph/badge.svg)](https://codecov.io/gh/bedrock-python/redis-client-kit)
10
10
  [![Docs](https://img.shields.io/badge/docs-online-blue)](https://bedrock-python.github.io/redis-client-kit/)
11
11
 
12
+ > [!TIP]
13
+ > **Building this with an AI assistant?** Hand it
14
+ > **[one page](https://bedrock-python.github.io/redis-client-kit/agents/)** instead of the
15
+ > whole site: the public API of both the async and the sync side, what each extra turns on,
16
+ > every setting with the default it actually has, the rules that break code when they are
17
+ > broken, the mistakes models make with this API, and a map of which page to fetch for the
18
+ > rest. Every docs page is also served as raw Markdown at its own URL, and a **Copy page**
19
+ > button at the top of each one hands it straight to a chat window.
20
+
12
21
  ## Features
13
22
 
14
23
  | Feature | Included | Extra Required |
@@ -290,10 +299,11 @@ settings = BaseRedisSettings(
290
299
 
291
300
  Full documentation: [bedrock-python.github.io/redis-client-kit](https://bedrock-python.github.io/redis-client-kit/)
292
301
 
302
+ - [For AI agents](https://bedrock-python.github.io/redis-client-kit/agents/) — the whole library on one page
293
303
  - [Quick Start Guide](https://bedrock-python.github.io/redis-client-kit/guide/quickstart/)
294
304
  - [Configuration Guide](https://bedrock-python.github.io/redis-client-kit/guide/configuration/)
295
305
  - [Advanced Usage](https://bedrock-python.github.io/redis-client-kit/guide/advanced/)
296
- - [API Reference](https://bedrock-python.github.io/redis-client-kit/reference/api/)
306
+ - [API Reference](https://bedrock-python.github.io/redis-client-kit/reference/)
297
307
 
298
308
  ## Contributing
299
309
 
@@ -102,6 +102,10 @@ warn_unreachable = true
102
102
  [tool.ruff]
103
103
  line-length = 120
104
104
  target-version = "py310"
105
+ # The Markdown guides hold fragments -- a keyword argument on its own, a body
106
+ # without its def -- that read as code to a formatter and come out as
107
+ # something else. ruff 0.16 formats fenced Python in Markdown by default.
108
+ extend-exclude = ["*.md"]
105
109
 
106
110
  [tool.ruff.lint]
107
111
  select = ["F", "E", "W", "I", "B", "N", "S", "C4", "DTZ", "SIM", "TRY", "PERF", "RUF", "UP", "ANN", "T20", "PTH", "PLC", "PLE", "PLW"]
@@ -136,8 +140,6 @@ omit = [
136
140
  "*/test_*.py",
137
141
  "*/_deps.py",
138
142
  "redis_client_kit/metrics/redis.py",
139
- "redis_client_kit/providers/redis.py",
140
- "redis_client_kit/providers/utils.py",
141
143
  ]
142
144
 
143
145
  [tool.coverage.report]
@@ -24,7 +24,7 @@ from .utils import build_base_redis_kwargs, build_redis_retry, parse_redis_url_n
24
24
  try:
25
25
  __version__ = version("redis-client-kit")
26
26
  except PackageNotFoundError: # pragma: no cover
27
- __version__ = "0.1.2"
27
+ __version__ = "0.1.4"
28
28
 
29
29
  # ruff: noqa: RUF022
30
30
  __all__ = [
@@ -0,0 +1 @@
1
+ __version__ = "0.1.4"
@@ -15,6 +15,27 @@ from ..protocols import RedisMetricsProtocol
15
15
  logger = logging.getLogger(__name__)
16
16
 
17
17
 
18
+ def _translate_closed_transport(error: RuntimeError) -> RedisConnectionError | None:
19
+ """Translate a uvloop closed-transport RuntimeError into a Redis ConnectionError.
20
+
21
+ uvloop raises RuntimeError when the transport is closed but still being used.
22
+ redis-py only handles its own ConnectionError (retry or reconnect), so the error
23
+ is translated. Returns None for any other RuntimeError.
24
+ """
25
+ message = str(error).lower()
26
+ if "the handler is closed" in message or "transport is closed" in message:
27
+ return RedisConnectionError(str(error))
28
+ return None
29
+
30
+
31
+ def _record_error(metrics: RedisMetricsProtocol, error: BaseException) -> None:
32
+ """Record an error without letting the metrics backend break the command."""
33
+ try:
34
+ metrics.record_error(error_type=type(error).__name__)
35
+ except Exception:
36
+ logger.exception("Failed to record Redis error metrics")
37
+
38
+
18
39
  class InstrumentedRedis(Redis):
19
40
  """Redis client with Prometheus metrics collection."""
20
41
 
@@ -32,10 +53,12 @@ class InstrumentedRedis(Redis):
32
53
  pool = self.connection_pool
33
54
  if pool:
34
55
  try:
35
- # redis-py async pool has _all_connections and _in_use_connections
56
+ # redis-py async pool has _available_connections and _in_use_connections
57
+ available: list[Any] = getattr(pool, "_available_connections", [])
58
+ in_use: set[Any] = getattr(pool, "_in_use_connections", set())
36
59
  self._metrics.record_pool_stats(
37
- pool_size=len(getattr(pool, "_all_connections", [])),
38
- pool_checked_out=len(getattr(pool, "_in_use_connections", [])),
60
+ pool_size=len(available) + len(in_use),
61
+ pool_checked_out=len(in_use),
39
62
  )
40
63
  except Exception:
41
64
  logger.exception("Failed to record Redis pool metrics")
@@ -43,18 +66,15 @@ class InstrumentedRedis(Redis):
43
66
  try:
44
67
  return await super().execute_command(*args, **options)
45
68
  except RuntimeError as e:
46
- # uvloop raises RuntimeError when transport is closed but still being used.
47
- # We translate it to ConnectionError so redis-py can handle it (retry or reconnect).
48
- if "the handler is closed" in str(e).lower() or "transport is closed" in str(e).lower():
49
- raise RedisConnectionError(str(e)) from e
69
+ status = "error"
70
+ translated = _translate_closed_transport(e)
71
+ _record_error(self._metrics, translated or e)
72
+ if translated is not None:
73
+ raise translated from e
50
74
  raise
51
75
  except Exception as e:
52
76
  status = "error"
53
- error_type = type(e).__name__
54
- try:
55
- self._metrics.record_error(error_type=error_type)
56
- except Exception:
57
- logger.exception("Failed to record Redis error metrics")
77
+ _record_error(self._metrics, e)
58
78
  raise
59
79
  finally:
60
80
  duration = time.perf_counter() - start
@@ -86,18 +106,15 @@ class InstrumentedRedisCluster(RedisCluster):
86
106
  try:
87
107
  return await super().execute_command(*args, **options)
88
108
  except RuntimeError as e:
89
- # uvloop raises RuntimeError when transport is closed but still being used.
90
- # We translate it to ConnectionError so redis-py can handle it (retry or reconnect).
91
- if "the handler is closed" in str(e).lower() or "transport is closed" in str(e).lower():
92
- raise RedisConnectionError(str(e)) from e
109
+ status = "error"
110
+ translated = _translate_closed_transport(e)
111
+ _record_error(self._metrics, translated or e)
112
+ if translated is not None:
113
+ raise translated from e
93
114
  raise
94
115
  except Exception as e:
95
116
  status = "error"
96
- error_type = type(e).__name__
97
- try:
98
- self._metrics.record_error(error_type=error_type)
99
- except Exception:
100
- logger.exception("Failed to record Redis error metrics")
117
+ _record_error(self._metrics, e)
101
118
  raise
102
119
  finally:
103
120
  duration = time.perf_counter() - start
@@ -9,7 +9,7 @@ from redis.exceptions import RedisError
9
9
  from ..aio import AsyncRedisClient, check_async_redis_health, close_async_redis_client, create_async_redis_client
10
10
  from ..config import RedisSettingsProtocol
11
11
  from ..protocols import RedisMetricsProtocol
12
- from ._deps import Provider, Scope, provide
12
+ from ._deps import Provider, Scope
13
13
  from .utils import retry_async_connection, safe_async_cleanup
14
14
 
15
15
  logger = logging.getLogger(__name__)
@@ -18,17 +18,39 @@ logger = logging.getLogger(__name__)
18
18
  class AsyncRedisProvider(Provider): # type: ignore[misc]
19
19
  """Dishka provider for Redis dependencies.
20
20
 
21
- Provides two Redis client options:
22
- - get_redis(): Simple client without startup health check
23
- - get_redis_with_health_check(): Client with connection verification and retries
21
+ Provides one ``AsyncRedisClient``, chosen when the provider is constructed:
24
22
 
25
- Choose get_redis() for faster startup when Redis availability is not critical.
26
- Choose get_redis_with_health_check() for guaranteed connection on startup.
23
+ - ``AsyncRedisProvider()`` verifies the connection on startup with retries and
24
+ raises when Redis stays unreachable, so startup fails instead of handing out
25
+ a client that cannot answer.
26
+ - ``AsyncRedisProvider(check_health_on_startup=False)`` yields the client without
27
+ contacting Redis, for a faster startup when Redis availability is not critical.
28
+
29
+ It also provides ``RedisMetricsProtocol | None`` as ``None`` so a container with no
30
+ metrics provider still resolves. Dishka lets the last registered provider of a type
31
+ win, so this default overrides a metrics provider registered before it: either
32
+ register this provider first, or construct it with ``provide_default_metrics=False``.
27
33
  """
28
34
 
29
35
  scope = Scope.APP # type: ignore[misc]
30
36
 
31
- @provide # type: ignore[misc]
37
+ def __init__(
38
+ self,
39
+ *,
40
+ check_health_on_startup: bool = True,
41
+ provide_default_metrics: bool = True,
42
+ ) -> None:
43
+ """Register the client factory, and the default metrics factory when asked to.
44
+
45
+ Args:
46
+ check_health_on_startup: Verify the connection before yielding the client
47
+ provide_default_metrics: Provide ``RedisMetricsProtocol | None`` as ``None``
48
+ """
49
+ super().__init__()
50
+ self.provide(self.get_redis_with_health_check if check_health_on_startup else self.get_redis)
51
+ if provide_default_metrics:
52
+ self.provide(self.get_default_metrics)
53
+
32
54
  async def get_redis(
33
55
  self,
34
56
  redis_settings: RedisSettingsProtocol,
@@ -57,7 +79,6 @@ class AsyncRedisProvider(Provider): # type: ignore[misc]
57
79
  exception_type=RedisError,
58
80
  )
59
81
 
60
- @provide # type: ignore[misc]
61
82
  async def get_redis_with_health_check(
62
83
  self,
63
84
  redis_settings: RedisSettingsProtocol,
@@ -76,14 +97,22 @@ class AsyncRedisProvider(Provider): # type: ignore[misc]
76
97
  Configured and verified AsyncRedisClient instance
77
98
 
78
99
  Raises:
79
- Exception: If connection fails after max retry attempts
100
+ ConnectionError: If Redis is still unreachable after the last attempt
80
101
  """
81
102
  client = create_async_redis_client(redis_settings, metrics=metrics)
82
103
 
83
- await retry_async_connection(
84
- connect_func=lambda: check_async_redis_health(client),
85
- service_name="Redis",
86
- )
104
+ try:
105
+ await retry_async_connection(
106
+ connect_func=lambda: check_async_redis_health(client),
107
+ service_name="Redis",
108
+ )
109
+ except BaseException:
110
+ await safe_async_cleanup(
111
+ cleanup_func=functools.partial(close_async_redis_client, client),
112
+ service_name="Redis client",
113
+ exception_type=RedisError,
114
+ )
115
+ raise
87
116
 
88
117
  try:
89
118
  yield client
@@ -94,7 +123,6 @@ class AsyncRedisProvider(Provider): # type: ignore[misc]
94
123
  exception_type=RedisError,
95
124
  )
96
125
 
97
- @provide # type: ignore[misc]
98
126
  def get_default_metrics(self) -> RedisMetricsProtocol | None:
99
127
  """Provide default None for metrics if not provided in container."""
100
128
  return None
@@ -0,0 +1,71 @@
1
+ """Provider utility functions for connection retry and cleanup."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import Awaitable, Callable
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ async def retry_async_connection(
11
+ connect_func: Callable[[], Awaitable[bool]],
12
+ service_name: str,
13
+ max_attempts: int = 3,
14
+ backoff_base: float = 1.0,
15
+ ) -> None:
16
+ """Retry async connection with exponential backoff.
17
+
18
+ An attempt fails when connect_func raises or returns a falsy value; both are
19
+ retried with the same backoff.
20
+
21
+ Args:
22
+ connect_func: Callable returning True once the service is reachable
23
+ service_name: Name used in log messages and in the raised error
24
+ max_attempts: Number of attempts before giving up
25
+ backoff_base: Base of the exponential backoff in seconds
26
+
27
+ Raises:
28
+ ConnectionError: If no attempt reported success and none of them raised.
29
+ Exception: The error raised by the last attempt, if it raised one.
30
+ """
31
+ for attempt in range(1, max_attempts + 1):
32
+ error: Exception
33
+ try:
34
+ if await connect_func():
35
+ logger.info("%s connected successfully", service_name)
36
+ return
37
+ error = ConnectionError(f"{service_name} did not report a healthy connection")
38
+ except Exception as e:
39
+ error = e
40
+
41
+ if attempt == max_attempts:
42
+ raise error
43
+
44
+ wait_time = backoff_base * (2 ** (attempt - 1))
45
+ logger.warning(
46
+ "%s connection failed (attempt %d/%d), retrying in %ss: %s",
47
+ service_name,
48
+ attempt,
49
+ max_attempts,
50
+ wait_time,
51
+ error,
52
+ )
53
+ await asyncio.sleep(wait_time)
54
+
55
+
56
+ async def safe_async_cleanup(
57
+ cleanup_func: Callable[[], Awaitable[None]],
58
+ service_name: str,
59
+ exception_type: type[Exception],
60
+ ) -> None:
61
+ """Safely cleanup async resource."""
62
+ try:
63
+ await cleanup_func()
64
+ logger.info("%s cleaned up successfully", service_name)
65
+ except exception_type as e:
66
+ logger.warning("%s cleanup failed: %s", service_name, e)
67
+ except Exception:
68
+ logger.exception("Unexpected error during %s cleanup", service_name)
69
+
70
+
71
+ __all__ = ["retry_async_connection", "safe_async_cleanup"]
@@ -116,4 +116,12 @@ class BaseRedisSettings(BaseSettings):
116
116
  return self
117
117
 
118
118
 
119
- __all__ = ["BaseRedisSettings"]
119
+ __all__ = [
120
+ "BaseRedisSettings",
121
+ "RedisClusterSettings",
122
+ "RedisConnectionSettings",
123
+ "RedisPoolSettings",
124
+ "RedisResponseSettings",
125
+ "RedisRetrySettings",
126
+ "RedisSSLSettings",
127
+ ]
@@ -15,6 +15,26 @@ from ..protocols import RedisMetricsProtocol
15
15
  logger = logging.getLogger(__name__)
16
16
 
17
17
 
18
+ def _translate_closed_transport(error: RuntimeError) -> RedisConnectionError | None:
19
+ """Translate a closed-transport RuntimeError into a Redis ConnectionError.
20
+
21
+ redis-py only handles its own ConnectionError (retry or reconnect), so the error
22
+ is translated. Returns None for any other RuntimeError.
23
+ """
24
+ message = str(error).lower()
25
+ if "the handler is closed" in message or "transport is closed" in message:
26
+ return RedisConnectionError(str(error))
27
+ return None
28
+
29
+
30
+ def _record_error(metrics: RedisMetricsProtocol, error: BaseException) -> None:
31
+ """Record an error without letting the metrics backend break the command."""
32
+ try:
33
+ metrics.record_error(error_type=type(error).__name__)
34
+ except Exception:
35
+ logger.exception("Failed to record Redis error metrics")
36
+
37
+
18
38
  class InstrumentedRedis(Redis):
19
39
  """Redis client with Prometheus metrics collection."""
20
40
 
@@ -46,17 +66,15 @@ class InstrumentedRedis(Redis):
46
66
  try:
47
67
  return super().execute_command(*args, **options)
48
68
  except RuntimeError as e:
49
- # Handle closed connection errors
50
- if "the handler is closed" in str(e).lower() or "transport is closed" in str(e).lower():
51
- raise RedisConnectionError(str(e)) from e
69
+ status = "error"
70
+ translated = _translate_closed_transport(e)
71
+ _record_error(self._metrics, translated or e)
72
+ if translated is not None:
73
+ raise translated from e
52
74
  raise
53
75
  except Exception as e:
54
76
  status = "error"
55
- error_type = type(e).__name__
56
- try:
57
- self._metrics.record_error(error_type=error_type)
58
- except Exception:
59
- logger.exception("Failed to record Redis error metrics")
77
+ _record_error(self._metrics, e)
60
78
  raise
61
79
  finally:
62
80
  duration = time.perf_counter() - start
@@ -88,17 +106,15 @@ class InstrumentedRedisCluster(RedisCluster):
88
106
  try:
89
107
  return super().execute_command(*args, **options)
90
108
  except RuntimeError as e:
91
- # Handle closed connection errors
92
- if "the handler is closed" in str(e).lower() or "transport is closed" in str(e).lower():
93
- raise RedisConnectionError(str(e)) from e
109
+ status = "error"
110
+ translated = _translate_closed_transport(e)
111
+ _record_error(self._metrics, translated or e)
112
+ if translated is not None:
113
+ raise translated from e
94
114
  raise
95
115
  except Exception as e:
96
116
  status = "error"
97
- error_type = type(e).__name__
98
- try:
99
- self._metrics.record_error(error_type=error_type)
100
- except Exception:
101
- logger.exception("Failed to record Redis error metrics")
117
+ _record_error(self._metrics, e)
102
118
  raise
103
119
  finally:
104
120
  duration = time.perf_counter() - start
@@ -5,7 +5,7 @@ import binascii
5
5
  from pathlib import Path
6
6
  from urllib.parse import urlparse
7
7
 
8
- from redis.backoff import ExponentialBackoff
8
+ from redis.backoff import ExponentialBackoff, NoBackoff
9
9
  from redis.retry import Retry
10
10
 
11
11
  from .config import RedisSettingsProtocol
@@ -36,8 +36,6 @@ def parse_redis_url_node(node: str) -> tuple[str, int]:
36
36
 
37
37
  def build_base_redis_kwargs(settings: RedisSettingsProtocol) -> dict[str, object]:
38
38
  """Build base Redis client keyword arguments."""
39
- retry = build_redis_retry(settings)
40
-
41
39
  if settings.ssl.enabled:
42
40
  if settings.ssl.ca_certs:
43
41
  _validate_pem_format(settings.ssl.ca_certs, "CERTIFICATE")
@@ -54,6 +52,7 @@ def build_base_redis_kwargs(settings: RedisSettingsProtocol) -> dict[str, object
54
52
  "socket_keepalive": settings.pool.socket_keepalive,
55
53
  "socket_keepalive_options": settings.pool.socket_keepalive_options,
56
54
  "health_check_interval": settings.health_check_interval,
55
+ "retry": build_redis_retry(settings),
57
56
  "decode_responses": settings.response.decode_responses,
58
57
  "encoding": settings.response.encoding,
59
58
  "client_name": settings.connection.client_name,
@@ -70,14 +69,16 @@ def build_base_redis_kwargs(settings: RedisSettingsProtocol) -> dict[str, object
70
69
  kwargs["require_full_coverage"] = settings.cluster.require_full_coverage
71
70
  kwargs["read_from_replicas"] = settings.cluster.read_from_replicas
72
71
 
73
- if retry is not None:
74
- kwargs["retry"] = retry
75
-
76
72
  return kwargs
77
73
 
78
74
 
79
- def build_redis_retry(settings: RedisSettingsProtocol) -> Retry | None:
80
- """Build Redis Retry object from settings."""
75
+ def build_redis_retry(settings: RedisSettingsProtocol) -> Retry:
76
+ """Build Redis Retry object from settings.
77
+
78
+ Retries are off unless both ``retry.enabled`` and ``retry.max_attempts`` are set. Off is
79
+ still a ``Retry``, with zero retries: handed nothing, redis-py retries on its own (three
80
+ times since 6.0, ten since 8.0, with jittered backoff).
81
+ """
81
82
  if settings.retry.enabled and settings.retry.max_attempts:
82
83
  return Retry(
83
84
  backoff=ExponentialBackoff(
@@ -86,7 +87,7 @@ def build_redis_retry(settings: RedisSettingsProtocol) -> Retry | None:
86
87
  ),
87
88
  retries=settings.retry.max_attempts,
88
89
  )
89
- return None
90
+ return Retry(backoff=NoBackoff(), retries=0)
90
91
 
91
92
 
92
93
  def _validate_pem_format(path: str | Path, file_type: str) -> None:
@@ -116,3 +117,11 @@ def mask_redis_kwargs(kwargs: dict[str, object]) -> dict[str, object]:
116
117
  if masked.get("password"):
117
118
  masked["password"] = "********"
118
119
  return masked
120
+
121
+
122
+ __all__ = [
123
+ "build_base_redis_kwargs",
124
+ "build_redis_retry",
125
+ "mask_redis_kwargs",
126
+ "parse_redis_url_node",
127
+ ]
@@ -1 +0,0 @@
1
- __version__ = "0.1.2"
@@ -1,52 +0,0 @@
1
- """Provider utility functions for connection retry and cleanup."""
2
-
3
- import asyncio
4
- import logging
5
- from collections.abc import Awaitable, Callable
6
-
7
- logger = logging.getLogger(__name__)
8
-
9
-
10
- async def retry_async_connection(
11
- connect_func: Callable[[], Awaitable[bool]],
12
- service_name: str,
13
- max_attempts: int = 3,
14
- backoff_base: float = 1.0,
15
- ) -> None:
16
- """Retry async connection with exponential backoff."""
17
- for attempt in range(1, max_attempts + 1):
18
- try:
19
- if await connect_func():
20
- logger.info("%s connected successfully", service_name)
21
- return
22
- except Exception as e:
23
- if attempt == max_attempts:
24
- raise
25
- wait_time = backoff_base * (2 ** (attempt - 1))
26
- logger.warning(
27
- "%s connection failed (attempt %d/%d), retrying in %ss: %s",
28
- service_name,
29
- attempt,
30
- max_attempts,
31
- wait_time,
32
- e,
33
- )
34
- await asyncio.sleep(wait_time)
35
-
36
-
37
- async def safe_async_cleanup(
38
- cleanup_func: Callable[[], Awaitable[None]],
39
- service_name: str,
40
- exception_type: type[Exception],
41
- ) -> None:
42
- """Safely cleanup async resource."""
43
- try:
44
- await cleanup_func()
45
- logger.info("%s cleaned up successfully", service_name)
46
- except exception_type as e:
47
- logger.warning("%s cleanup failed: %s", service_name, e)
48
- except Exception:
49
- logger.exception("Unexpected error during %s cleanup", service_name)
50
-
51
-
52
- __all__ = ["retry_async_connection", "safe_async_cleanup"]