redis-http-client 0.1.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 victor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: redis-http-client
3
+ Version: 0.1.1
4
+ Summary: A Redis client over HTTP supporting both sync (requests) and async (aiohttp) modes.
5
+ Author-email: victor <xianyu.wu@innodealing.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, http://git.innodealing.cn/dm_spider_service/redis-http-client.git
8
+ Project-URL: Source, http://git.innodealing.cn/dm_spider_service/redis-http-client.git
9
+ Project-URL: Issues, http://git.innodealing.cn/dm_spider_service/redis-http-client.git/issues
10
+ Keywords: redis,http,client,asyncio,requests,aiohttp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: Implementation :: CPython
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Topic :: Database
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: aiohttp>=3.9.0
25
+ Requires-Dist: requests>=2.31.0
26
+ Requires-Dist: urllib3>=2.0.0
27
+ Requires-Dist: loguru>=0.7.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: build>=1.2.1; extra == "dev"
30
+ Requires-Dist: twine>=5.1.0; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # redis-http-client
34
+
35
+ A lightweight Redis client over HTTP, supporting both async (`aiohttp`) and sync (`requests`) execution modes.
36
+
37
+ ## Features
38
+
39
+ - Async mode based on `aiohttp`
40
+ - Sync mode based on `requests`
41
+ - Dynamic Redis command proxy via `__getattr__`
42
+ - Pipeline support (`redis_pipeline_async` / `redis_pipeline_sync`)
43
+ - Built-in retry and connection-pool handling
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install redis-http-client
49
+ ```
50
+
51
+ ## Quick Start
52
+
53
+ ### Async
54
+
55
+ ```python
56
+ import asyncio
57
+ from redis_http_client import RedisHttpClient
58
+
59
+
60
+ async def main():
61
+ client = RedisHttpClient(
62
+ spider_redis_manager_service_url="http://127.0.0.1:8000",
63
+ mode="async",
64
+ )
65
+
66
+ result = await client.get("my_key")
67
+ print(result)
68
+
69
+ await client.close()
70
+
71
+
72
+ asyncio.run(main())
73
+ ```
74
+
75
+ ### Sync
76
+
77
+ ```python
78
+ from redis_http_client import RedisHttpClient
79
+
80
+ client = RedisHttpClient(
81
+ spider_redis_manager_service_url="http://127.0.0.1:8000",
82
+ mode="sync",
83
+ )
84
+
85
+ result = client.get("my_key")
86
+ print(result)
87
+
88
+ # sync mode reuses internal requests session; close when done
89
+ import asyncio
90
+ asyncio.run(client.close())
91
+ ```
92
+
93
+ ### Pipeline
94
+
95
+ ```python
96
+ commands = [
97
+ {"command": "GET", "args": ["k1"], "kwargs": {}},
98
+ {"command": "GET", "args": ["k2"], "kwargs": {}},
99
+ ]
100
+
101
+ # async
102
+ # results = await client.redis_pipeline_async(commands)
103
+
104
+ # sync
105
+ # results = client.redis_pipeline_sync(commands)
106
+ ```
107
+
108
+ ## Build
109
+
110
+ ```bash
111
+ python -m pip install --upgrade build twine
112
+ python -m build
113
+ ```
114
+
115
+ Build outputs are generated in `dist/`.
116
+
117
+ ## Upload To PyPI
118
+
119
+ ### 1. TestPyPI (recommended first)
120
+
121
+ ```bash
122
+ twine check dist/*
123
+ twine upload -r testpypi dist/*
124
+ ```
125
+
126
+ ### 2. PyPI
127
+
128
+ ```bash
129
+ twine upload dist/*
130
+ ```
131
+
132
+ You can use API token authentication:
133
+
134
+ - username: `__token__`
135
+ - password: `pypi-xxxx...`
136
+
137
+ ## Versioning
138
+
139
+ Current version: `0.1.1`
140
+
141
+ When releasing a new version:
142
+
143
+ 1. Update `version` in `pyproject.toml`
144
+ 2. Update `__version__` in `redis_http_client/__init__.py`
145
+ 3. Rebuild and upload
146
+
147
+ ## License
148
+
149
+ MIT
@@ -0,0 +1,117 @@
1
+ # redis-http-client
2
+
3
+ A lightweight Redis client over HTTP, supporting both async (`aiohttp`) and sync (`requests`) execution modes.
4
+
5
+ ## Features
6
+
7
+ - Async mode based on `aiohttp`
8
+ - Sync mode based on `requests`
9
+ - Dynamic Redis command proxy via `__getattr__`
10
+ - Pipeline support (`redis_pipeline_async` / `redis_pipeline_sync`)
11
+ - Built-in retry and connection-pool handling
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install redis-http-client
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ### Async
22
+
23
+ ```python
24
+ import asyncio
25
+ from redis_http_client import RedisHttpClient
26
+
27
+
28
+ async def main():
29
+ client = RedisHttpClient(
30
+ spider_redis_manager_service_url="http://127.0.0.1:8000",
31
+ mode="async",
32
+ )
33
+
34
+ result = await client.get("my_key")
35
+ print(result)
36
+
37
+ await client.close()
38
+
39
+
40
+ asyncio.run(main())
41
+ ```
42
+
43
+ ### Sync
44
+
45
+ ```python
46
+ from redis_http_client import RedisHttpClient
47
+
48
+ client = RedisHttpClient(
49
+ spider_redis_manager_service_url="http://127.0.0.1:8000",
50
+ mode="sync",
51
+ )
52
+
53
+ result = client.get("my_key")
54
+ print(result)
55
+
56
+ # sync mode reuses internal requests session; close when done
57
+ import asyncio
58
+ asyncio.run(client.close())
59
+ ```
60
+
61
+ ### Pipeline
62
+
63
+ ```python
64
+ commands = [
65
+ {"command": "GET", "args": ["k1"], "kwargs": {}},
66
+ {"command": "GET", "args": ["k2"], "kwargs": {}},
67
+ ]
68
+
69
+ # async
70
+ # results = await client.redis_pipeline_async(commands)
71
+
72
+ # sync
73
+ # results = client.redis_pipeline_sync(commands)
74
+ ```
75
+
76
+ ## Build
77
+
78
+ ```bash
79
+ python -m pip install --upgrade build twine
80
+ python -m build
81
+ ```
82
+
83
+ Build outputs are generated in `dist/`.
84
+
85
+ ## Upload To PyPI
86
+
87
+ ### 1. TestPyPI (recommended first)
88
+
89
+ ```bash
90
+ twine check dist/*
91
+ twine upload -r testpypi dist/*
92
+ ```
93
+
94
+ ### 2. PyPI
95
+
96
+ ```bash
97
+ twine upload dist/*
98
+ ```
99
+
100
+ You can use API token authentication:
101
+
102
+ - username: `__token__`
103
+ - password: `pypi-xxxx...`
104
+
105
+ ## Versioning
106
+
107
+ Current version: `0.1.1`
108
+
109
+ When releasing a new version:
110
+
111
+ 1. Update `version` in `pyproject.toml`
112
+ 2. Update `__version__` in `redis_http_client/__init__.py`
113
+ 3. Rebuild and upload
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "redis-http-client"
7
+ version = "0.1.1"
8
+ description = "A Redis client over HTTP supporting both sync (requests) and async (aiohttp) modes."
9
+ authors = [
10
+ { name="victor", email="xianyu.wu@innodealing.com" }
11
+ ]
12
+ readme = "README.md"
13
+ requires-python = ">=3.11"
14
+ license = "MIT"
15
+ keywords = ["redis", "http", "client", "asyncio", "requests", "aiohttp"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: Implementation :: CPython",
24
+ "Operating System :: OS Independent",
25
+ "Topic :: Database",
26
+ "Topic :: Software Development :: Libraries :: Python Modules"
27
+ ]
28
+
29
+ dependencies = [
30
+ "aiohttp>=3.9.0",
31
+ "requests>=2.31.0",
32
+ "urllib3>=2.0.0",
33
+ "loguru>=0.7.0"
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "http://git.innodealing.cn/dm_spider_service/redis-http-client.git"
38
+ Source = "http://git.innodealing.cn/dm_spider_service/redis-http-client.git"
39
+ Issues = "http://git.innodealing.cn/dm_spider_service/redis-http-client.git/issues"
40
+
41
+ [project.optional-dependencies]
42
+ dev = [
43
+ "build>=1.2.1",
44
+ "twine>=5.1.0"
45
+ ]
46
+
47
+ [tool.setuptools]
48
+ include-package-data = true
49
+ license-files = ["LICENSE"]
50
+
51
+ [tool.setuptools.packages.find]
52
+ where = ["."]
53
+ include = ["redis_http_client*"]
@@ -0,0 +1,4 @@
1
+ from .client import RedisHttpClient
2
+
3
+ __all__ = ["RedisHttpClient"]
4
+ __version__ = "0.1.1"
@@ -0,0 +1,443 @@
1
+ import asyncio
2
+ import json
3
+ import time
4
+ import traceback
5
+ import aiohttp
6
+ import requests
7
+ from urllib3.util import Retry
8
+ from requests.adapters import HTTPAdapter
9
+ from redis_http_client.other_logger import other_logger
10
+ from aiohttp.client_exceptions import (
11
+ ServerDisconnectedError, ClientOSError, ClientPayloadError, ClientConnectionError
12
+ )
13
+
14
+
15
+ class RedisHttpClient:
16
+ def __init__(self,
17
+ spider_redis_manager_service_url,
18
+ base_url: str = None,
19
+ mode: str = "async",
20
+ local=False,
21
+ force_close: bool = False,
22
+ limit: int = 1000
23
+ ):
24
+ """
25
+ mode: "async" 使用 aiohttp, "sync" 使用 requests
26
+ decode_responses: True → 返回 JSON 结果 (字符串)
27
+ False → 返回原始 bytes
28
+ """
29
+ if local:
30
+ self.base_url = base_url or f"{spider_redis_manager_service_url}/redis/exec/local"
31
+ self.pipeline_url = f"{spider_redis_manager_service_url}/redis/pipeline/local"
32
+ else:
33
+ self.base_url = base_url or f"{spider_redis_manager_service_url}/redis/exec"
34
+ self.pipeline_url = f"{spider_redis_manager_service_url}/redis/pipeline"
35
+ if mode not in ("async", "sync"):
36
+ raise ValueError("mode must be 'async' or 'sync'")
37
+ self.mode = mode
38
+ self._fail_count = 0
39
+ # 异步 session 延迟初始化
40
+ self.session = None
41
+ self._lock = asyncio.Lock()
42
+ self._session_created_at = 0
43
+ # 同步 session 立即初始化 + 连接池
44
+ if self.mode == "sync":
45
+ self.sync_session = requests.Session()
46
+ retry_strategy = Retry(
47
+ total=3,
48
+ backoff_factor=0.3,
49
+ status_forcelist=[500, 502, 503, 504],
50
+ allowed_methods=["HEAD", "GET", "OPTIONS", "POST"],
51
+ )
52
+ adapter = HTTPAdapter(
53
+ pool_connections=50, # 连接池大小
54
+ pool_maxsize=1000, # 单个 host 最大连接数
55
+ max_retries=retry_strategy,
56
+ )
57
+ self.sync_session.mount("http://", adapter)
58
+ self.sync_session.mount("https://", adapter)
59
+ else:
60
+ self.sync_session = None
61
+ # 记录session的创建时间
62
+ self._session_created_at = time.time()
63
+ # 计数 每隔xx秒输出一次日志
64
+ self._request_count = 0
65
+ self.max_tries = 5
66
+ self.force_close = force_close
67
+ self.limit = limit
68
+
69
+ async def _init_session(self, force=False):
70
+ """延迟初始化 aiohttp session,带锁"""
71
+ # 保证只有一个协程能创建/关闭 session
72
+ if force or self.session is None or self.session.closed:
73
+ async with self._lock:
74
+ if force or self.session is None or self.session.closed:
75
+ connector = aiohttp.TCPConnector(
76
+ limit=self.limit,
77
+ ttl_dns_cache=60,
78
+ keepalive_timeout=50,
79
+ enable_cleanup_closed=True,
80
+ force_close=self.force_close,
81
+ ssl=False,
82
+ )
83
+ timeout = aiohttp.ClientTimeout(
84
+ total=120, # 容忍整体请求更久
85
+ connect=10, # 连接最长 10s
86
+ sock_connect=10,
87
+ sock_read=100 # Redis pipeline 大响应时给足时间
88
+ )
89
+ self.session = aiohttp.ClientSession(connector=connector, timeout=timeout)
90
+ self._session_created_at = time.time()
91
+ other_logger.info("[RedisHttpClient] 新建 aiohttp session")
92
+
93
+ async def _recreate_session(self):
94
+ # 冷却防抖(建议 >=10s)
95
+ if time.time() - self._session_created_at < 10:
96
+ other_logger.warning("[RedisHttpClient] recreate skipped (cooldown)")
97
+ return
98
+ try:
99
+ # 锁内:原子“新建并替换”
100
+ async with self._lock:
101
+ old = self.session
102
+ connector = aiohttp.TCPConnector(
103
+ limit=self.limit,
104
+ ttl_dns_cache=60,
105
+ keepalive_timeout=50,
106
+ enable_cleanup_closed=True,
107
+ force_close=self.force_close,
108
+ ssl=False,
109
+ )
110
+ timeout = aiohttp.ClientTimeout(
111
+ total=120, # 容忍整体请求更久
112
+ connect=10, # 连接最长 10s
113
+ sock_connect=10,
114
+ sock_read=100 # Redis pipeline 大响应时给足时间
115
+ )
116
+ new_session = aiohttp.ClientSession(connector=connector, timeout=timeout)
117
+ self.session = new_session
118
+ self._session_created_at = time.time()
119
+ other_logger.warning("[RedisHttpClient] session swapped")
120
+ except Exception:
121
+ other_logger.error(traceback.format_exc())
122
+ return
123
+ # 🔕 锁外:**延迟**关闭旧 session,给在途请求留出时间
124
+
125
+ if old and not old.closed:
126
+ asyncio.create_task(self._delayed_close(old))
127
+
128
+ async def _delayed_close(self, session, delay=1.0):
129
+ """延迟关闭 session,给正在使用的请求留出时间"""
130
+ try:
131
+ other_logger.info("[RedisHttpClient] start close old session ")
132
+ # 防止多次异步触发 _delayed_close() 重复关闭。
133
+ if not session or getattr(session, "_closing", False):
134
+ return
135
+ setattr(session, "_closing", True)
136
+ await asyncio.sleep(delay)
137
+ if session and not session.closed:
138
+ await session.close()
139
+ other_logger.info("[RedisHttpClient] old session closed !")
140
+ except Exception:
141
+ other_logger.warning(traceback.format_exc())
142
+
143
+ async def _check_pool_health(self):
144
+ other_logger.info("[RedisHttpClient] 检查 redis pool health")
145
+ if not self.session or not self.session.connector:
146
+ return
147
+ dead_conns = 0
148
+ self.session.connector._cleanup_closed()
149
+ for key, conns in list(self.session.connector._conns.items()):
150
+ for conn, _ in list(conns):
151
+ if not conn:
152
+ continue
153
+ transport = getattr(conn, "transport", None)
154
+ if not transport:
155
+ # 没 transport -> 真死连接
156
+ continue
157
+ if transport.is_closing():
158
+ # socket 已关闭
159
+ conn.close()
160
+ dead_conns += 1
161
+ if dead_conns > 0:
162
+ other_logger.warning(f"[RedisHttpClient] 清理 {dead_conns} 个死连接")
163
+
164
+ async def close(self):
165
+ """关闭 aiohttp/requests session"""
166
+ if self.session and not self.session.closed:
167
+ await self.session.close()
168
+ if self.sync_session:
169
+ self.sync_session.close()
170
+
171
+ # ------------------ async 部分 ------------------
172
+ async def _fetch_async(self, url, method="GET", data=None, headers=None, params=None, resp_type="json"):
173
+ await self._init_session()
174
+
175
+ start_time = time.time()
176
+
177
+ try:
178
+ for attempt in range(self.max_tries):
179
+ session = self.session
180
+ if session is None or session.closed:
181
+ other_logger.warning("[RedisHttpClient] session closed before request, re-init...")
182
+ await self._recreate_session()
183
+ session = self.session
184
+ self._request_count += 1
185
+ if self._request_count % 500 == 0: # 每500次请求输出一次
186
+ other_logger.info(
187
+ f"[RedisHttpClient]链接池: 当前正在被使用的连接数量: {len(session.connector._acquired)} "
188
+ f"连接池中当前可用的空闲连接数量: {sum(len(conn_list) for conn_list in session.connector._conns.values())} "
189
+ f" 连接池允许的最大连接总数: {session.connector.limit} "
190
+ )
191
+ if self._request_count % 200 == 0:
192
+ await self._check_pool_health()
193
+ try:
194
+ async with session.request(method, url, headers=headers, params=params, json=data,
195
+ ssl=False) as response:
196
+ if response.status != 200:
197
+ text = await response.text()
198
+ # 服务端400但包含Redis错误 → 说明是可恢复的
199
+ if response.status == 400 and any(
200
+ k in text.lower() for k in
201
+ ["redis", "connection", "timeout", "broken", "db", "error"]
202
+ ):
203
+ other_logger.warning(
204
+ f"[RedisHttpClient] retryable 400: {text[:200]} ({attempt + 1}/{self.max_tries})"
205
+ )
206
+ await asyncio.sleep(0.3 * (attempt + 1))
207
+ continue
208
+ # 其他400、403、404不重试
209
+ raise Exception(f"[HTTP {response.status}] {text}")
210
+ if resp_type == "json":
211
+ result = await response.json()
212
+ elif resp_type == "text":
213
+ result = await response.text()
214
+ elif resp_type == "bytes":
215
+ result = await response.read()
216
+ duration = (time.time() - start_time) * 1000
217
+ # other_logger.info(f"[RedisHttpClient] {url} ok, cost={duration:.2f}ms")
218
+ return result
219
+ except RuntimeError as e:
220
+ if "Session is closed" in str(e):
221
+ other_logger.warning(
222
+ f"[RedisHttpClient] session closed mid-flight ({attempt + 1}/3); recreating")
223
+ await self._recreate_session()
224
+ await asyncio.sleep(0.2)
225
+ continue
226
+ raise
227
+
228
+ except asyncio.TimeoutError:
229
+ other_logger.warning(f"[RedisHttpClient] TimeoutError ({attempt + 1}/{self.max_tries}): {url}")
230
+ await asyncio.sleep(0.3 * (attempt + 1))
231
+ continue
232
+ except asyncio.CancelledError:
233
+ other_logger.warning(f"[RedisHttpClient] CancelledError ({attempt + 1}/{self.max_tries}): {url}")
234
+ raise # 不要吞掉取消异常
235
+ except (ServerDisconnectedError, ClientOSError, ClientConnectionError, ClientPayloadError) as e:
236
+ other_logger.warning(
237
+ f"[RedisHttpClient] 'ServerDisconnectedError, ClientOSError, ClientConnectionError, ClientPayloadError'"
238
+ f" ({attempt + 1}/{self.max_tries}): {url}")
239
+ await asyncio.sleep(0.3 * (attempt + 1))
240
+ if attempt == self.max_tries - 2:
241
+ await self._recreate_session()
242
+ else:
243
+ await self._check_pool_health()
244
+ continue
245
+
246
+ raise Exception(f"[aiohttp] Failed after {self.max_tries} retries, url={url}")
247
+ except Exception as e:
248
+ other_logger.error(f'[RedisHttpClient] Unhandled error: {traceback.format_exc()}')
249
+ return None
250
+
251
+ # ------------------ sync 部分 ------------------
252
+ def _fetch_sync(self, url: str, method: str = "GET", data=None, headers=None,
253
+ params=None, resp_type: str = "json"):
254
+ if not self.sync_session:
255
+ raise RuntimeError("Sync session not initialized")
256
+ try:
257
+ for i in range(3):
258
+ try:
259
+ resp = self.sync_session.request(
260
+ method, url,
261
+ headers=headers,
262
+ params=params,
263
+ json=data,
264
+ timeout=(10, 60), # 连接10s,读取60s(你原来60,60也行)
265
+ verify=False
266
+ )
267
+
268
+ # 关键:先把非 200 的情况打印出来
269
+ if resp.status_code != 200:
270
+ ct = resp.headers.get("Content-Type", "")
271
+ other_logger.error(
272
+ f"[RedisHttpClient][sync] HTTP {resp.status_code} url={url} "
273
+ f"Content-Type={ct} body={resp.text[:500]!r}"
274
+ )
275
+ # 让 Retry/上层逻辑决定是否重试
276
+ time.sleep(0.3 * (i + 1))
277
+ continue
278
+
279
+ if resp_type == "json":
280
+ # 关键:body 为空直接返回 None(避免 JSONDecodeError)
281
+ if not resp.content:
282
+ other_logger.error(
283
+ f"[RedisHttpClient][sync] empty body url={url} headers={dict(resp.headers)}"
284
+ )
285
+ return None
286
+
287
+ ct = resp.headers.get("Content-Type", "")
288
+ if "application/json" not in ct.lower():
289
+ other_logger.error(
290
+ f"[RedisHttpClient][sync] non-json content url={url} "
291
+ f"Content-Type={ct} body={resp.text[:500]!r}"
292
+ )
293
+ return None
294
+
295
+ return resp.json()
296
+
297
+ elif resp_type == "text":
298
+ return resp.text
299
+ elif resp_type == "bytes":
300
+ return resp.content
301
+ else:
302
+ raise ValueError("Invalid resp_type")
303
+
304
+ except Exception as e:
305
+ other_logger.warning(f"[RedisHttpClient][sync] retry {i + 1}/3 {type(e).__name__}: {e}")
306
+ other_logger.warning(f"[RedisHttpClient][sync] error {traceback.format_exc()}")
307
+ try:
308
+ other_logger.info(
309
+ f"status={resp.status_code} "
310
+ f"ct={resp.headers.get('Content-Type')} "
311
+ f"ce={resp.headers.get('Content-Encoding')} "
312
+ f"len={len(resp.content)} "
313
+ f"first16={resp.content[:16]!r}"
314
+ f"data={data}"
315
+ )
316
+ except Exception as e:
317
+ pass
318
+ # 关键:清理可能的“半死连接”
319
+ time.sleep(0.3 * (i + 1))
320
+ continue
321
+
322
+ except Exception as e:
323
+ other_logger.info('[RedisHttpClient] unhandled error: {}'.format(traceback.format_exc()))
324
+ return {}
325
+ return None
326
+
327
+ # ------------------ 动态方法代理 ------------------
328
+ def __getattr__(self, command: str):
329
+ if self.mode == "async":
330
+ async def wrapper(*args, **kwargs):
331
+ try:
332
+ return await self._request_redis_exec_async(command, list(args), kwargs)
333
+ except Exception as e:
334
+ other_logger.error(f"Redis async command error [{command}]: {traceback.format_exc()}")
335
+ return None # 返回 None 或自定义默认值,防止抛出异常
336
+
337
+ return wrapper
338
+ else:
339
+ def wrapper(*args, **kwargs):
340
+ try:
341
+ return self._request_redis_exec_sync(command, list(args), kwargs)
342
+ except Exception as e:
343
+ other_logger.error(f"Redis sync command error [{command}]: {traceback.format_exc()}")
344
+ return None
345
+
346
+ return wrapper
347
+
348
+ def _maybe_json_decode(self, obj):
349
+ """安全地对嵌套的 JSON 字符串进行一次解包"""
350
+ if isinstance(obj, str):
351
+ s = obj.strip()
352
+ if s and s[0] in '[{' and s[-1] in ']}':
353
+ try:
354
+ return json.loads(s)
355
+ except Exception:
356
+ return obj
357
+ return obj
358
+
359
+ # ------------------ redis 执行方法 ------------------
360
+ async def _request_redis_exec_async(self, command: str, args_list: list, kwargs: dict = None):
361
+ headers = {"accept": "application/json", "Content-Type": "application/json"}
362
+ data = {
363
+ "command": command,
364
+ "args": args_list,
365
+ "kwargs": kwargs or {}
366
+ }
367
+ result = await self._real_exec(data, headers)
368
+ return result
369
+
370
+ def _request_redis_exec_sync(self, command: str, args_list: list, kwargs: dict = None):
371
+ headers = {"accept": "application/json", "Content-Type": "application/json", "Connection": "close"}
372
+ data = {
373
+ "command": command,
374
+ "args": args_list,
375
+ "kwargs": kwargs or {}
376
+ }
377
+
378
+ result = self._fetch_sync(
379
+ self.base_url, "POST", data=data, headers=headers,
380
+ resp_type="json"
381
+ )
382
+ return result
383
+
384
+ async def redis_pipeline_async(self, commands: list):
385
+ """
386
+ commands: [
387
+ {"command": "GETBIT", "args": ["my_bloom_key", 1234], "kwargs": {}},
388
+ {"command": "GETBIT", "args": ["my_bloom_key", 4567], "kwargs": {}}
389
+ ]
390
+ """
391
+ headers = {"accept": "application/json", "Content-Type": "application/json"}
392
+ data = {"commands": []}
393
+ for cmd in commands:
394
+ data["commands"].append({
395
+ "command": cmd["command"],
396
+ "args": cmd.get("args", []),
397
+ "kwargs": cmd.get("kwargs", {})
398
+ })
399
+ result = await self._real_pipeline(data, headers)
400
+ if len(commands) > 1:
401
+ converted = []
402
+ for c, r in zip(commands, result):
403
+ converted.append(self._maybe_json_decode(r))
404
+ return converted
405
+ else:
406
+ maybe = self._maybe_json_decode(result[0])
407
+ if isinstance(maybe, (list, dict)):
408
+ result = maybe
409
+ return result
410
+
411
+ def redis_pipeline_sync(self, commands: list):
412
+ headers = {"accept": "application/json", "Content-Type": "application/json", "Connection": "close"}
413
+ data = {"commands": []}
414
+ for cmd in commands:
415
+ data["commands"].append({
416
+ "command": cmd["command"],
417
+ "args": cmd.get("args", []),
418
+ "kwargs": cmd.get("kwargs", {})
419
+ })
420
+
421
+ result = self._fetch_sync(
422
+ self.pipeline_url, "POST", data=data, headers=headers, resp_type="json"
423
+ )
424
+ if len(commands) > 1:
425
+ converted = []
426
+ for c, r in zip(commands, result):
427
+ converted.append(self._maybe_json_decode(r))
428
+ return converted
429
+ else:
430
+ maybe = self._maybe_json_decode(result[0])
431
+ if isinstance(maybe, (list, dict)):
432
+ result = maybe
433
+ return result
434
+
435
+ async def _real_pipeline(self, data, headers):
436
+ return await self._fetch_async(
437
+ self.pipeline_url, "POST", data=data, headers=headers, resp_type="json"
438
+ )
439
+
440
+ async def _real_exec(self, data, headers):
441
+ return await self._fetch_async(
442
+ self.base_url, "POST", data=data, headers=headers, resp_type="json"
443
+ )
@@ -0,0 +1,22 @@
1
+ class OtherLogger(object):
2
+ def __init__(self, types='[other_logger]'):
3
+ import sys
4
+ from loguru import logger
5
+ logger.remove()
6
+ logger.add(sys.stderr, format="{time:YYYY-MM-DD HH:mm:ss} {extra[types]} {level}: {message}", enqueue=True)
7
+ self.logger = logger.bind(types=types)
8
+
9
+ def info(self, message):
10
+ self.logger.info(message)
11
+
12
+ def error(self, message):
13
+ self.logger.error(message)
14
+
15
+ def warning(self, message):
16
+ self.logger.warning(message)
17
+
18
+ def debug(self, message):
19
+ self.logger.debug(message)
20
+
21
+
22
+ other_logger = OtherLogger()
@@ -0,0 +1,55 @@
1
+ import asyncio, threading, os, time
2
+
3
+ '''额外起一个子线程,用于跑其他的loop
4
+ 为了解决scrapy线程多层loop嵌套的情况。,loop用的过多,没有剩余的loop去处理redis-http-client的请求,导致超时
5
+ '''
6
+
7
+
8
+ class SubLoopRunner:
9
+ def __init__(self):
10
+ self.loop = None
11
+
12
+ def _build(self):
13
+ self.pid = os.getpid()
14
+ self.loop = asyncio.new_event_loop()
15
+ self.thread = threading.Thread(target=self._run, daemon=True, name=f"SubLoop-{self.pid}")
16
+ self.thread.start()
17
+ # 等到 loop 真跑起来
18
+ for _ in range(100):
19
+ """避免loop未被创建"""
20
+ if self.loop.is_running():
21
+ break
22
+ time.sleep(0.1)
23
+ if not self.loop.is_running():
24
+ raise RuntimeError("SubLoop failed to start")
25
+
26
+ def _ensure_alive(self):
27
+ """不再init时初始化loop,避免 在主进程创建 loop,否则爬虫启动后,主进程fork后的子进程无法拿到fork后loop线程,只能拿到subloop_runner对象,
28
+
29
+ """
30
+ if self.loop is None or os.getpid() != self.pid or self.loop.is_closed():
31
+ try:
32
+ # 尝试优雅停止旧 loop(若在子进程调用,这里多半是 no-op)
33
+ self.loop.call_soon_threadsafe(self.loop.stop)
34
+ except Exception:
35
+ pass
36
+ self._build()
37
+
38
+ def _run(self):
39
+ """
40
+ 在子线程中运行事件循环的主循环(loop.run_forever()),让 loop 真正处理异步任务
41
+ 就相当于是「子线程中的 asyncio 事件调度器」
42
+
43
+ """
44
+ asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
45
+
46
+ asyncio.set_event_loop(self.loop)
47
+ self.loop.run_forever()
48
+
49
+ def run_coroutine(self, coro):
50
+ """run_coroutine_threadsafe 此方法线程安全"""
51
+ self._ensure_alive()
52
+ return asyncio.run_coroutine_threadsafe(coro, self.loop)
53
+
54
+
55
+ subloop_runner = SubLoopRunner()
@@ -0,0 +1,242 @@
1
+ import json
2
+ import os
3
+ import platform
4
+ import aiohttp
5
+
6
+
7
+ def to_str(value):
8
+ if value is None:
9
+ return None
10
+ return str(value)
11
+
12
+
13
+ def to_int(value):
14
+ if value is None:
15
+ return None
16
+ return int(value)
17
+
18
+
19
+ def to_bool(value):
20
+ if value is None:
21
+ return None
22
+ if isinstance(value, bool):
23
+ return value
24
+ if isinstance(value, (int, str)):
25
+ return str(value).lower() in ("1", "true", "ok")
26
+ return False
27
+
28
+
29
+ def to_set(value):
30
+ if value is None:
31
+ return set()
32
+ return set(value)
33
+
34
+
35
+ def to_list(value):
36
+ if value is None:
37
+ return []
38
+ return list(value)
39
+
40
+
41
+ def _unpack_cursor_items(value, default_items):
42
+ if not value:
43
+ return 0, default_items
44
+ cursor = value[0]
45
+ try:
46
+ cursor = int(cursor)
47
+ except Exception:
48
+ # 有的实现返回的是字符串 '0'
49
+ cursor = int(str(cursor))
50
+ items = value[1] if len(value) > 1 else default_items
51
+ return cursor, items
52
+
53
+
54
+ def to_scan(value):
55
+ # SCAN -> (cursor, [keys...])
56
+ cursor, items = _unpack_cursor_items(value, [])
57
+ # 确保是 list
58
+ return cursor, list(items) if items is not None else []
59
+
60
+
61
+ def to_sscan(value):
62
+ # SSCAN -> (cursor, [members...])
63
+ cursor, items = _unpack_cursor_items(value, [])
64
+ return cursor, list(items) if items is not None else []
65
+
66
+
67
+ def to_hscan(value):
68
+ # HSCAN -> (cursor, {field: value}) 或 (cursor, [f1, v1, f2, v2, ...])
69
+ cursor, items = _unpack_cursor_items(value, {})
70
+ if isinstance(items, dict):
71
+ return cursor, items
72
+ # 兼容交替列表形式
73
+ items = list(items) if items is not None else []
74
+ if len(items) % 2 != 0:
75
+ # 长度异常时,尽力配对,多出来的最后一个丢弃或给 None(这里选择丢弃)
76
+ items = items[:len(items) // 2 * 2]
77
+ return cursor, dict(zip(items[0::2], items[1::2]))
78
+
79
+
80
+ def to_zscan(value):
81
+ # ZSCAN -> (cursor, [(member, score), ...]) 或 (cursor, [member, score, ...])
82
+ cursor, items = _unpack_cursor_items(value, [])
83
+ # 常见两种格式:list-of-tuples 或 扁平交替列表
84
+ # 统一成 list[(member, float(score))]
85
+ if isinstance(items, dict):
86
+ # 极少数实现可能给 dict;统一转 list
87
+ pairs = [(m, float(s)) for m, s in items.items()]
88
+ return cursor, pairs
89
+
90
+ items = list(items) if items is not None else []
91
+ if items and not isinstance(items[0], (list, tuple)):
92
+ # 扁平交替:[member, score, member, score, ...]
93
+ if len(items) % 2 != 0:
94
+ items = items[:len(items) // 2 * 2]
95
+ it = iter(items)
96
+ pairs = []
97
+ for m, s in zip(it, it):
98
+ try:
99
+ pairs.append((m, float(s)))
100
+ except Exception:
101
+ # 分数解析失败时保持原样
102
+ pairs.append((m, s))
103
+ return cursor, pairs
104
+ else:
105
+ # 已经是 [(member, score), ...] 形式
106
+ pairs = []
107
+ for m, s in items:
108
+ try:
109
+ pairs.append((m, float(s)))
110
+ except Exception:
111
+ pairs.append((m, s))
112
+ return cursor, pairs
113
+
114
+
115
+ def to_eval(value):
116
+ if isinstance(value, str):
117
+ try:
118
+ return json.loads(value)
119
+ except Exception:
120
+ return value
121
+ return value
122
+
123
+
124
+ def to_zrange(value):
125
+ # 可能是 list[str] / list[ [member, score], ... ] / 扁平 ["m1", 1.0, "m2", 2.0] / 或者单个字符串
126
+ if value is None:
127
+ return []
128
+
129
+ # 已是字符串(且没被 _maybe_json_decode 解开),按单元素处理,避免被 list() 拆字符
130
+ if isinstance(value, str):
131
+ return [value]
132
+
133
+ if isinstance(value, list):
134
+ # 形如 [["a", 1.0], ["b", 2.0]]
135
+ if all(isinstance(i, list) and len(i) == 2 for i in value):
136
+ return [(str(k), float(v)) for k, v in value]
137
+ # 形如 ["a", 1.0, "b", 2.0]
138
+ if len(value) % 2 == 0 and any(isinstance(v, (int, float)) for v in value[1::2]):
139
+ return list(zip(map(str, value[0::2]), map(float, value[1::2])))
140
+ # 纯成员列表 ["a","b","c"]
141
+ return [str(x) for x in value]
142
+
143
+ # 其它情况兜底成列表
144
+ return [value]
145
+
146
+
147
+ REDIS_TYPE_MAP = {
148
+ # === String 相关 ===
149
+ "get": to_str, # key 不存在 -> None
150
+ "set": to_bool, # "OK" -> True
151
+ "incr": to_int, # 自增后的值
152
+ "incrby": to_int, # 自增后的值
153
+ "getbit": to_int, # 返回 0 / 1
154
+ "setbit": to_int,
155
+ "strlen": to_int, # 字符串长度
156
+ "bitcount": to_int, # bit count
157
+
158
+ # === Key 通用 ===
159
+ "delete": to_int, # 删除 key 个数
160
+ "exists": to_int, # 存在的 key 个数
161
+ "expire": to_bool, # True/False
162
+ "keys": to_list, # 列表
163
+ "ttl": to_int, # 剩余过期秒数
164
+
165
+ # === Hash ===
166
+ "hset": to_int, # 新增/修改数量
167
+ "hget": to_str, # 不存在 -> None
168
+ "hdel": to_int, # 删除字段数
169
+ "hincrby": to_int,
170
+ "hexists": to_bool,
171
+ "hscan": to_hscan,
172
+ # "hgetall": to_dict, # dict
173
+ "hlen": to_int,
174
+ "hkeys": to_list,
175
+ "hvals": to_list,
176
+
177
+ # === Set ===
178
+ "sadd": to_int,
179
+ "smembers": to_set,
180
+ "sismember": to_int,
181
+ "srem": to_int,
182
+ "spop": to_str, # 单个值(批量 -> list)
183
+ "srandmember": to_str, # 单个值(批量 -> list)
184
+ "scard": to_int,
185
+ "sscan": to_sscan,
186
+ "sdiff": to_set,
187
+
188
+ # === List ===
189
+ "lpush": to_int,
190
+ "rpush": to_int,
191
+ "lpop": to_str,
192
+ "rpop": to_str,
193
+ "lrange": to_list,
194
+ "llen": to_int,
195
+ "lindex": to_str,
196
+ "rpoplpush": to_str,
197
+ "lrem": to_int,
198
+
199
+ # === ZSet ===
200
+ "zadd": to_int,
201
+ "zremrangebyrank": to_int,
202
+ "zscore": to_str, # (可转 float)
203
+ "zremrangebyscore": to_int,
204
+ "zincrby": to_str, # (可转 float)
205
+ "zcount": to_int,
206
+ "zcard": to_int,
207
+ "zrem": to_int,
208
+ "zscan": to_zscan,
209
+ "zrange": to_zrange,
210
+ "zrangebyscore": to_zrange,
211
+
212
+ # === 脚本 / 特殊 ===
213
+ "eval": to_eval,
214
+ "scan": to_scan,
215
+ }
216
+
217
+
218
+ def create_async_session(self):
219
+ """创建同步的session"""
220
+ connector = aiohttp.TCPConnector(
221
+ limit=self.limit,
222
+ ttl_dns_cache=60,
223
+ enable_cleanup_closed=True,
224
+ force_close=False,
225
+ ssl=False,
226
+ )
227
+ timeout = aiohttp.ClientTimeout(
228
+ total=120, # 容忍整体请求更久
229
+ connect=10, # 连接最长 10s
230
+ sock_connect=10,
231
+ sock_read=100 # Redis pipeline 大响应时给足时间
232
+ )
233
+ return aiohttp.ClientSession(connector=connector, timeout=timeout)
234
+
235
+
236
+ SYSTEM_NAME = platform.system().lower()
237
+ # 等待删除
238
+ CUR_DIR = os.path.abspath(os.path.realpath(os.path.dirname(__file__)))
239
+ if SYSTEM_NAME in ["windows", "darwin"]:
240
+ local_env = True
241
+ else:
242
+ local_env = False
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: redis-http-client
3
+ Version: 0.1.1
4
+ Summary: A Redis client over HTTP supporting both sync (requests) and async (aiohttp) modes.
5
+ Author-email: victor <xianyu.wu@innodealing.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, http://git.innodealing.cn/dm_spider_service/redis-http-client.git
8
+ Project-URL: Source, http://git.innodealing.cn/dm_spider_service/redis-http-client.git
9
+ Project-URL: Issues, http://git.innodealing.cn/dm_spider_service/redis-http-client.git/issues
10
+ Keywords: redis,http,client,asyncio,requests,aiohttp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: Implementation :: CPython
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Topic :: Database
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: aiohttp>=3.9.0
25
+ Requires-Dist: requests>=2.31.0
26
+ Requires-Dist: urllib3>=2.0.0
27
+ Requires-Dist: loguru>=0.7.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: build>=1.2.1; extra == "dev"
30
+ Requires-Dist: twine>=5.1.0; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # redis-http-client
34
+
35
+ A lightweight Redis client over HTTP, supporting both async (`aiohttp`) and sync (`requests`) execution modes.
36
+
37
+ ## Features
38
+
39
+ - Async mode based on `aiohttp`
40
+ - Sync mode based on `requests`
41
+ - Dynamic Redis command proxy via `__getattr__`
42
+ - Pipeline support (`redis_pipeline_async` / `redis_pipeline_sync`)
43
+ - Built-in retry and connection-pool handling
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install redis-http-client
49
+ ```
50
+
51
+ ## Quick Start
52
+
53
+ ### Async
54
+
55
+ ```python
56
+ import asyncio
57
+ from redis_http_client import RedisHttpClient
58
+
59
+
60
+ async def main():
61
+ client = RedisHttpClient(
62
+ spider_redis_manager_service_url="http://127.0.0.1:8000",
63
+ mode="async",
64
+ )
65
+
66
+ result = await client.get("my_key")
67
+ print(result)
68
+
69
+ await client.close()
70
+
71
+
72
+ asyncio.run(main())
73
+ ```
74
+
75
+ ### Sync
76
+
77
+ ```python
78
+ from redis_http_client import RedisHttpClient
79
+
80
+ client = RedisHttpClient(
81
+ spider_redis_manager_service_url="http://127.0.0.1:8000",
82
+ mode="sync",
83
+ )
84
+
85
+ result = client.get("my_key")
86
+ print(result)
87
+
88
+ # sync mode reuses internal requests session; close when done
89
+ import asyncio
90
+ asyncio.run(client.close())
91
+ ```
92
+
93
+ ### Pipeline
94
+
95
+ ```python
96
+ commands = [
97
+ {"command": "GET", "args": ["k1"], "kwargs": {}},
98
+ {"command": "GET", "args": ["k2"], "kwargs": {}},
99
+ ]
100
+
101
+ # async
102
+ # results = await client.redis_pipeline_async(commands)
103
+
104
+ # sync
105
+ # results = client.redis_pipeline_sync(commands)
106
+ ```
107
+
108
+ ## Build
109
+
110
+ ```bash
111
+ python -m pip install --upgrade build twine
112
+ python -m build
113
+ ```
114
+
115
+ Build outputs are generated in `dist/`.
116
+
117
+ ## Upload To PyPI
118
+
119
+ ### 1. TestPyPI (recommended first)
120
+
121
+ ```bash
122
+ twine check dist/*
123
+ twine upload -r testpypi dist/*
124
+ ```
125
+
126
+ ### 2. PyPI
127
+
128
+ ```bash
129
+ twine upload dist/*
130
+ ```
131
+
132
+ You can use API token authentication:
133
+
134
+ - username: `__token__`
135
+ - password: `pypi-xxxx...`
136
+
137
+ ## Versioning
138
+
139
+ Current version: `0.1.1`
140
+
141
+ When releasing a new version:
142
+
143
+ 1. Update `version` in `pyproject.toml`
144
+ 2. Update `__version__` in `redis_http_client/__init__.py`
145
+ 3. Rebuild and upload
146
+
147
+ ## License
148
+
149
+ MIT
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ redis_http_client/__init__.py
5
+ redis_http_client/client.py
6
+ redis_http_client/other_logger.py
7
+ redis_http_client/sub_run.py
8
+ redis_http_client/to_method.py
9
+ redis_http_client.egg-info/PKG-INFO
10
+ redis_http_client.egg-info/SOURCES.txt
11
+ redis_http_client.egg-info/dependency_links.txt
12
+ redis_http_client.egg-info/requires.txt
13
+ redis_http_client.egg-info/top_level.txt
@@ -0,0 +1,8 @@
1
+ aiohttp>=3.9.0
2
+ requests>=2.31.0
3
+ urllib3>=2.0.0
4
+ loguru>=0.7.0
5
+
6
+ [dev]
7
+ build>=1.2.1
8
+ twine>=5.1.0
@@ -0,0 +1 @@
1
+ redis_http_client
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+