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.
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/CHANGELOG.md +14 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/PKG-INFO +12 -2
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/README.md +11 -1
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/pyproject.toml +4 -2
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/__init__.py +1 -1
- redis_client_kit-0.1.4/redis_client_kit/__version__.py +1 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/aio/instrumented.py +38 -21
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/providers/redis.py +42 -14
- redis_client_kit-0.1.4/redis_client_kit/providers/utils.py +71 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/settings/redis.py +9 -1
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/sync/instrumented.py +32 -16
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/utils.py +18 -9
- redis_client_kit-0.1.2/redis_client_kit/__version__.py +0 -1
- redis_client_kit-0.1.2/redis_client_kit/providers/utils.py +0 -52
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/.gitignore +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/LICENSE +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/aio/__init__.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/aio/factory.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/aio/lifecycle.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/aio/types.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/config.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/metrics/__init__.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/metrics/_deps.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/metrics/redis.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/protocols.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/providers/__init__.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/providers/_deps.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/py.typed +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/settings/__init__.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/settings/_deps.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/sync/__init__.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/sync/factory.py +0 -0
- {redis_client_kit-0.1.2 → redis_client_kit-0.1.4}/redis_client_kit/sync/lifecycle.py +0 -0
- {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.
|
|
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
|
[](https://codecov.io/gh/bedrock-python/redis-client-kit)
|
|
250
250
|
[](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/
|
|
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
|
[](https://codecov.io/gh/bedrock-python/redis-client-kit)
|
|
10
10
|
[](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/
|
|
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.
|
|
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
|
|
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(
|
|
38
|
-
pool_checked_out=len(
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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__ = [
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|