valkey-glide 1.3.5rc5__cp312-cp312-macosx_11_0_arm64.whl → 2.0.0__cp312-cp312-macosx_11_0_arm64.whl

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.

Potentially problematic release.


This version of valkey-glide might be problematic. Click here for more details.

Files changed (36) hide show
  1. glide/__init__.py +32 -8
  2. glide/async_commands/{transaction.py → batch.py} +1420 -992
  3. glide/async_commands/batch_options.py +261 -0
  4. glide/async_commands/bitmap.py +94 -85
  5. glide/async_commands/cluster_commands.py +293 -126
  6. glide/async_commands/command_args.py +7 -6
  7. glide/async_commands/core.py +1313 -721
  8. glide/async_commands/server_modules/ft.py +83 -14
  9. glide/async_commands/server_modules/ft_options/ft_aggregate_options.py +15 -8
  10. glide/async_commands/server_modules/ft_options/ft_create_options.py +23 -11
  11. glide/async_commands/server_modules/ft_options/ft_profile_options.py +12 -7
  12. glide/async_commands/server_modules/ft_options/ft_search_options.py +12 -6
  13. glide/async_commands/server_modules/glide_json.py +134 -43
  14. glide/async_commands/server_modules/json_batch.py +157 -127
  15. glide/async_commands/sorted_set.py +39 -29
  16. glide/async_commands/standalone_commands.py +202 -95
  17. glide/async_commands/stream.py +94 -87
  18. glide/config.py +253 -112
  19. glide/constants.py +8 -4
  20. glide/glide.cpython-312-darwin.so +0 -0
  21. glide/glide.pyi +25 -0
  22. glide/glide_client.py +305 -94
  23. glide/logger.py +31 -19
  24. glide/opentelemetry.py +181 -0
  25. glide/protobuf/command_request_pb2.py +15 -15
  26. glide/protobuf/command_request_pb2.pyi +75 -46
  27. glide/protobuf/connection_request_pb2.py +12 -12
  28. glide/protobuf/connection_request_pb2.pyi +36 -29
  29. glide/protobuf/response_pb2.py +6 -6
  30. glide/protobuf/response_pb2.pyi +14 -9
  31. glide/protobuf_codec.py +7 -6
  32. glide/routes.py +41 -8
  33. {valkey_glide-1.3.5rc5.dist-info → valkey_glide-2.0.0.dist-info}/METADATA +38 -14
  34. valkey_glide-2.0.0.dist-info/RECORD +39 -0
  35. valkey_glide-1.3.5rc5.dist-info/RECORD +0 -37
  36. {valkey_glide-1.3.5rc5.dist-info → valkey_glide-2.0.0.dist-info}/WHEEL +0 -0
glide/glide.pyi CHANGED
@@ -27,6 +27,28 @@ class ClusterScanCursor:
27
27
  def get_cursor(self) -> str: ...
28
28
  def is_finished(self) -> bool: ...
29
29
 
30
+ class OpenTelemetryConfig:
31
+ def __init__(
32
+ self,
33
+ traces: Optional[OpenTelemetryTracesConfig] = None,
34
+ metrics: Optional[OpenTelemetryMetricsConfig] = None,
35
+ flush_interval_ms: Optional[int] = None,
36
+ ) -> None: ...
37
+ def get_traces(self) -> Optional[OpenTelemetryTracesConfig]: ...
38
+ def set_traces(self, traces: OpenTelemetryTracesConfig) -> None: ...
39
+ def get_metrics(self) -> Optional[OpenTelemetryMetricsConfig]: ...
40
+
41
+ class OpenTelemetryTracesConfig:
42
+ def __init__(
43
+ self, endpoint: str, sample_percentage: Optional[int] = None
44
+ ) -> None: ...
45
+ def get_endpoint(self) -> str: ...
46
+ def get_sample_percentage(self) -> Optional[int]: ...
47
+
48
+ class OpenTelemetryMetricsConfig:
49
+ def __init__(self, endpoint: str) -> None: ...
50
+ def get_endpoint(self) -> str: ...
51
+
30
52
  def start_socket_listener_external(init_callback: Callable) -> None: ...
31
53
  def value_from_pointer(pointer: int) -> TResult: ...
32
54
  def create_leaked_value(message: str) -> int: ...
@@ -34,3 +56,6 @@ def create_leaked_bytes_vec(args_vec: List[bytes]) -> int: ...
34
56
  def get_statistics() -> dict: ...
35
57
  def py_init(level: Optional[Level], file_name: Optional[str]) -> Level: ...
36
58
  def py_log(log_level: Level, log_identifier: str, message: str) -> None: ...
59
+ def create_otel_span(name: str) -> int: ...
60
+ def drop_otel_span(span_ptr: int) -> None: ...
61
+ def init_opentelemetry(config: OpenTelemetryConfig) -> None: ...
glide/glide_client.py CHANGED
@@ -1,9 +1,24 @@
1
1
  # Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0
2
2
 
3
- import asyncio
4
3
  import sys
5
4
  import threading
6
- from typing import Any, Dict, List, Optional, Tuple, Type, Union, cast
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ Any,
8
+ Awaitable,
9
+ Dict,
10
+ List,
11
+ Optional,
12
+ Set,
13
+ Tuple,
14
+ Type,
15
+ Union,
16
+ cast,
17
+ )
18
+
19
+ import anyio
20
+ import sniffio
21
+ from anyio import to_thread
7
22
 
8
23
  from glide.async_commands.cluster_commands import ClusterCommands
9
24
  from glide.async_commands.command_args import ObjectType
@@ -21,6 +36,7 @@ from glide.exceptions import (
21
36
  )
22
37
  from glide.logger import Level as LogLevel
23
38
  from glide.logger import Logger as ClientLogger
39
+ from glide.opentelemetry import OpenTelemetry
24
40
  from glide.protobuf.command_request_pb2 import Command, CommandRequest, RequestType
25
41
  from glide.protobuf.connection_request_pb2 import ConnectionRequest
26
42
  from glide.protobuf.response_pb2 import RequestErrorType, Response
@@ -32,18 +48,26 @@ from .glide import (
32
48
  MAX_REQUEST_ARGS_LEN,
33
49
  ClusterScanCursor,
34
50
  create_leaked_bytes_vec,
51
+ create_otel_span,
52
+ drop_otel_span,
35
53
  get_statistics,
36
54
  start_socket_listener_external,
37
55
  value_from_pointer,
38
56
  )
39
57
 
40
58
  if sys.version_info >= (3, 11):
41
- import asyncio as async_timeout
42
59
  from typing import Self
43
60
  else:
44
- import async_timeout
45
61
  from typing_extensions import Self
46
62
 
63
+ if TYPE_CHECKING:
64
+ import asyncio
65
+
66
+ import trio
67
+
68
+ TTask = Union[asyncio.Task[None], trio.lowlevel.Task]
69
+ TFuture = Union[asyncio.Future[Any], "_CompatFuture"]
70
+
47
71
 
48
72
  def get_request_error_class(
49
73
  error_type: Optional[RequestErrorType.ValueType],
@@ -59,38 +83,168 @@ def get_request_error_class(
59
83
  return RequestError
60
84
 
61
85
 
86
+ class _CompatFuture:
87
+ """anyio shim for asyncio.Future-like functionality"""
88
+
89
+ def __init__(self) -> None:
90
+ self._is_done = anyio.Event()
91
+ self._result: Any = None
92
+ self._exception: Optional[Exception] = None
93
+
94
+ def set_result(self, result: Any) -> None:
95
+ self._result = result
96
+ self._is_done.set()
97
+
98
+ def set_exception(self, exception: Exception) -> None:
99
+ self._exception = exception
100
+ self._is_done.set()
101
+
102
+ def done(self) -> bool:
103
+ return self._is_done.is_set()
104
+
105
+ def __await__(self):
106
+ return self._is_done.wait().__await__()
107
+
108
+ def result(self) -> Any:
109
+ if self._exception:
110
+ raise self._exception
111
+
112
+ return self._result
113
+
114
+
115
+ def _get_new_future_instance() -> "TFuture":
116
+ if sniffio.current_async_library() == "asyncio":
117
+ import asyncio
118
+
119
+ return asyncio.get_running_loop().create_future()
120
+
121
+ # _CompatFuture is also compatible with asyncio, but is not as closely integrated
122
+ # into the asyncio event loop and thus introduces a noticeable performance
123
+ # degradation. so we only use it for trio
124
+ return _CompatFuture()
125
+
126
+
62
127
  class BaseClient(CoreCommands):
63
128
  def __init__(self, config: BaseClientConfiguration):
64
129
  """
65
130
  To create a new client, use the `create` classmethod
66
131
  """
67
132
  self.config: BaseClientConfiguration = config
68
- self._available_futures: Dict[int, asyncio.Future] = {}
133
+ self._available_futures: Dict[int, "TFuture"] = {}
69
134
  self._available_callback_indexes: List[int] = list()
70
135
  self._buffered_requests: List[TRequest] = list()
71
136
  self._writer_lock = threading.Lock()
72
137
  self.socket_path: Optional[str] = None
73
- self._reader_task: Optional[asyncio.Task] = None
138
+ self._reader_task: Optional["TTask"] = None
74
139
  self._is_closed: bool = False
75
- self._pubsub_futures: List[asyncio.Future] = []
140
+ self._pubsub_futures: List["TFuture"] = []
76
141
  self._pubsub_lock = threading.Lock()
77
142
  self._pending_push_notifications: List[Response] = list()
78
143
 
144
+ self._pending_tasks: Optional[Set[Awaitable[None]]] = None
145
+ """asyncio-only to avoid gc on pending write tasks"""
146
+
147
+ def _create_task(self, task, *args, **kwargs):
148
+ """framework agnostic free-floating task shim"""
149
+ framework = sniffio.current_async_library()
150
+ if framework == "trio":
151
+ from functools import partial
152
+
153
+ import trio
154
+
155
+ return trio.lowlevel.spawn_system_task(partial(task, **kwargs), *args)
156
+ elif framework == "asyncio":
157
+ import asyncio
158
+
159
+ # the asyncio event loop holds weak refs to tasks, so it's recommended to
160
+ # hold strong refs to them during their lifetime to prevent garbage
161
+ # collection
162
+ t = asyncio.create_task(task(*args, **kwargs))
163
+
164
+ if self._pending_tasks is None:
165
+ self._pending_tasks = set()
166
+
167
+ self._pending_tasks.add(t)
168
+ t.add_done_callback(self._pending_tasks.discard)
169
+
170
+ return t
171
+
172
+ raise RuntimeError(f"Unsupported async framework {framework}")
173
+
79
174
  @classmethod
80
175
  async def create(cls, config: BaseClientConfiguration) -> Self:
81
176
  """Creates a Glide client.
82
177
 
83
178
  Args:
84
- config (ClientConfiguration): The client configurations.
85
- If no configuration is provided, a default client to "localhost":6379 will be created.
179
+ config (ClientConfiguration): The configuration options for the client, including cluster addresses,
180
+ authentication credentials, TLS settings, periodic checks, and Pub/Sub subscriptions.
86
181
 
87
182
  Returns:
88
- Self: a Glide Client instance.
183
+ Self: A promise that resolves to a connected client instance.
184
+
185
+ Examples:
186
+ # Connecting to a Standalone Server
187
+ >>> from glide import GlideClientConfiguration, NodeAddress, GlideClient, ServerCredentials, BackoffStrategy
188
+ >>> config = GlideClientConfiguration(
189
+ ... [
190
+ ... NodeAddress('primary.example.com', 6379),
191
+ ... NodeAddress('replica1.example.com', 6379),
192
+ ... ],
193
+ ... use_tls = True,
194
+ ... database_id = 1,
195
+ ... credentials = ServerCredentials(username = 'user1', password = 'passwordA'),
196
+ ... reconnect_strategy = BackoffStrategy(num_of_retries = 5, factor = 1000, exponent_base = 2),
197
+ ... pubsub_subscriptions = GlideClientConfiguration.PubSubSubscriptions(
198
+ ... channels_and_patterns = {GlideClientConfiguration.PubSubChannelModes.Exact: {'updates'}},
199
+ ... callback = lambda message,context : print(message),
200
+ ... ),
201
+ ... )
202
+ >>> client = await GlideClient.create(config)
203
+
204
+ # Connecting to a Cluster
205
+ >>> from glide import GlideClusterClientConfiguration, NodeAddress, GlideClusterClient,
206
+ ... PeriodicChecksManualInterval
207
+ >>> config = GlideClusterClientConfiguration(
208
+ ... [
209
+ ... NodeAddress('address1.example.com', 6379),
210
+ ... NodeAddress('address2.example.com', 6379),
211
+ ... ],
212
+ ... use_tls = True,
213
+ ... periodic_checks = PeriodicChecksManualInterval(duration_in_sec = 30),
214
+ ... credentials = ServerCredentials(username = 'user1', password = 'passwordA'),
215
+ ... reconnect_strategy = BackoffStrategy(num_of_retries = 5, factor = 1000, exponent_base = 2),
216
+ ... pubsub_subscriptions = GlideClusterClientConfiguration.PubSubSubscriptions(
217
+ ... channels_and_patterns = {
218
+ ... GlideClusterClientConfiguration.PubSubChannelModes.Exact: {'updates'},
219
+ ... GlideClusterClientConfiguration.PubSubChannelModes.Sharded: {'sharded_channel'},
220
+ ... },
221
+ ... callback = lambda message,context : print(message),
222
+ ... ),
223
+ ... )
224
+ >>> client = await GlideClusterClient.create(config)
225
+
226
+ Remarks:
227
+ Use this static method to create and connect a client to a Valkey server.
228
+ The client will automatically handle connection establishment, including cluster topology discovery and
229
+ handling of authentication and TLS configurations.
230
+
231
+ - **Cluster Topology Discovery**: The client will automatically discover the cluster topology based
232
+ on the seed addresses provided.
233
+ - **Authentication**: If `ServerCredentials` are provided, the client will attempt to authenticate
234
+ using the specified username and password.
235
+ - **TLS**: If `use_tls` is set to `true`, the client will establish secure connections using TLS.
236
+ - **Periodic Checks**: The `periodic_checks` setting allows you to configure how often the client
237
+ checks for cluster topology changes.
238
+ - **Reconnection Strategy**: The `BackoffStrategy` settings define how the client will attempt to
239
+ reconnect in case of disconnections.
240
+ - **Pub/Sub Subscriptions**: Any channels or patterns specified in `PubSubSubscriptions` will be
241
+ subscribed to upon connection.
242
+
89
243
  """
90
244
  config = config
91
245
  self = cls(config)
92
- init_future: asyncio.Future = asyncio.Future()
93
- loop = asyncio.get_event_loop()
246
+
247
+ init_event: threading.Event = threading.Event()
94
248
 
95
249
  def init_callback(socket_path: Optional[str], err: Optional[str]):
96
250
  if err is not None:
@@ -102,7 +256,7 @@ class BaseClient(CoreCommands):
102
256
  else:
103
257
  # Received socket path
104
258
  self.socket_path = socket_path
105
- loop.call_soon_threadsafe(init_future.set_result, True)
259
+ init_event.set()
106
260
 
107
261
  start_socket_listener_external(init_callback=init_callback)
108
262
 
@@ -110,36 +264,27 @@ class BaseClient(CoreCommands):
110
264
  # level or higher
111
265
  ClientLogger.log(LogLevel.INFO, "connection info", "new connection established")
112
266
  # Wait for the socket listener to complete its initialization
113
- await init_future
267
+ await to_thread.run_sync(init_event.wait)
114
268
  # Create UDS connection
115
269
  await self._create_uds_connection()
270
+
116
271
  # Start the reader loop as a background task
117
- self._reader_task = asyncio.create_task(self._reader_loop())
272
+ self._reader_task = self._create_task(self._reader_loop)
273
+
118
274
  # Set the client configurations
119
275
  await self._set_connection_configurations()
276
+
120
277
  return self
121
278
 
122
279
  async def _create_uds_connection(self) -> None:
123
280
  try:
124
281
  # Open an UDS connection
125
- async with async_timeout.timeout(DEFAULT_TIMEOUT_IN_MILLISECONDS):
126
- reader, writer = await asyncio.open_unix_connection(
127
- path=self.socket_path
282
+ with anyio.fail_after(DEFAULT_TIMEOUT_IN_MILLISECONDS):
283
+ self._stream = await anyio.connect_unix(
284
+ path=cast(str, self.socket_path)
128
285
  )
129
- self._reader = reader
130
- self._writer = writer
131
286
  except Exception as e:
132
- await self.close(f"Failed to create UDS connection: {e}")
133
- raise
134
-
135
- def __del__(self) -> None:
136
- try:
137
- if self._reader_task:
138
- self._reader_task.cancel()
139
- except RuntimeError as e:
140
- if "no running event loop" in str(e):
141
- # event loop already closed
142
- pass
287
+ raise ClosingError("Failed to create UDS connection") from e
143
288
 
144
289
  async def close(self, err_message: Optional[str] = None) -> None:
145
290
  """
@@ -147,28 +292,28 @@ class BaseClient(CoreCommands):
147
292
  All open futures will be closed with an exception.
148
293
 
149
294
  Args:
150
- err_message (Optional[str]): If not None, this error message will be passed along with the exceptions when closing all open futures.
295
+ err_message (Optional[str]): If not None, this error message will be passed along with the exceptions when
296
+ closing all open futures.
151
297
  Defaults to None.
152
298
  """
153
- self._is_closed = True
154
- for response_future in self._available_futures.values():
155
- if not response_future.done():
156
- err_message = "" if err_message is None else err_message
157
- response_future.set_exception(ClosingError(err_message))
158
- try:
159
- self._pubsub_lock.acquire()
160
- for pubsub_future in self._pubsub_futures:
161
- if not pubsub_future.done() and not pubsub_future.cancelled():
162
- pubsub_future.set_exception(ClosingError(""))
163
- finally:
164
- self._pubsub_lock.release()
299
+ if not self._is_closed:
300
+ self._is_closed = True
301
+ err_message = "" if err_message is None else err_message
302
+ for response_future in self._available_futures.values():
303
+ if not response_future.done():
304
+ response_future.set_exception(ClosingError(err_message))
305
+ try:
306
+ self._pubsub_lock.acquire()
307
+ for pubsub_future in self._pubsub_futures:
308
+ if not pubsub_future.done():
309
+ pubsub_future.set_exception(ClosingError(err_message))
310
+ finally:
311
+ self._pubsub_lock.release()
165
312
 
166
- self._writer.close()
167
- await self._writer.wait_closed()
168
- self.__del__()
313
+ await self._stream.aclose()
169
314
 
170
- def _get_future(self, callback_idx: int) -> asyncio.Future:
171
- response_future: asyncio.Future = asyncio.Future()
315
+ def _get_future(self, callback_idx: int) -> "TFuture":
316
+ response_future: "TFuture" = _get_new_future_instance()
172
317
  self._available_futures.update({callback_idx: response_future})
173
318
  return response_future
174
319
 
@@ -177,14 +322,15 @@ class BaseClient(CoreCommands):
177
322
 
178
323
  async def _set_connection_configurations(self) -> None:
179
324
  conn_request = self._get_protobuf_conn_request()
180
- response_future: asyncio.Future = self._get_future(0)
181
- await self._write_or_buffer_request(conn_request)
325
+ response_future: "TFuture" = self._get_future(0)
326
+ self._create_write_task(conn_request)
182
327
  await response_future
183
- if response_future.result() is not OK:
184
- raise ClosingError(response_future.result())
328
+ res = response_future.result()
329
+ if res is not OK:
330
+ raise ClosingError(res)
185
331
 
186
332
  def _create_write_task(self, request: TRequest):
187
- asyncio.create_task(self._write_or_buffer_request(request))
333
+ self._create_task(self._write_or_buffer_request, request)
188
334
 
189
335
  async def _write_or_buffer_request(self, request: TRequest):
190
336
  self._buffered_requests.append(request)
@@ -192,7 +338,21 @@ class BaseClient(CoreCommands):
192
338
  try:
193
339
  while len(self._buffered_requests) > 0:
194
340
  await self._write_buffered_requests_to_socket()
195
-
341
+ except Exception as e:
342
+ # trio system tasks cannot raise exceptions, so gracefully propagate
343
+ # any error to the pending future instead
344
+ callback_idx = (
345
+ request.callback_idx if isinstance(request, CommandRequest) else 0
346
+ )
347
+ res_future = self._available_futures.pop(callback_idx, None)
348
+ if res_future:
349
+ res_future.set_exception(e)
350
+ else:
351
+ ClientLogger.log(
352
+ LogLevel.WARN,
353
+ "unhandled response error",
354
+ f"Unhandled response error for unknown request: {callback_idx}",
355
+ )
196
356
  finally:
197
357
  self._writer_lock.release()
198
358
 
@@ -202,8 +362,7 @@ class BaseClient(CoreCommands):
202
362
  b_arr = bytearray()
203
363
  for request in requests:
204
364
  ProtobufCodec.encode_delimited(b_arr, request)
205
- self._writer.write(b_arr)
206
- await self._writer.drain()
365
+ await self._stream.send(b_arr)
207
366
 
208
367
  def _encode_arg(self, arg: TEncodable) -> bytes:
209
368
  """
@@ -254,6 +413,13 @@ class BaseClient(CoreCommands):
254
413
  raise ClosingError(
255
414
  "Unable to execute requests; the client is closed. Please create a new client."
256
415
  )
416
+
417
+ # Create span if OpenTelemetry is configured and sampling indicates we should trace
418
+ span = None
419
+ if OpenTelemetry.should_sample():
420
+ command_name = RequestType.Name(request_type)
421
+ span = create_otel_span(command_name)
422
+
257
423
  request = CommandRequest()
258
424
  request.callback_idx = self._get_callback_index()
259
425
  request.single_command.request_type = request_type
@@ -268,21 +434,39 @@ class BaseClient(CoreCommands):
268
434
  request.single_command.args_vec_pointer = create_leaked_bytes_vec(
269
435
  encoded_args
270
436
  )
437
+
438
+ # Add span pointer to request if span was created
439
+ if span:
440
+ request.root_span_ptr = span
441
+
271
442
  set_protobuf_route(request, route)
272
443
  return await self._write_request_await_response(request)
273
444
 
274
- async def _execute_transaction(
445
+ async def _execute_batch(
275
446
  self,
276
447
  commands: List[Tuple[RequestType.ValueType, List[TEncodable]]],
448
+ is_atomic: bool,
449
+ raise_on_error: bool = False,
450
+ retry_server_error: bool = False,
451
+ retry_connection_error: bool = False,
277
452
  route: Optional[Route] = None,
453
+ timeout: Optional[int] = None,
278
454
  ) -> List[TResult]:
279
455
  if self._is_closed:
280
456
  raise ClosingError(
281
457
  "Unable to execute requests; the client is closed. Please create a new client."
282
458
  )
459
+
460
+ # Create span if OpenTelemetry is configured and sampling indicates we should trace
461
+ span = None
462
+
463
+ if OpenTelemetry.should_sample():
464
+ # Use "Batch" as span name for batches
465
+ span = create_otel_span("Batch")
466
+
283
467
  request = CommandRequest()
284
468
  request.callback_idx = self._get_callback_index()
285
- transaction_commands = []
469
+ batch_commands = []
286
470
  for requst_type, args in commands:
287
471
  command = Command()
288
472
  command.request_type = requst_type
@@ -293,8 +477,19 @@ class BaseClient(CoreCommands):
293
477
  command.args_array.args[:] = encoded_args
294
478
  else:
295
479
  command.args_vec_pointer = create_leaked_bytes_vec(encoded_args)
296
- transaction_commands.append(command)
297
- request.transaction.commands.extend(transaction_commands)
480
+ batch_commands.append(command)
481
+ request.batch.commands.extend(batch_commands)
482
+ request.batch.is_atomic = is_atomic
483
+ request.batch.raise_on_error = raise_on_error
484
+ if timeout is not None:
485
+ request.batch.timeout = timeout
486
+ request.batch.retry_server_error = retry_server_error
487
+ request.batch.retry_connection_error = retry_connection_error
488
+
489
+ # Add span pointer to request if span was created
490
+ if span:
491
+ request.root_span_ptr = span
492
+
298
493
  set_protobuf_route(request, route)
299
494
  return await self._write_request_await_response(request)
300
495
 
@@ -346,14 +541,15 @@ class BaseClient(CoreCommands):
346
541
  )
347
542
 
348
543
  # locking might not be required
349
- response_future: asyncio.Future = asyncio.Future()
544
+ response_future: "TFuture" = _get_new_future_instance()
350
545
  try:
351
546
  self._pubsub_lock.acquire()
352
547
  self._pubsub_futures.append(response_future)
353
548
  self._complete_pubsub_futures_safe()
354
549
  finally:
355
550
  self._pubsub_lock.release()
356
- return await response_future
551
+ await response_future
552
+ return response_future.result()
357
553
 
358
554
  def try_get_pubsub_message(self) -> Optional[CoreCommands.PubSubMsg]:
359
555
  if self._is_closed:
@@ -386,8 +582,7 @@ class BaseClient(CoreCommands):
386
582
  def _cancel_pubsub_futures_with_exception_safe(self, exception: ConnectionError):
387
583
  while len(self._pubsub_futures):
388
584
  next_future = self._pubsub_futures.pop(0)
389
- if not next_future.cancelled():
390
- next_future.set_exception(exception)
585
+ next_future.set_exception(exception)
391
586
 
392
587
  def _notification_to_pubsub_message_safe(
393
588
  self, response: Response
@@ -467,10 +662,16 @@ class BaseClient(CoreCommands):
467
662
  if response.HasField("closing_error")
468
663
  else f"Client Error - closing due to unknown error. callback index: {response.callback_idx}"
469
664
  )
665
+ exc = ClosingError(err_msg)
470
666
  if res_future is not None:
471
- res_future.set_exception(ClosingError(err_msg))
472
- await self.close(err_msg)
473
- raise ClosingError(err_msg)
667
+ res_future.set_exception(exc)
668
+ else:
669
+ ClientLogger.log(
670
+ LogLevel.WARN,
671
+ "unhandled response error",
672
+ f"Unhandled response error for unknown request: {response.callback_idx}",
673
+ )
674
+ raise exc
474
675
  else:
475
676
  self._available_callback_indexes.append(response.callback_idx)
476
677
  if response.HasField("request_error"):
@@ -483,6 +684,10 @@ class BaseClient(CoreCommands):
483
684
  else:
484
685
  res_future.set_result(None)
485
686
 
687
+ # Clean up span if it was created
688
+ if response.HasField("root_span_ptr"):
689
+ drop_otel_span(response.root_span_ptr)
690
+
486
691
  async def _process_push(self, response: Response) -> None:
487
692
  if response.HasField("closing_error") or not response.HasField("resp_pointer"):
488
693
  err_msg = (
@@ -490,9 +695,7 @@ class BaseClient(CoreCommands):
490
695
  if response.HasField("closing_error")
491
696
  else "Client Error - push notification without resp_pointer"
492
697
  )
493
- await self.close(err_msg)
494
698
  raise ClosingError(err_msg)
495
-
496
699
  try:
497
700
  self._pubsub_lock.acquire()
498
701
  callback, context = self.config._get_pubsub_callback_and_context()
@@ -508,30 +711,36 @@ class BaseClient(CoreCommands):
508
711
 
509
712
  async def _reader_loop(self) -> None:
510
713
  # Socket reader loop
511
- remaining_read_bytes = bytearray()
512
- while True:
513
- read_bytes = await self._reader.read(DEFAULT_READ_BYTES_SIZE)
514
- if len(read_bytes) == 0:
515
- err_msg = "The communication layer was unexpectedly closed."
516
- await self.close(err_msg)
517
- raise ClosingError(err_msg)
518
- read_bytes = remaining_read_bytes + bytearray(read_bytes)
519
- read_bytes_view = memoryview(read_bytes)
520
- offset = 0
521
- while offset <= len(read_bytes):
714
+ try:
715
+ remaining_read_bytes = bytearray()
716
+ while True:
522
717
  try:
523
- response, offset = ProtobufCodec.decode_delimited(
524
- read_bytes, read_bytes_view, offset, Response
718
+ read_bytes = await self._stream.receive(DEFAULT_READ_BYTES_SIZE)
719
+ except (anyio.ClosedResourceError, anyio.EndOfStream):
720
+ raise ClosingError(
721
+ "The communication layer was unexpectedly closed."
525
722
  )
526
- except PartialMessageException:
527
- # Received only partial response, break the inner loop
528
- remaining_read_bytes = read_bytes[offset:]
529
- break
530
- response = cast(Response, response)
531
- if response.is_push:
532
- await self._process_push(response=response)
533
- else:
534
- await self._process_response(response=response)
723
+ read_bytes = remaining_read_bytes + bytearray(read_bytes)
724
+ read_bytes_view = memoryview(read_bytes)
725
+ offset = 0
726
+ while offset <= len(read_bytes):
727
+ try:
728
+ response, offset = ProtobufCodec.decode_delimited(
729
+ read_bytes, read_bytes_view, offset, Response
730
+ )
731
+ except PartialMessageException:
732
+ # Received only partial response, break the inner loop
733
+ remaining_read_bytes = read_bytes[offset:]
734
+ break
735
+ response = cast(Response, response)
736
+ if response.is_push:
737
+ await self._process_push(response=response)
738
+ else:
739
+ await self._process_response(response=response)
740
+ except Exception as e:
741
+ # close and stop reading at terminal exceptions from incoming responses or
742
+ # stream closures
743
+ await self.close(str(e))
535
744
 
536
745
  async def get_statistics(self) -> dict:
537
746
  return get_statistics()
@@ -556,8 +765,9 @@ class BaseClient(CoreCommands):
556
765
  class GlideClusterClient(BaseClient, ClusterCommands):
557
766
  """
558
767
  Client used for connection to cluster servers.
768
+ Use :func:`~BaseClient.create` to request a client.
559
769
  For full documentation, see
560
- https://github.com/valkey-io/valkey-glide/wiki/Python-wrapper#cluster
770
+ [Valkey GLIDE Wiki](https://github.com/valkey-io/valkey-glide/wiki/Python-wrapper#cluster)
561
771
  """
562
772
 
563
773
  async def _cluster_scan(
@@ -596,8 +806,9 @@ class GlideClusterClient(BaseClient, ClusterCommands):
596
806
  class GlideClient(BaseClient, StandaloneCommands):
597
807
  """
598
808
  Client used for connection to standalone servers.
809
+ Use :func:`~BaseClient.create` to request a client.
599
810
  For full documentation, see
600
- https://github.com/valkey-io/valkey-glide/wiki/Python-wrapper#standalone
811
+ [Valkey GLIDE Wiki](https://github.com/valkey-io/valkey-glide/wiki/Python-wrapper#standalone)
601
812
  """
602
813
 
603
814