flo-python 0.1.0.dev4__py3-none-any.whl → 0.1.2__py3-none-any.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.
flo/client.py CHANGED
@@ -10,19 +10,31 @@ from typing import TYPE_CHECKING
10
10
 
11
11
  from .exceptions import (
12
12
  ConnectionFailedError,
13
+ InvalidChecksumError,
13
14
  InvalidEndpointError,
14
15
  NotConnectedError,
16
+ ProtocolError,
17
+ RequestTimeoutError,
15
18
  UnexpectedEofError,
16
19
  raise_for_status,
17
20
  )
18
- from .types import HEADER_SIZE, OpCode, StatusCode
19
- from .wire import RawResponse, parse_response_header, serialize_request
21
+ from .retry import DEFAULT_RETRY_POLICY, RetryPolicy, _policy_backoff, _refused_unrun
22
+ from .types import _DEFAULT_WORKER_BLOCK_MS, HEADER_SIZE, OpCode, OptionTag, StatusCode
23
+ from .wire import (
24
+ OptionsIterator,
25
+ RawResponse,
26
+ compute_crc32,
27
+ parse_response_header,
28
+ serialize_request,
29
+ )
20
30
 
21
31
  if TYPE_CHECKING:
22
32
  from .worker import ActionWorker, StreamRecordHandler, StreamWorker
23
33
 
24
34
  logger = logging.getLogger("flo")
25
35
 
36
+ _SERVER_AWAIT_DEFAULT_MS = 30_000
37
+
26
38
 
27
39
  class FloClient:
28
40
  """Async client for Flo server.
@@ -40,19 +52,28 @@ class FloClient:
40
52
  namespace: str = "default",
41
53
  timeout_ms: int = 5000,
42
54
  debug: bool = False,
55
+ retry: RetryPolicy = DEFAULT_RETRY_POLICY,
43
56
  ):
44
57
  """Initialize Flo client.
45
58
 
46
59
  Args:
47
60
  endpoint: Server endpoint in "host:port" format.
48
61
  namespace: Default namespace for operations.
49
- timeout_ms: Connection and operation timeout in milliseconds.
62
+ timeout_ms: Connection and operation timeout in milliseconds. A
63
+ blocking call (block_ms) is allowed this long on
64
+ top of its wait. A call that times out or is cancelled drops
65
+ the connection; call reconnect() before reusing the client.
50
66
  debug: Enable debug logging.
67
+ retry: How a request the server refused without running it
68
+ (overloaded or unavailable, ran = no) is sent again; backoffs
69
+ are in milliseconds. RetryPolicy(max_attempts=1) turns
70
+ retrying off. Workers made from this client use it too.
51
71
  """
52
72
  self._endpoint = endpoint
53
73
  self._namespace = namespace
54
74
  self._timeout = timeout_ms / 1000.0 # Convert to seconds
55
75
  self._debug = debug
76
+ self._retry = retry
56
77
 
57
78
  self._reader: asyncio.StreamReader | None = None
58
79
  self._writer: asyncio.StreamWriter | None = None
@@ -83,7 +104,27 @@ class FloClient:
83
104
 
84
105
  @staticmethod
85
106
  def _parse_endpoint(endpoint: str) -> tuple[str, int]:
86
- """Parse endpoint string into host and port."""
107
+ """Parse endpoint string into host and port.
108
+
109
+ Accepts an optional URI scheme: ``flo://`` (plaintext) is stripped and
110
+ bare ``host:port`` remains valid. ``flos://`` (TLS) is reserved but not
111
+ yet implemented, and any other scheme is rejected so it fails here
112
+ rather than as a confusing DNS error at connect time.
113
+ """
114
+ if "://" in endpoint:
115
+ scheme, _, rest = endpoint.partition("://")
116
+ scheme = scheme.lower()
117
+ if scheme == "flo":
118
+ # Drop scheme and any trailing path/query (reserved for future use).
119
+ endpoint = rest.split("/", 1)[0]
120
+ elif scheme == "flos":
121
+ raise InvalidEndpointError(f"flos:// (TLS) is not yet supported: {endpoint}")
122
+ else:
123
+ raise InvalidEndpointError(
124
+ f"Unsupported scheme {scheme}:// (expected flo:// or bare "
125
+ f"host:port): {endpoint}"
126
+ )
127
+
87
128
  parts = endpoint.rsplit(":", 1)
88
129
  if len(parts) != 2:
89
130
  raise InvalidEndpointError(f"Invalid endpoint format: {endpoint} (expected host:port)")
@@ -167,13 +208,16 @@ class FloClient:
167
208
  timeout: Maximum total time to keep retrying (default 5 min).
168
209
  """
169
210
  async with self._lock:
170
- # Close existing connection
171
- if self._writer:
172
- self._writer.close()
173
- with contextlib.suppress(Exception):
174
- await self._writer.wait_closed()
211
+ # Close existing connection. It is detached before the await so a
212
+ # concurrent reconnect that installs its connection meanwhile
213
+ # keeps it.
214
+ old = self._writer
215
+ if old:
175
216
  self._writer = None
176
217
  self._reader = None
218
+ old.close()
219
+ with contextlib.suppress(Exception):
220
+ await old.wait_closed()
177
221
 
178
222
  backoff = 1.0
179
223
  max_backoff = 30.0
@@ -183,10 +227,16 @@ class FloClient:
183
227
  while True:
184
228
  attempt += 1
185
229
  try:
186
- self._reader, self._writer = await asyncio.wait_for(
230
+ reader, writer = await asyncio.wait_for(
187
231
  asyncio.open_connection(self._host, self._port),
188
232
  timeout=self._timeout,
189
233
  )
234
+ # A concurrent reconnect may have installed its connection
235
+ # first; close it rather than leak it.
236
+ replaced = self._writer
237
+ self._reader, self._writer = reader, writer
238
+ if replaced is not None:
239
+ replaced.close()
190
240
  if self._debug:
191
241
  logger.debug(f"[flo] Reconnected to {self._endpoint} (attempt {attempt})")
192
242
  return
@@ -224,6 +274,24 @@ class FloClient:
224
274
  self._request_id += 1
225
275
  return self._request_id
226
276
 
277
+ @staticmethod
278
+ def _server_wait_ms(op_code: OpCode, value: bytes, options: bytes) -> int:
279
+ """How long the server may hold this request before answering."""
280
+ if op_code == OpCode.KV_GET:
281
+ # kv.get's body opens with wait_ms: [present:u8][u64].
282
+ if len(value) >= 9 and value[0] == 1:
283
+ return int.from_bytes(value[1:9], "little")
284
+ return 0
285
+ waits = {
286
+ opt.tag: opt.as_u32() or 0
287
+ for opt in OptionsIterator(options)
288
+ if opt.tag == OptionTag.BLOCK_MS
289
+ }
290
+ if op_code == OpCode.ACTION_AWAIT:
291
+ # An await that names no block_ms waits the server's default.
292
+ waits.setdefault(OptionTag.BLOCK_MS, _SERVER_AWAIT_DEFAULT_MS)
293
+ return max(waits.values(), default=0)
294
+
227
295
  async def _send_request(
228
296
  self,
229
297
  op_code: OpCode,
@@ -232,7 +300,35 @@ class FloClient:
232
300
  value: bytes,
233
301
  options: bytes = b"",
234
302
  ) -> RawResponse:
235
- """Send a request and wait for response.
303
+ """Send a request and wait for its response, sending it again as the
304
+ retry policy allows while the server refuses it without running it.
305
+
306
+ Each try is a new request with a new id. The wait between tries is
307
+ taken outside the connection lock, so other calls proceed meanwhile.
308
+ """
309
+ wait = _policy_backoff(self._retry)
310
+ attempt = 1
311
+ while True:
312
+ response = await self._send_once(op_code, namespace, key, value, options)
313
+ if attempt >= self._retry.max_attempts or not _refused_unrun(
314
+ response.status, response.data
315
+ ):
316
+ return response
317
+ d = wait.next()
318
+ if self._debug:
319
+ logger.debug(f"[flo] {response.status.name}, not run: retrying in {d:.3f}s")
320
+ await asyncio.sleep(d)
321
+ attempt += 1
322
+
323
+ async def _send_once(
324
+ self,
325
+ op_code: OpCode,
326
+ namespace: str,
327
+ key: bytes,
328
+ value: bytes,
329
+ options: bytes = b"",
330
+ ) -> RawResponse:
331
+ """Send a request, under a new request id, and wait for its response.
236
332
 
237
333
  Args:
238
334
  op_code: Operation code.
@@ -252,6 +348,10 @@ class FloClient:
252
348
  raise NotConnectedError("Not connected to server")
253
349
 
254
350
  async with self._lock:
351
+ # A call that held the lock before this one may have dropped the
352
+ # connection.
353
+ if self._writer is None or self._reader is None:
354
+ raise NotConnectedError("Not connected to server")
255
355
  request_id = self._next_request_id()
256
356
 
257
357
  # Serialize request
@@ -267,46 +367,74 @@ class FloClient:
267
367
  if self._debug:
268
368
  logger.debug(f"[flo] -> {op_code.name} ns={namespace} key={key!r}")
269
369
 
270
- # Send request
271
- self._writer.write(request)
272
- await self._writer.drain()
273
-
274
- # Read response header
370
+ # The server may hold a blocking request for its whole wait, so it
371
+ # gets the operation timeout on top of that wait.
372
+ read_timeout = self._timeout + self._server_wait_ms(op_code, value, options) / 1000.0
373
+ writer, reader = self._writer, self._reader
275
374
  try:
276
- header_data = await asyncio.wait_for(
277
- self._reader.readexactly(HEADER_SIZE),
278
- timeout=self._timeout,
279
- )
280
- except asyncio.IncompleteReadError as e:
281
- raise UnexpectedEofError("Connection closed while reading response header") from e
282
-
283
- # Parse header to get data length
284
- status, data_len, resp_request_id, crc = parse_response_header(header_data)
375
+ writer.write(request)
376
+ await writer.drain()
285
377
 
286
- # Read response data
287
- if data_len > 0:
288
378
  try:
289
- response_data = await asyncio.wait_for(
290
- self._reader.readexactly(data_len),
291
- timeout=self._timeout,
379
+ header_data = await asyncio.wait_for(
380
+ reader.readexactly(HEADER_SIZE),
381
+ timeout=read_timeout,
292
382
  )
293
383
  except asyncio.IncompleteReadError as e:
294
- raise UnexpectedEofError("Connection closed while reading response data") from e
295
- else:
296
- response_data = b""
384
+ raise UnexpectedEofError(
385
+ "Connection closed while reading response header"
386
+ ) from e
387
+ except asyncio.TimeoutError as e:
388
+ raise RequestTimeoutError(f"No response to {op_code.name}") from e
389
+
390
+ status, data_len, resp_request_id, crc = parse_response_header(header_data)
391
+
392
+ if data_len > 0:
393
+ try:
394
+ response_data = await asyncio.wait_for(
395
+ reader.readexactly(data_len),
396
+ timeout=self._timeout,
397
+ )
398
+ except asyncio.IncompleteReadError as e:
399
+ raise UnexpectedEofError(
400
+ "Connection closed while reading response data"
401
+ ) from e
402
+ except asyncio.TimeoutError as e:
403
+ raise RequestTimeoutError(
404
+ f"Response to {op_code.name} stalled mid-frame"
405
+ ) from e
406
+ else:
407
+ response_data = b""
408
+
409
+ computed_crc = compute_crc32(header_data, response_data)
410
+ if computed_crc != crc:
411
+ raise InvalidChecksumError(
412
+ f"CRC32 mismatch: 0x{computed_crc:08X} != 0x{crc:08X}"
413
+ )
414
+ if resp_request_id == 0:
415
+ # The server answers a request it could not parse with
416
+ # id 0 and then closes; surface its error.
417
+ raise_for_status(status, response_data)
418
+ if resp_request_id != request_id:
419
+ raise ProtocolError(
420
+ f"Response is for request {resp_request_id}, expected {request_id}"
421
+ )
422
+ except BaseException:
423
+ # The reply to an abandoned request (timed out, cancelled, cut
424
+ # off mid-frame) may still arrive and would be read as the
425
+ # answer to the next one, and after a corrupt or mismatched
426
+ # frame the stream position is unknown, so the connection is
427
+ # dropped; the caller reconnects.
428
+ writer.close()
429
+ # reconnect() swaps the connection outside the lock.
430
+ if self._writer is writer:
431
+ self._writer = None
432
+ self._reader = None
433
+ raise
297
434
 
298
435
  if self._debug:
299
436
  logger.debug(f"[flo] <- {status.name} {len(response_data)} bytes")
300
437
 
301
- # Verify CRC32
302
- from .wire import compute_crc32
303
-
304
- computed_crc = compute_crc32(header_data, response_data)
305
- if computed_crc != crc:
306
- from .exceptions import InvalidChecksumError
307
-
308
- raise InvalidChecksumError(f"CRC32 mismatch: 0x{computed_crc:08X} != 0x{crc:08X}")
309
-
310
438
  return RawResponse(status=status, data=response_data, request_id=resp_request_id)
311
439
 
312
440
  async def _send_and_check(
@@ -356,7 +484,8 @@ class FloClient:
356
484
  worker_id: str | None = None,
357
485
  concurrency: int = 10,
358
486
  action_timeout: float = 300.0,
359
- block_ms: int = 30000,
487
+ block_ms: int = _DEFAULT_WORKER_BLOCK_MS,
488
+ auto_touch: bool = False,
360
489
  ) -> "ActionWorker":
361
490
  """Create a new ActionWorker from this client.
362
491
 
@@ -367,7 +496,11 @@ class FloClient:
367
496
  worker_id: Unique worker identifier (auto-generated if not provided).
368
497
  concurrency: Maximum number of concurrent actions.
369
498
  action_timeout: Timeout for action handlers in seconds.
370
- block_ms: Timeout for blocking dequeue in milliseconds.
499
+ block_ms: Long-poll wait per await in milliseconds, at most 300000.
500
+ 0 means the 30000 default.
501
+ auto_touch: Touch each run every third of its action's timeout
502
+ while its handler runs. Off by default: a handler that runs
503
+ past the timeout without touching is taken back and run again.
371
504
 
372
505
  Returns:
373
506
  A new ActionWorker instance ready to register actions and start.
@@ -388,6 +521,7 @@ class FloClient:
388
521
  concurrency=concurrency,
389
522
  action_timeout=action_timeout,
390
523
  block_ms=block_ms,
524
+ auto_touch=auto_touch,
391
525
  )
392
526
 
393
527
  def new_stream_worker(
@@ -400,8 +534,10 @@ class FloClient:
400
534
  worker_id: str | None = None,
401
535
  concurrency: int = 10,
402
536
  batch_size: int = 10,
403
- block_ms: int = 30000,
537
+ block_ms: int = _DEFAULT_WORKER_BLOCK_MS,
404
538
  message_timeout: float = 300.0,
539
+ redeliver_pending_on_reconnect: bool = True,
540
+ claim_min_idle_ms: int = 0,
405
541
  ) -> "StreamWorker":
406
542
  """Create a new StreamWorker from this client.
407
543
 
@@ -417,8 +553,17 @@ class FloClient:
417
553
  worker_id: Unique worker identifier (auto-generated if not provided).
418
554
  concurrency: Maximum number of concurrent record handlers.
419
555
  batch_size: Number of records to fetch per poll.
420
- block_ms: Timeout for blocking read in milliseconds.
556
+ block_ms: Long-poll wait per read in milliseconds, at most 300000.
557
+ 0 means the 30000 default.
421
558
  message_timeout: Timeout for record handlers in seconds.
559
+ redeliver_pending_on_reconnect: Drain this consumer's pending
560
+ (delivered-but-unacked) entries via group_claim after a
561
+ reconnect, before resuming normal reads. Defaults to True
562
+ (required for at-least-once across reconnects). Set False for
563
+ at-most-once.
564
+ claim_min_idle_ms: Minimum idle time (ms) before the reconnect drain
565
+ claims an entry. 0 drains everything this consumer owns; > 0
566
+ also steals entries abandoned by dead consumers.
422
567
 
423
568
  Returns:
424
569
  A new StreamWorker instance ready to start.
@@ -446,4 +591,6 @@ class FloClient:
446
591
  batch_size=batch_size,
447
592
  block_ms=block_ms,
448
593
  message_timeout=message_timeout,
594
+ redeliver_pending_on_reconnect=redeliver_pending_on_reconnect,
595
+ claim_min_idle_ms=claim_min_idle_ms,
449
596
  )
flo/exceptions.py CHANGED
@@ -3,9 +3,11 @@
3
3
  Exception classes for Flo client errors.
4
4
  """
5
5
 
6
+ import asyncio
6
7
  import contextlib
8
+ from collections.abc import Callable
7
9
 
8
- from .types import StatusCode
10
+ from .types import TABLE_HASH, VERSION, Ran, Reason, StatusCode
9
11
 
10
12
 
11
13
  class FloError(Exception):
@@ -56,6 +58,12 @@ class UnexpectedEofError(FloError):
56
58
  pass
57
59
 
58
60
 
61
+ class RequestTimeoutError(FloError, asyncio.TimeoutError):
62
+ """The server did not answer a request in time; the connection was dropped."""
63
+
64
+ pass
65
+
66
+
59
67
  # =============================================================================
60
68
  # Protocol Errors
61
69
  # =============================================================================
@@ -73,10 +81,23 @@ class InvalidMagicError(ProtocolError):
73
81
  pass
74
82
 
75
83
 
76
- class UnsupportedVersionError(ProtocolError):
77
- """Unsupported protocol version."""
78
-
79
- pass
84
+ class TableMismatchError(ProtocolError):
85
+ """The server was built from another protocol version or op table, so
86
+ neither can read the other. Upgrade the SDK and the server together."""
87
+
88
+ def __init__(self, server_version: int, server_table: int) -> None:
89
+ self.server_version = server_version
90
+ self.server_table = server_table
91
+ if server_version != VERSION:
92
+ message = (
93
+ f"server protocol {server_version}, client protocol {VERSION}: upgrade the client"
94
+ )
95
+ else:
96
+ message = (
97
+ f"server table 0x{server_table:016x}, client table 0x{TABLE_HASH:016x}: "
98
+ "upgrade the client"
99
+ )
100
+ super().__init__(message)
80
101
 
81
102
 
82
103
  class InvalidChecksumError(ProtocolError):
@@ -85,12 +106,6 @@ class InvalidChecksumError(ProtocolError):
85
106
  pass
86
107
 
87
108
 
88
- class InvalidReservedFieldError(ProtocolError):
89
- """Reserved field contains non-zero value."""
90
-
91
- pass
92
-
93
-
94
109
  class PayloadTooLargeError(ProtocolError):
95
110
  """Payload exceeds maximum allowed size."""
96
111
 
@@ -132,6 +147,12 @@ class ValueTooLargeError(ValidationError):
132
147
  pass
133
148
 
134
149
 
150
+ class BlockTooLongError(ValidationError):
151
+ """A blocking wait (block_ms) exceeds 300000 ms (5 minutes)."""
152
+
153
+ pass
154
+
155
+
135
156
  # =============================================================================
136
157
  # Server Response Errors
137
158
  # =============================================================================
@@ -141,6 +162,10 @@ class ServerError(FloError):
141
162
  """Base class for server-returned errors."""
142
163
 
143
164
  status_code: StatusCode
165
+ # Why; Reason.UNCLASSIFIED when the server didn't say.
166
+ reason: Reason = Reason.UNCLASSIFIED
167
+ # Whether the request took effect; unknown unless the server said.
168
+ ran: Ran = Ran.UNKNOWN
144
169
 
145
170
  def __init__(self, message: str, status_code: StatusCode):
146
171
  super().__init__(message)
@@ -189,26 +214,42 @@ class RateLimitedError(ServerError):
189
214
  super().__init__(message, StatusCode.RATE_LIMITED)
190
215
 
191
216
 
217
+ class UnavailableError(ServerError):
218
+ """No leader, or the shard isn't taking writes or is offline. Retryable.
219
+
220
+ The message says why; an offline shard stays unavailable until an
221
+ operator acts, so whether and when to retry is the caller's call.
222
+ """
223
+
224
+ def __init__(self, message: str = StatusCode.UNAVAILABLE.message()):
225
+ super().__init__(message, StatusCode.UNAVAILABLE)
226
+
227
+
192
228
  class InternalServerError(ServerError):
193
- """Internal server error."""
229
+ """Internal server error. Not retryable: the server uses it for a write
230
+ that committed but wasn't applied, which must not be resent."""
194
231
 
195
232
  def __init__(self, message: str = "Internal server error"):
196
233
  super().__init__(message, StatusCode.INTERNAL_ERROR)
197
234
 
198
235
 
199
236
  class GenericServerError(ServerError):
200
- """Generic server error."""
237
+ """Generic server error, or a status with no dedicated error type."""
201
238
 
202
- def __init__(self, message: str = "Generic error"):
203
- super().__init__(message, StatusCode.ERROR_GENERIC)
239
+ def __init__(
240
+ self, message: str = "Generic error", status_code: StatusCode = StatusCode.ERROR_GENERIC
241
+ ):
242
+ super().__init__(message, status_code)
204
243
 
205
244
 
206
245
  def is_connection_error(exc: BaseException) -> bool:
207
246
  """Return True if the exception indicates a broken connection.
208
247
 
209
- Connection errors may be resolved by reconnecting.
248
+ Connection errors may be resolved by reconnecting. A timed-out request
249
+ counts: the client drops its connection rather than risk reading the late
250
+ reply as the next answer.
210
251
  """
211
- return isinstance(exc, (UnexpectedEofError, NotConnectedError))
252
+ return isinstance(exc, (UnexpectedEofError, NotConnectedError, RequestTimeoutError, OSError))
212
253
 
213
254
 
214
255
  def raise_for_status(status: StatusCode, data: bytes = b"") -> None:
@@ -224,22 +265,41 @@ def raise_for_status(status: StatusCode, data: bytes = b"") -> None:
224
265
  if status == StatusCode.OK:
225
266
  return
226
267
 
227
- # Try to decode error message from data
268
+ # A refusal's body: [reason:u16][ran:u8][message].
269
+ if len(data) < 3:
270
+ raise IncompleteResponseError(f"a refusal body of {len(data)} bytes")
271
+ try:
272
+ reason = Reason(int.from_bytes(data[0:2], "little"))
273
+ ran = Ran(data[2])
274
+ except ValueError as e:
275
+ raise IncompleteResponseError(f"a refusal with reason and ran {data[0:3].hex()}") from e
228
276
  message = status.message()
229
- if data:
277
+ if len(data) > 3:
230
278
  with contextlib.suppress(UnicodeDecodeError, ValueError):
231
- message = data.decode("utf-8")
279
+ message = data[3:].decode("utf-8")
280
+ error = _error_for(status, message)
281
+ error.reason = reason
282
+ error.ran = ran
283
+ raise error
284
+
285
+
286
+ def _error_for(status: StatusCode, message: str) -> ServerError:
232
287
 
233
- error_map = {
288
+ error_map: dict[StatusCode, Callable[[str], ServerError]] = {
234
289
  StatusCode.NOT_FOUND: NotFoundError,
235
290
  StatusCode.BAD_REQUEST: BadRequestError,
236
291
  StatusCode.CONFLICT: ConflictError,
237
292
  StatusCode.UNAUTHORIZED: UnauthorizedError,
238
293
  StatusCode.OVERLOADED: OverloadedError,
294
+ StatusCode.UNAVAILABLE: UnavailableError,
239
295
  StatusCode.RATE_LIMITED: RateLimitedError,
240
296
  StatusCode.INTERNAL_ERROR: InternalServerError,
241
297
  StatusCode.ERROR_GENERIC: GenericServerError,
242
298
  }
243
299
 
244
- error_class = error_map.get(status, GenericServerError)
245
- raise error_class(message)
300
+ error_class = error_map.get(status)
301
+ if error_class is not None:
302
+ return error_class(message)
303
+ if status.name not in StatusCode.__members__:
304
+ message = f"Unknown status {int(status)}: {message}"
305
+ return GenericServerError(message, status)