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/__init__.py +60 -15
- flo/actions.py +20 -27
- flo/client.py +193 -46
- flo/exceptions.py +83 -23
- flo/kv.py +373 -165
- flo/kv_body.py +554 -0
- flo/kv_txn.py +237 -0
- flo/processing.py +3 -67
- flo/queue.py +25 -101
- flo/retry.py +124 -0
- flo/streams.py +146 -31
- flo/types.py +431 -221
- flo/wire.py +263 -208
- flo/worker.py +684 -250
- {flo_python-0.1.0.dev4.dist-info → flo_python-0.1.2.dist-info}/METADATA +165 -98
- flo_python-0.1.2.dist-info/RECORD +19 -0
- {flo_python-0.1.0.dev4.dist-info → flo_python-0.1.2.dist-info}/WHEEL +1 -1
- flo_python-0.1.2.dist-info/licenses/LICENSE +202 -0
- flo_python-0.1.0.dev4.dist-info/RECORD +0 -16
- flo_python-0.1.0.dev4.dist-info/licenses/LICENSE +0 -21
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 .
|
|
19
|
-
from .
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
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(
|
|
295
|
-
|
|
296
|
-
|
|
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 =
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
|
77
|
-
"""
|
|
78
|
-
|
|
79
|
-
|
|
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__(
|
|
203
|
-
|
|
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
|
-
#
|
|
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
|
|
245
|
-
|
|
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)
|