serverish 2.2.0__tar.gz → 2.3.0__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.
Files changed (65) hide show
  1. {serverish-2.2.0 → serverish-2.3.0}/PKG-INFO +5 -1
  2. {serverish-2.2.0 → serverish-2.3.0}/README.md +4 -0
  3. {serverish-2.2.0 → serverish-2.3.0}/pyproject.toml +2 -2
  4. {serverish-2.2.0 → serverish-2.3.0}/serverish/base/exceptions.py +52 -1
  5. serverish-2.3.0/serverish/connection/nats_errors.py +97 -0
  6. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/messenger.py +17 -5
  7. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_callback_sub.py +6 -1
  8. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_core_pub.py +14 -11
  9. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_core_read.py +8 -3
  10. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_kv.py +10 -8
  11. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_kv_read.py +7 -4
  12. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_kv_store.py +24 -16
  13. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_publisher.py +29 -14
  14. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_reader.py +32 -11
  15. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_rpc_req.py +15 -11
  16. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_rpc_resp.py +11 -6
  17. {serverish-2.2.0 → serverish-2.3.0}/LICENSE +0 -0
  18. {serverish-2.2.0 → serverish-2.3.0}/serverish/__init__.py +0 -0
  19. {serverish-2.2.0 → serverish-2.3.0}/serverish/base/__init__.py +0 -0
  20. {serverish-2.2.0 → serverish-2.3.0}/serverish/base/asyncio_util_functions.py +0 -0
  21. {serverish-2.2.0 → serverish-2.3.0}/serverish/base/collector.py +0 -0
  22. {serverish-2.2.0 → serverish-2.3.0}/serverish/base/datetime.py +0 -0
  23. {serverish-2.2.0 → serverish-2.3.0}/serverish/base/fifoset.py +0 -0
  24. {serverish-2.2.0 → serverish-2.3.0}/serverish/base/hasstatuses.py +0 -0
  25. {serverish-2.2.0 → serverish-2.3.0}/serverish/base/idmanger.py +0 -0
  26. {serverish-2.2.0 → serverish-2.3.0}/serverish/base/iterators.py +0 -0
  27. {serverish-2.2.0 → serverish-2.3.0}/serverish/base/manageable.py +0 -0
  28. {serverish-2.2.0 → serverish-2.3.0}/serverish/base/singleton.py +0 -0
  29. {serverish-2.2.0 → serverish-2.3.0}/serverish/base/status.py +0 -0
  30. {serverish-2.2.0 → serverish-2.3.0}/serverish/base/task_manager.py +0 -0
  31. {serverish-2.2.0 → serverish-2.3.0}/serverish/connection/__init__.py +0 -0
  32. {serverish-2.2.0 → serverish-2.3.0}/serverish/connection/connection.py +0 -0
  33. {serverish-2.2.0 → serverish-2.3.0}/serverish/connection/connection_jets.py +0 -0
  34. {serverish-2.2.0 → serverish-2.3.0}/serverish/connection/connection_nats.py +0 -0
  35. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/__init__.py +0 -0
  36. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/live_document.py +0 -0
  37. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/logging.py +0 -0
  38. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_cmd_pub.py +0 -0
  39. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_cmd_sub.py +0 -0
  40. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_core_sub.py +0 -0
  41. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_document_read.py +0 -0
  42. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_journal_pub.py +0 -0
  43. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_journal_read.py +0 -0
  44. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_kv_sub.py +0 -0
  45. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_progress_pub.py +0 -0
  46. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_progress_read.py +0 -0
  47. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_single_pub.py +0 -0
  48. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msg_single_read.py +0 -0
  49. {serverish-2.2.0 → serverish-2.3.0}/serverish/messenger/msgvalidator.py +0 -0
  50. {serverish-2.2.0 → serverish-2.3.0}/serverish/monitoring/__init__.py +0 -0
  51. {serverish-2.2.0 → serverish-2.3.0}/serverish/monitoring/bridge.py +0 -0
  52. {serverish-2.2.0 → serverish-2.3.0}/serverish/monitoring/factory.py +0 -0
  53. {serverish-2.2.0 → serverish-2.3.0}/serverish/monitoring/monitored.py +0 -0
  54. {serverish-2.2.0 → serverish-2.3.0}/serverish/monitoring/monitored_nats.py +0 -0
  55. {serverish-2.2.0 → serverish-2.3.0}/serverish/schema/config.schema.json +0 -0
  56. {serverish-2.2.0 → serverish-2.3.0}/serverish/schema/default.schema.json +0 -0
  57. {serverish-2.2.0 → serverish-2.3.0}/serverish/schema/include/timestamp.schema.json +0 -0
  58. {serverish-2.2.0 → serverish-2.3.0}/serverish/schema/journal.schema.json +0 -0
  59. {serverish-2.2.0 → serverish-2.3.0}/serverish/schema/meta.schema.json +0 -0
  60. {serverish-2.2.0 → serverish-2.3.0}/serverish/schema/program_current_state.schema.json +0 -0
  61. {serverish-2.2.0 → serverish-2.3.0}/serverish/schema/program_state.schema.json +0 -0
  62. {serverish-2.2.0 → serverish-2.3.0}/serverish/schema/progress.schema.json +0 -0
  63. {serverish-2.2.0 → serverish-2.3.0}/serverish/schema/telemetry.schema.json +0 -0
  64. {serverish-2.2.0 → serverish-2.3.0}/serverish/services/TODO.txt +0 -0
  65. {serverish-2.2.0 → serverish-2.3.0}/serverish/slog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: serverish
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: helpers for server alike projects
5
5
  License-File: LICENSE
6
6
  Author: Mikołaj Kałuszyński
@@ -50,6 +50,10 @@ this will install `nats-py` package.
50
50
  - Custom JetStream API replacing nats-py private internals access
51
51
 
52
52
  ## Changes
53
+ * 2.3 **Breaking:** exceptions of the NATS client library (`nats.errors.*`) no longer leak from the
54
+ Messenger API - every error is a `ServerishError` (see [Error handling](doc/concepts.md#error-handling)).
55
+ Replace `except nats.errors...` handlers around serverish calls. `raise_on_publish_error=False`
56
+ covers a missing connection too.
53
57
  * 1.7 Subscription reliability: reader auto-recovers from NATS disconnects and consumer expiry,
54
58
  RPC responder auto-resubscribes, `health_status` property on all drivers (reader, publisher,
55
59
  RPC responder, connection) with reconnect counts, error tracking, slow consumer detection.
@@ -28,6 +28,10 @@ this will install `nats-py` package.
28
28
  - Custom JetStream API replacing nats-py private internals access
29
29
 
30
30
  ## Changes
31
+ * 2.3 **Breaking:** exceptions of the NATS client library (`nats.errors.*`) no longer leak from the
32
+ Messenger API - every error is a `ServerishError` (see [Error handling](doc/concepts.md#error-handling)).
33
+ Replace `except nats.errors...` handlers around serverish calls. `raise_on_publish_error=False`
34
+ covers a missing connection too.
31
35
  * 1.7 Subscription reliability: reader auto-recovers from NATS disconnects and consumer expiry,
32
36
  RPC responder auto-resubscribes, `health_status` property on all drivers (reader, publisher,
33
37
  RPC responder, connection) with reconnect counts, error tracking, slow consumer detection.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "serverish"
3
- version = "2.2.0"
3
+ version = "2.3.0"
4
4
  description = "helpers for server alike projects"
5
5
  authors = ["Mikołaj Kałuszyński", "MMME team"]
6
6
  readme = "README.md"
@@ -24,7 +24,7 @@ pytest = ">=9.1,<10"
24
24
  pytest-asyncio = "^1.4"
25
25
  nats-py = "^2.6.0"
26
26
  jupyter = "*"
27
- rich = "^13.5.2"
27
+ rich = ">=13.5.2"
28
28
  aiodns = ">=3.1.1,<5"
29
29
  testcontainers = {extras = ["nats"], version = "^4.14.2"}
30
30
  pytest-timeout = "^2.4.0"
@@ -13,6 +13,57 @@ class MessengerCannotConnect(ServerishError):
13
13
  pass
14
14
 
15
15
  class MessengerNotConnected(ServerishError):
16
+ """Raised when an operation needs a NATS connection which is not there
17
+
18
+ Either the Messenger has not been opened, or the connection is gone
19
+ (see `MessengerConnectionClosed`).
20
+ """
21
+ pass
22
+
23
+
24
+ class MessengerTransportError(ServerishError):
25
+ """Raised when the underlying NATS client fails to carry out an operation
26
+
27
+ Common base of all errors translated from the NATS client library. The
28
+ original exception is always available as ``__cause__``; serverish users
29
+ are not expected to inspect it, nor to know the client library at all.
30
+ """
31
+ pass
32
+
33
+
34
+ class MessengerConnectionClosed(MessengerTransportError, MessengerNotConnected):
35
+ """Raised when the NATS connection is closed, draining, stale or has no server to talk to"""
36
+ pass
37
+
38
+
39
+ class MessengerOutboundBufferFull(MessengerTransportError):
40
+ """Raised when the client's outbound buffer overflows
41
+
42
+ Typically the connection is down (reconnecting) and messages published
43
+ in the meantime no longer fit the pending buffer.
44
+ """
45
+ pass
46
+
47
+
48
+ class MessengerJetStreamError(MessengerTransportError):
49
+ """Raised when the JetStream API refuses or fails a request
50
+
51
+ Attributes:
52
+ code (int | None): HTTP-like status of the response (e.g. 404, 503)
53
+ err_code (int | None): JetStream specific error code
54
+ description (str | None): server-side error description
55
+ """
56
+
57
+ def __init__(self, *args, code: int | None = None, err_code: int | None = None,
58
+ description: str | None = None) -> None:
59
+ super().__init__(*args)
60
+ self.code = code
61
+ self.err_code = err_code
62
+ self.description = description
63
+
64
+
65
+ class MessengerTransportTimeout(MessengerTransportError, TimeoutError):
66
+ """Raised when the NATS server did not answer in time"""
16
67
  pass
17
68
 
18
69
  class MessengerReaderStopped(ServerishError):
@@ -52,7 +103,7 @@ class MessengerRequestNoResultYet(MessengerRequestNoResponse):
52
103
  pass
53
104
 
54
105
 
55
- class MessengerPublishAckTimeout(ServerishError, TimeoutError):
106
+ class MessengerPublishAckTimeout(MessengerTransportTimeout):
56
107
  """Raised when JetStream did not confirm a publish within the ack timeout.
57
108
 
58
109
  The message MAY have been delivered and stored — only the acknowledgement
@@ -0,0 +1,97 @@
1
+ """Translation of NATS client library errors into serverish exceptions
2
+
3
+ The single place where ``nats.errors`` / ``nats.js.errors`` exceptions are
4
+ mapped onto the `ServerishError` hierarchy. The messenger API never lets a
5
+ raw client-library exception out: whoever catches `ServerishError` handles
6
+ every failure serverish can report, without knowing which client library
7
+ (or which version of it) works underneath.
8
+
9
+ Use `nats_errors_translated` around calls into the client library at the
10
+ public boundary of a driver. Code which reacts to specific client errors
11
+ internally keeps catching the raw exceptions inside that boundary.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ from typing import Iterator
17
+
18
+ import nats.errors
19
+ import nats.js.errors
20
+
21
+ from serverish.base.exceptions import (ServerishError, MessengerTransportError, MessengerConnectionClosed,
22
+ MessengerOutboundBufferFull, MessengerJetStreamError,
23
+ MessengerTransportTimeout)
24
+
25
+ _CONNECTION_GONE = (
26
+ nats.errors.ConnectionClosedError,
27
+ nats.errors.ConnectionDrainingError,
28
+ nats.errors.ConnectionReconnectingError,
29
+ nats.errors.StaleConnectionError,
30
+ nats.errors.UnexpectedEOF, # a StaleConnectionError subclass today - listed so the mapping does not depend on it
31
+ nats.errors.NoServersError,
32
+ )
33
+
34
+ # Status implied by the class of a JetStream API error; the client library
35
+ # does not always fill `code` in (e.g. errors it raises on its own)
36
+ _API_ERROR_CODES = (
37
+ (nats.js.errors.NotFoundError, 404),
38
+ (nats.js.errors.BadRequestError, 400),
39
+ (nats.js.errors.ServiceUnavailableError, 503),
40
+ (nats.js.errors.ServerError, 500),
41
+ )
42
+
43
+
44
+ def translate_nats_error(error: Exception, context: str) -> Exception:
45
+ """Returns the serverish counterpart of a NATS client library exception
46
+
47
+ Args:
48
+ error: exception to translate
49
+ context: what serverish was doing, the lead of the resulting message
50
+
51
+ Returns:
52
+ A `MessengerTransportError` (subclass) for a client library error -
53
+ raise it ``from error`` to keep the original as ``__cause__``.
54
+ Any other exception (serverish ones included) is returned unchanged.
55
+ """
56
+ if isinstance(error, ServerishError) or not isinstance(error, nats.errors.Error):
57
+ return error
58
+ message = f"{context}: {error}"
59
+ if isinstance(error, _CONNECTION_GONE):
60
+ return MessengerConnectionClosed(message)
61
+ if isinstance(error, nats.errors.OutboundBufferLimitError):
62
+ return MessengerOutboundBufferFull(message)
63
+ if isinstance(error, nats.js.errors.APIError):
64
+ return MessengerJetStreamError(message, code=jetstream_error_code(error), err_code=error.err_code,
65
+ description=error.description)
66
+ if isinstance(error, nats.errors.TimeoutError):
67
+ return MessengerTransportTimeout(message)
68
+ return MessengerTransportError(message)
69
+
70
+
71
+ @contextlib.contextmanager
72
+ def nats_errors_translated(context: str) -> Iterator[None]:
73
+ """Context manager translating NATS client library errors raised in its body
74
+
75
+ Args:
76
+ context: what serverish is doing, the lead of the resulting message
77
+
78
+ Raises:
79
+ MessengerTransportError: (subclass) instead of any ``nats.errors.Error``
80
+ """
81
+ try:
82
+ yield
83
+ except nats.errors.Error as e:
84
+ raise translate_nats_error(e, context) from e
85
+
86
+
87
+ def jetstream_error_code(error: Exception) -> int | None:
88
+ """Returns the HTTP-like status code of a JetStream API error, raw or translated"""
89
+ if isinstance(error, MessengerJetStreamError):
90
+ return error.code
91
+ if isinstance(error, nats.js.errors.APIError):
92
+ if error.code is not None:
93
+ return int(error.code)
94
+ for cls, code in _API_ERROR_CODES:
95
+ if isinstance(error, cls):
96
+ return code
97
+ return None
@@ -24,11 +24,12 @@ from nats.aio.msg import Msg
24
24
  from serverish.base import dt_utcnow_array, dt_from_array, Task, create_task, dt_ensure_array
25
25
  from serverish.base.collector import Collector
26
26
  from serverish.connection.connection_jets import ConnectionJetStream
27
- from serverish.base.idmanger import gen_id
27
+ from serverish.base.idmanger import gen_id, gen_uid
28
28
  from serverish.base.manageable import Manageable
29
29
  from serverish.messenger.msgvalidator import MsgValidator
30
30
  from serverish.base.singleton import Singleton
31
- from serverish.base import MessengerCannotConnect
31
+ from serverish.base import MessengerCannotConnect, MessengerNotConnected
32
+ from serverish.connection.nats_errors import nats_errors_translated
32
33
 
33
34
  if TYPE_CHECKING:
34
35
  from serverish.messenger.msg_publisher import MsgPublisher
@@ -51,12 +52,20 @@ class Messenger(Singleton):
51
52
  def __init__(self, name: str = None, parent: Collector = None, **kwargs) -> None:
52
53
  self.validator = MsgValidator()
53
54
  self.opener_task: Task | None = None
55
+ # Random per-process token, part of the Nats-Msg-Id dedup header.
56
+ # meta.id (gen_id) is only process-locally unique - two processes (or one
57
+ # process restarted within the stream's duplicate window) both emit
58
+ # 'msg-1', 'msg-2', ..., and JetStream would silently drop the later
59
+ # publishes as duplicates. The instance prefix makes the dedup identity
60
+ # global while retries still reuse the exact same header.
61
+ self.instance_id: str = gen_uid('mi', 12)
54
62
  super().__init__(name, parent, **kwargs)
55
63
 
56
64
  @property
57
65
  def connection(self) -> ConnectionJetStream:
58
66
  if self.conn is None:
59
- raise ValueError("Messenger connection have not been opened, use configure(host, port) first")
67
+ raise MessengerNotConnected("Messenger connection has not been opened, "
68
+ "use Messenger().open(host, port) or Messenger().context(host, port) first")
60
69
  return self.conn
61
70
 
62
71
  async def open(self, host: str | None = None, port: int | None = None,
@@ -312,8 +321,11 @@ class Messenger(Singleton):
312
321
 
313
322
  async def purge(self, subject: str) -> None:
314
323
  js = self.connection.js
315
- stream = await js.find_stream_name_by_subject(subject)
316
- await js.purge_stream(stream, subject=subject)
324
+ if js is None:
325
+ raise MessengerNotConnected(f"Cannot purge subject '{subject}', JetStream not connected")
326
+ with nats_errors_translated(f"Purging subject '{subject}' failed"):
327
+ stream = await js.find_stream_name_by_subject(subject)
328
+ await js.purge_stream(stream, subject=subject)
317
329
 
318
330
  @staticmethod
319
331
  def get_publisher(subject: str) -> "MsgPublisher":
@@ -21,7 +21,12 @@ class MsgCallbackSubscriber(MsgReader):
21
21
  """
22
22
  callback = param.Callable(default=None, doc="Callback function to call on each message")
23
23
  task = param.ClassSelector(default=None, class_=Task, doc="Task for reading messages")
24
- _stop_event = param.ClassSelector(default=Event(), class_=Event, doc="Event to stop reading messages")
24
+
25
+ def __init__(self, *args, **kwargs) -> None:
26
+ # per instance: a class-level default would be one Event shared by all subscribers,
27
+ # stopping one would stop them all
28
+ self._stop_event: Event = Event()
29
+ super().__init__(*args, **kwargs)
25
30
 
26
31
  async def open(self) -> None:
27
32
  return await super().open()
@@ -5,6 +5,7 @@ import jsonschema
5
5
  import param
6
6
 
7
7
  from serverish.base import MessengerNotConnected
8
+ from serverish.connection.nats_errors import translate_nats_error
8
9
  from serverish.messenger.messenger import MsgDriver, log
9
10
 
10
11
 
@@ -15,7 +16,8 @@ class MsgCorePub(MsgDriver):
15
16
  on subjects that are not backed by a JetStream stream.
16
17
 
17
18
  Parameters:
18
- raise_on_publish_error (bool): Raise on publish error, default ``True`` re-raises underlying exceptions
19
+ raise_on_publish_error (bool): Raise on publish error (default ``True``). When ``False``, errors
20
+ (a missing connection included) are logged and reported in the returned message instead
19
21
  """
20
22
 
21
23
  raise_on_publish_error = param.Boolean(default=True, doc="Raise on publish error")
@@ -32,7 +34,11 @@ class MsgCorePub(MsgDriver):
32
34
 
33
35
  Raises:
34
36
  MessengerNotConnected: if the NATS connection is not available
35
- jsonschema.ValidationError: if the message fails schema validation
37
+ MessengerTransportError: any other failure of the underlying NATS client
38
+ jsonschema.ValidationError: if the message fails schema validation (always raised)
39
+
40
+ With ``raise_on_publish_error`` set to False the serverish errors above are not raised:
41
+ the error is logged and the message is returned with the ``error`` tag.
36
42
  """
37
43
  from serverish.messenger import Messenger
38
44
  messenger: Messenger = self.messenger
@@ -51,22 +57,19 @@ class MsgCorePub(MsgDriver):
51
57
  f"Trying to publish to subject '{self.subject}' failed. NATS not connected"
52
58
  )
53
59
  await nc.publish(self.subject, bdata)
54
- except MessengerNotConnected:
55
- raise
56
- except AttributeError:
57
- raise MessengerNotConnected(
58
- f"Trying to publish to subject '{self.subject}' failed. NATS not connected"
59
- )
60
60
  except Exception as e:
61
+ error = translate_nats_error(e, f"Publishing message {msg['meta']['id']} to '{self.subject}' failed")
61
62
  log.error(
62
63
  f"Trying to publish to subject '{self.subject}' failed. "
63
- f"Message {msg['meta']['id']} publish error: {e}"
64
+ f"Message {msg['meta']['id']} publish error: {error}"
64
65
  )
65
66
  if self.raise_on_publish_error:
66
- raise e
67
+ if error is e:
68
+ raise e
69
+ raise error from e
67
70
  else:
68
71
  msg['meta']['tags'].append('error')
69
- msg['meta']['status'] = str(e)
72
+ msg['meta']['status'] = str(error)
70
73
  return msg
71
74
 
72
75
 
@@ -6,7 +6,8 @@ import logging
6
6
  from nats.aio.msg import Msg
7
7
  from nats.aio.subscription import Subscription
8
8
 
9
- from serverish.base.exceptions import MessengerReaderStopped
9
+ from serverish.base.exceptions import MessengerReaderStopped, MessengerNotConnected
10
+ from serverish.connection.nats_errors import nats_errors_translated
10
11
  from serverish.messenger.messenger import MsgDriver
11
12
 
12
13
  log = logging.getLogger(__name__.rsplit('.')[-1])
@@ -79,7 +80,8 @@ class MsgCoreReader(MsgDriver):
79
80
  self.connection.remove_reconnect_cb(self._reconnect_cb)
80
81
  self._reconnect_cb = None
81
82
  if self.subscription is not None:
82
- await self.subscription.unsubscribe()
83
+ with nats_errors_translated(f"Unsubscribing from '{self.subject}' failed"):
84
+ await self.subscription.unsubscribe()
83
85
  self.subscription = None
84
86
  return await super().close()
85
87
 
@@ -90,6 +92,8 @@ class MsgCoreReader(MsgDriver):
90
92
  async def _do_subscribe(self) -> None:
91
93
  """(Re-)create the raw NATS subscription, routing messages into the queue."""
92
94
  nc = self.connection.nc
95
+ if nc is None:
96
+ raise MessengerNotConnected(f"Cannot subscribe to '{self.subject}', NATS not connected")
93
97
 
94
98
  async def _cb(nats_msg: Msg) -> None:
95
99
  if self._queue is None:
@@ -101,7 +105,8 @@ class MsgCoreReader(MsgDriver):
101
105
  return
102
106
  await self._queue.put((data, meta))
103
107
 
104
- self.subscription = await nc.subscribe(self.subject, cb=_cb)
108
+ with nats_errors_translated(f"Subscribing to '{self.subject}' failed"):
109
+ self.subscription = await nc.subscribe(self.subject, cb=_cb)
105
110
 
106
111
  # ------------------------------------------------------------------
107
112
  # Reading
@@ -19,6 +19,7 @@ import param
19
19
  from nats.js.kv import KeyValue
20
20
 
21
21
  from serverish.base import MessengerKvBucketNotFound, MessengerKvMalformed, dt_ensure_array
22
+ from serverish.connection.nats_errors import nats_errors_translated
22
23
  from serverish.messenger.messenger import MsgDriver
23
24
 
24
25
  log = logging.getLogger(__name__.rsplit('.')[-1])
@@ -48,14 +49,15 @@ class MsgKvDriver(MsgDriver):
48
49
  self.subject = f'$KV.{self.bucket}' # diagnostic only, KV entries are addressed by bucket/key
49
50
 
50
51
  async def open(self) -> None:
51
- try:
52
- self.kv = await self.connection.ensure_kv_bucket(self.bucket,
53
- create_if_needed=self.create_bucket,
54
- **self.bucket_config)
55
- except nats.js.errors.BucketNotFoundError as e:
56
- raise MessengerKvBucketNotFound(
57
- f"KV bucket '{self.bucket}' does not exist, "
58
- f"create it externally or open the driver with create_bucket=True") from e
52
+ with nats_errors_translated(f"Opening KV bucket '{self.bucket}' failed"):
53
+ try:
54
+ self.kv = await self.connection.ensure_kv_bucket(self.bucket,
55
+ create_if_needed=self.create_bucket,
56
+ **self.bucket_config)
57
+ except nats.js.errors.BucketNotFoundError as e:
58
+ raise MessengerKvBucketNotFound(
59
+ f"KV bucket '{self.bucket}' does not exist, "
60
+ f"create it externally or open the driver with create_bucket=True") from e
59
61
  await super().open()
60
62
 
61
63
  async def close(self) -> None:
@@ -4,6 +4,7 @@ import param
4
4
  from nats.js.kv import KeyValue
5
5
 
6
6
  from serverish.base import MessengerReaderStopped
7
+ from serverish.connection.nats_errors import nats_errors_translated
7
8
  from serverish.messenger.messenger import Messenger
8
9
  from serverish.messenger.msg_kv import MsgKvDriver, log
9
10
 
@@ -34,9 +35,10 @@ class MsgKvReader(MsgKvDriver):
34
35
 
35
36
  async def open(self) -> None:
36
37
  await super().open()
37
- self._watcher = await self.kv.watch(self.key,
38
- include_history=self.include_history,
39
- ignore_deletes=self.ignore_deletes)
38
+ with nats_errors_translated(f"Watching KV {self.bucket}/{self.key} failed"):
39
+ self._watcher = await self.kv.watch(self.key,
40
+ include_history=self.include_history,
41
+ ignore_deletes=self.ignore_deletes)
40
42
 
41
43
  async def close(self) -> None:
42
44
  if self._watcher is not None:
@@ -54,7 +56,8 @@ class MsgKvReader(MsgKvDriver):
54
56
  if self._watcher is None:
55
57
  raise MessengerReaderStopped(f"KV reader {self} is not open")
56
58
  while True:
57
- entry = await self._watcher.__anext__() # raises StopAsyncIteration when watcher is stopped
59
+ with nats_errors_translated(f"Watching KV {self.bucket}/{self.key} failed"):
60
+ entry = await self._watcher.__anext__() # raises StopAsyncIteration when watcher is stopped
58
61
  if entry is None:
59
62
  # nats-py sends a None marker once the initial replay is done, it is not a value
60
63
  continue
@@ -4,6 +4,7 @@ import nats.js.errors
4
4
  from nats.js.kv import KeyValue
5
5
 
6
6
  from serverish.base import MessengerKvKeyNotFound
7
+ from serverish.connection.nats_errors import nats_errors_translated
7
8
  from serverish.messenger.messenger import Messenger, MsgDriver
8
9
  from serverish.messenger.msg_kv import MsgKvDriver, log
9
10
 
@@ -31,7 +32,8 @@ class MsgKvStore(MsgKvDriver):
31
32
  dict: published message, with meta['kv'] carrying bucket/key/revision
32
33
  """
33
34
  msg, bdata = self.encode_envelope(data, meta)
34
- revision = await self.kv.put(key, bdata)
35
+ with nats_errors_translated(f"KV PUT {self.bucket}/{key} failed"):
36
+ revision = await self.kv.put(key, bdata)
35
37
  msg['meta']['kv'] = {'bucket': self.bucket, 'key': key, 'revision': revision, 'operation': 'PUT'}
36
38
  self.messenger.log_msg_trace(msg.get('data', {}), msg['meta'], f"KV PUT {self.bucket}/{key}")
37
39
  return msg
@@ -48,10 +50,11 @@ class MsgKvStore(MsgKvDriver):
48
50
  MessengerKvKeyNotFound: key does not exist (or is deleted)
49
51
  MessengerKvMalformed: entry is not a serverish envelope
50
52
  """
51
- try:
52
- entry = await self.kv.get(key, revision=revision)
53
- except nats.js.errors.KeyNotFoundError as e:
54
- raise MessengerKvKeyNotFound(f"Key '{key}' not found in KV bucket '{self.bucket}'") from e
53
+ with nats_errors_translated(f"KV GET {self.bucket}/{key} failed"):
54
+ try:
55
+ entry = await self.kv.get(key, revision=revision)
56
+ except nats.js.errors.KeyNotFoundError as e:
57
+ raise MessengerKvKeyNotFound(f"Key '{key}' not found in KV bucket '{self.bucket}'") from e
55
58
  data, meta = self.decode_entry(entry)
56
59
  self.messenger.log_msg_trace(data, meta, f"KV GET {self.bucket}/{key}")
57
60
  return data, meta
@@ -59,22 +62,25 @@ class MsgKvStore(MsgKvDriver):
59
62
  @MsgDriver.ensure_open
60
63
  async def delete(self, key: str) -> None:
61
64
  """Places a delete marker for the key and removes previous revisions"""
62
- await self.kv.delete(key)
65
+ with nats_errors_translated(f"KV DEL {self.bucket}/{key} failed"):
66
+ await self.kv.delete(key)
63
67
  log.debug(f"KV DEL {self.bucket}/{key}")
64
68
 
65
69
  @MsgDriver.ensure_open
66
70
  async def purge(self, key: str) -> None:
67
71
  """Removes the key including all its revisions"""
68
- await self.kv.purge(key)
72
+ with nats_errors_translated(f"KV PURGE {self.bucket}/{key} failed"):
73
+ await self.kv.purge(key)
69
74
  log.debug(f"KV PURGE {self.bucket}/{key}")
70
75
 
71
76
  @MsgDriver.ensure_open
72
77
  async def keys(self) -> list[str]:
73
78
  """Returns list of keys in the bucket, empty list for an empty bucket"""
74
- try:
75
- return await self.kv.keys()
76
- except nats.js.errors.NoKeysError:
77
- return []
79
+ with nats_errors_translated(f"Listing keys of KV bucket '{self.bucket}' failed"):
80
+ try:
81
+ return await self.kv.keys()
82
+ except nats.js.errors.NoKeysError:
83
+ return []
78
84
 
79
85
  @MsgDriver.ensure_open
80
86
  async def history(self, key: str) -> list[tuple[dict | None, dict]]:
@@ -86,16 +92,18 @@ class MsgKvStore(MsgKvDriver):
86
92
  Raises:
87
93
  MessengerKvKeyNotFound: key has no history in the bucket
88
94
  """
89
- try:
90
- entries = await self.kv.history(key)
91
- except nats.js.errors.NoKeysError as e:
92
- raise MessengerKvKeyNotFound(f"Key '{key}' has no history in KV bucket '{self.bucket}'") from e
95
+ with nats_errors_translated(f"KV history of {self.bucket}/{key} failed"):
96
+ try:
97
+ entries = await self.kv.history(key)
98
+ except nats.js.errors.NoKeysError as e:
99
+ raise MessengerKvKeyNotFound(f"Key '{key}' has no history in KV bucket '{self.bucket}'") from e
93
100
  return [self.decode_entry(entry) for entry in entries]
94
101
 
95
102
  @MsgDriver.ensure_open
96
103
  async def status(self) -> KeyValue.BucketStatus:
97
104
  """Returns the status of the underlying KV bucket"""
98
- return await self.kv.status()
105
+ with nats_errors_translated(f"Status of KV bucket '{self.bucket}' failed"):
106
+ return await self.kv.status()
99
107
 
100
108
 
101
109
  def get_kvstore(bucket: str, **kwargs) -> MsgKvStore:
@@ -8,6 +8,7 @@ import nats.js
8
8
  import param
9
9
 
10
10
  from serverish.base import MessengerNotConnected, MessengerPublishAckTimeout
11
+ from serverish.connection.nats_errors import translate_nats_error
11
12
  from serverish.messenger import Messenger
12
13
  from serverish.messenger.messenger import MsgDriver, log
13
14
 
@@ -26,7 +27,8 @@ class MsgPublisher(MsgDriver):
26
27
  processed too late), the retry is discarded by the server.
27
28
 
28
29
  Parameters:
29
- raise_on_publish_error (bool): Raise on publish error, default `True` re-raises underlying exceptions
30
+ raise_on_publish_error (bool): Raise on publish error (default `True`). When `False`, errors
31
+ (a missing connection included) are logged and reported in the returned message instead
30
32
  ack_timeout_retries (int): How many times to retry a publish for which no JetStream
31
33
  ack arrived in time (default 2). Retries are deduplicated server-side via
32
34
  ``Nats-Msg-Id``. When retries are exhausted, `MessengerPublishAckTimeout` is
@@ -63,8 +65,14 @@ class MsgPublisher(MsgDriver):
63
65
  Returns:
64
66
  dict: published message, or message to be published with the `error` tag in meta if failed
65
67
  Raises:
66
- Raises nats errors if the message could not be published until `raise_on_publish_error` is set to True
67
- otherwise logs the error, and returns the message with the `error` tag and `status` set to the error message.
68
+ MessengerNotConnected: no connection to publish through
69
+ MessengerPublishAckTimeout: JetStream did not confirm the publish (after retries)
70
+ MessengerTransportError: any other failure of the underlying NATS client
71
+ jsonschema.ValidationError: the message does not match its schema (always raised)
72
+
73
+ With `raise_on_publish_error` set to False the serverish errors above are not raised:
74
+ the error is logged and the message is returned with the `error` tag and `status`
75
+ set to the error message.
68
76
  """
69
77
  msg = self.messenger.create_msg(data, meta)
70
78
  bdata = self.messenger.encode(msg)
@@ -75,17 +83,16 @@ class MsgPublisher(MsgDriver):
75
83
  raise e
76
84
  self.messenger.log_msg_trace(msg['data'], msg['meta'], f"PUB to {self.subject}")
77
85
  headers = dict(kwargs.pop('headers', None) or {})
78
- headers.setdefault('Nats-Msg-Id', msg['meta']['id']) # server-side dedup, makes ack-timeout retries safe
86
+ # Server-side dedup id: makes ack-timeout retries safe. Must be globally
87
+ # unique, hence the per-process instance prefix - meta.id alone is only
88
+ # process-locally unique and would collide across processes/restarts,
89
+ # silently dropping messages within the stream's duplicate window.
90
+ headers.setdefault('Nats-Msg-Id', f"{self.messenger.instance_id}:{msg['meta']['id']}")
79
91
  try:
80
92
  await self._publish_with_ack_retries(bdata, headers, msg['meta']['id'], **kwargs)
81
93
  # Track successful publish
82
94
  self._publish_count += 1
83
95
  self._last_publish_time = time.monotonic()
84
- except AttributeError as e: # no js - not connected
85
- self._error_count += 1
86
- self._last_error = e
87
- log.error(f"Trying to publish to subject '{self.subject}' failed. JetStream not connected")
88
- raise MessengerNotConnected(f"Trying to publish to subject '{self.subject}' failed. JetStream not connected")
89
96
  except (nats.errors.NoRespondersError, nats.js.errors.NoStreamResponseError):
90
97
  # it's OK for non-jetstream, we just don't have subscribers yet
91
98
  # Still count as successful publish (message was sent, just no stream to persist)
@@ -95,15 +102,18 @@ class MsgPublisher(MsgDriver):
95
102
  f'No subscribers yet returned by NATS server for subject {self.subject}, '
96
103
  f'if it was meant to be jetstream, the subject does not exist in any stream!')
97
104
  except Exception as e:
105
+ error = translate_nats_error(e, f"Publishing message {msg['meta']['id']} to '{self.subject}' failed")
98
106
  self._error_count += 1
99
- self._last_error = e
107
+ self._last_error = error
100
108
  log.error(f"Trying to publish to subject '{self.subject}' failed. "
101
- f"Message {msg['meta']['id']} publish error: {e}")
109
+ f"Message {msg['meta']['id']} publish error: {error}")
102
110
  if self.raise_on_publish_error:
103
- raise e
111
+ if error is e:
112
+ raise e
113
+ raise error from e
104
114
  else:
105
115
  msg['meta']['tags'].append('error')
106
- msg['meta']['status'] = str(e)
116
+ msg['meta']['status'] = str(error)
107
117
  return msg
108
118
 
109
119
  async def _publish_with_ack_retries(self, bdata: bytes, headers: dict, msg_id: str, **kwargs) -> None:
@@ -115,11 +125,16 @@ class MsgPublisher(MsgDriver):
115
125
  Nats-Msg-Id header, so this never produces duplicates.
116
126
 
117
127
  Raises:
128
+ MessengerNotConnected: JetStream is not connected
118
129
  MessengerPublishAckTimeout: no ack after all retries
119
130
  """
131
+ js = self.connection.js
132
+ if js is None:
133
+ raise MessengerNotConnected(f"Trying to publish to subject '{self.subject}' failed. "
134
+ f"JetStream not connected")
120
135
  for attempt in range(self.ack_timeout_retries + 1):
121
136
  try:
122
- await self.connection.js.publish(self.subject, bdata, headers=headers, **kwargs)
137
+ await js.publish(self.subject, bdata, headers=headers, **kwargs)
123
138
  return
124
139
  except nats.errors.TimeoutError as e:
125
140
  if attempt < self.ack_timeout_retries:
@@ -19,9 +19,10 @@ from nats.js.api import DeliverPolicy, ConsumerConfig
19
19
 
20
20
  from serverish.base import wait_for_psce
21
21
  from serverish.base.exceptions import (MessengerReaderStopped, MessengerReaderConfigError,
22
- MessengerNotConnected,
22
+ MessengerNotConnected, MessengerJetStreamError,
23
23
  MessengerReaderAlreadyOpen, MessengerRequestTimeout)
24
24
  from serverish.base.fifoset import FifoSet
25
+ from serverish.connection.nats_errors import nats_errors_translated, translate_nats_error
25
26
  from serverish.messenger import Messenger
26
27
  from serverish.messenger.messenger import MsgDriver
27
28
 
@@ -141,6 +142,11 @@ class MsgReader(MsgDriver):
141
142
  Only stop signal and Cancel exception finish the iteration.
142
143
  One may change this behavior by setting `on_connection_close` to 'RAISE' or 'FINISH'.
143
144
  This will cause raising exception or finishing iteration on seruios erros.
145
+
146
+ Raises:
147
+ MessengerReaderStopped: the reader has been stopped (or finished, see `nowait`)
148
+ MessengerReaderConfigError: the server rejected the reader configuration, retrying is pointless
149
+ MessengerTransportError: (`error_behavior='RAISE'` only) failure of the underlying NATS client
144
150
  """
145
151
 
146
152
  # The reader loop's state and methods
@@ -401,8 +407,10 @@ class MsgReader(MsgDriver):
401
407
  # Fatal NATS API errors (4xx): the same request will never
402
408
  # succeed — stop retrying immediately and surface a clear
403
409
  # MessengerReaderConfigError to the caller.
404
- if isinstance(e.error, (nats.js.errors.BadRequestError,
405
- nats.js.errors.NotFoundError)):
410
+ # (open() reports client errors translated, the original is its cause)
411
+ cause = e.error.__cause__ if isinstance(e.error, MessengerJetStreamError) else e.error
412
+ if isinstance(cause, (nats.js.errors.BadRequestError,
413
+ nats.js.errors.NotFoundError)):
406
414
  log.error(st.fmt(f"fatal NATS error (not retrying): {e.error}"))
407
415
  self._stop.set()
408
416
  raise MessengerReaderConfigError(
@@ -411,7 +419,10 @@ class MsgReader(MsgDriver):
411
419
  match self.error_behavior:
412
420
  case 'RAISE':
413
421
  log.error(st.fmt(f"raising read_next error: {e.error}"))
414
- raise e.error
422
+ error = translate_nats_error(e.error, f"Reading from '{self.subject}' failed")
423
+ if error is e.error:
424
+ raise e.error
425
+ raise error from e.error
415
426
  case 'FINISH':
416
427
  log.error(st.fmt(f"finishing iteration on error: {e.error}"))
417
428
  raise MessengerReaderStopped
@@ -622,6 +633,14 @@ class MsgReader(MsgDriver):
622
633
  ps._pending_next_msgs_calls.pop(task_name, None)
623
634
 
624
635
  async def open(self) -> None:
636
+ """Opens the reader: creates the JetStream consumer and subscribes to it
637
+
638
+ Raises:
639
+ MessengerReaderAlreadyOpen: the reader is open already
640
+ MessengerNotConnected: no connection to subscribe through
641
+ MessengerRequestTimeout: the server did not create the consumer in time
642
+ MessengerTransportError: any other failure of the underlying NATS client
643
+ """
625
644
  if self.pull_subscription is not None:
626
645
  raise MessengerReaderAlreadyOpen("Reader already open, do not reuse MsgReader instances")
627
646
 
@@ -652,13 +671,15 @@ class MsgReader(MsgDriver):
652
671
  # consumer_conf.durable_name = self.name if consumer_conf.durable_name is None else consumer_conf.durable_name
653
672
  if self.connection is None or self.connection.js is None:
654
673
  raise MessengerNotConnected('Cannot create pull subscription, not connected to NATS')
655
- try:
656
- ret = await self.connection.js.pull_subscribe(self.subject,
657
- durable=consumer_conf.durable_name,
658
- config=consumer_conf)
659
- except TimeoutError as e:
660
- raise MessengerRequestTimeout(e)
661
- ci = await ret.consumer_info()
674
+ with nats_errors_translated(f"Creating consumer for '{self.subject}' failed"):
675
+ try:
676
+ ret = await self.connection.js.pull_subscribe(self.subject,
677
+ durable=consumer_conf.durable_name,
678
+ config=consumer_conf)
679
+ except nats.errors.TimeoutError as e:
680
+ raise MessengerRequestTimeout(
681
+ f"Creating consumer for '{self.subject}' timed out: {e}") from e
682
+ ci = await ret.consumer_info()
662
683
  return ret
663
684
 
664
685
  async def _reopen(self) -> None:
@@ -3,10 +3,11 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  from asyncio import CancelledError
5
5
 
6
- from nats.errors import NoRespondersError, TimeoutError
6
+ import nats.errors
7
7
 
8
8
  from serverish.base import MessengerRequestNoResponders, MessengerRequestCanceled, MessengerRequestNoResultYet, \
9
- MessengerRequestTimeout, MessengerRequestJetStreamSubject
9
+ MessengerRequestTimeout, MessengerRequestJetStreamSubject, MessengerNotConnected
10
+ from serverish.connection.nats_errors import nats_errors_translated
10
11
  from serverish.base import Task, create_task
11
12
  from serverish.messenger import Messenger
12
13
  from serverish.messenger.messenger import MsgDriver
@@ -44,18 +45,21 @@ class MsgRpcRequester(MsgDriver):
44
45
  Raises:
45
46
  MessengerRequestNoResponders: if no responders were found for the request
46
47
  MessengerRequestTimeout: if no response was received in time
48
+ MessengerNotConnected: no connection to send the request through
49
+ MessengerTransportError: any other failure of the underlying NATS client
47
50
  """
48
- from nats.aio.client import Client as NATS
49
-
50
- nats: NATS = self.connection.nc
51
+ nc = self.connection.nc
52
+ if nc is None:
53
+ raise MessengerNotConnected(f"Request to '{self.subject}' failed. NATS not connected")
51
54
  msg = self.messenger.create_msg(data, meta=meta)
52
55
  bmsg = self.messenger.encode(msg)
53
- try:
54
- resp = await nats.request(self.subject, payload=bmsg, timeout=timeout)
55
- except NoRespondersError as e:
56
- raise MessengerRequestNoResponders(e)
57
- except TimeoutError as e:
58
- raise MessengerRequestTimeout(e)
56
+ with nats_errors_translated(f"Request to '{self.subject}' failed"):
57
+ try:
58
+ resp = await nc.request(self.subject, payload=bmsg, timeout=timeout)
59
+ except nats.errors.NoRespondersError as e:
60
+ raise MessengerRequestNoResponders(f"No responders for request to '{self.subject}'") from e
61
+ except nats.errors.TimeoutError as e:
62
+ raise MessengerRequestTimeout(f"No response to request to '{self.subject}' in time") from e
59
63
  rdata, rmeta = self.messenger.unpack_nats_msg(resp)
60
64
  if rmeta is None:
61
65
  raise MessengerRequestJetStreamSubject(self.subject)
@@ -8,6 +8,8 @@ from typing import Callable
8
8
  from nats.aio.msg import Msg
9
9
  from nats.aio.subscription import Subscription
10
10
 
11
+ from serverish.base import MessengerNotConnected
12
+ from serverish.connection.nats_errors import nats_errors_translated
11
13
  from serverish.messenger import Messenger
12
14
  from serverish.messenger.messenger import MsgDriver
13
15
 
@@ -44,7 +46,8 @@ class Rpc:
44
46
  messenger = Messenger() # this is singleton anyway
45
47
  msg = messenger.create_msg(data=self.resp_data, meta=self.resp_meta)
46
48
  bmsg = messenger.encode(msg)
47
- await self.nats_msg.respond(bmsg)
49
+ with nats_errors_translated(f"Responding to request on '{self.nats_msg.subject}' failed"):
50
+ await self.nats_msg.respond(bmsg)
48
51
 
49
52
 
50
53
  class MsgRpcResponder(MsgDriver):
@@ -120,9 +123,9 @@ class MsgRpcResponder(MsgDriver):
120
123
 
121
124
  async def _register_function_internal(self, callback: Callable[[Rpc], None] | Callable[[Rpc], asyncio.Future]):
122
125
  """Internal method to register the callback with NATS subscription"""
123
- from nats.aio.client import Client as NATS
124
-
125
- nats: NATS = self.connection.nc
126
+ nc = self.connection.nc
127
+ if nc is None:
128
+ raise MessengerNotConnected(f"Cannot subscribe RPC responder for '{self.subject}', NATS not connected")
126
129
  if asyncio.iscoroutinefunction(callback):
127
130
  acb = callback
128
131
  scb = None
@@ -152,13 +155,15 @@ class MsgRpcResponder(MsgDriver):
152
155
  await rpc.send_response()
153
156
 
154
157
 
155
- self.subscription = await nats.subscribe(self.subject, queue=self.subject, cb=_cb)
158
+ with nats_errors_translated(f"Subscribing RPC responder for '{self.subject}' failed"):
159
+ self.subscription = await nc.subscribe(self.subject, queue=self.subject, cb=_cb)
156
160
 
157
161
  async def close(self) -> None:
158
162
  """Close the responder and unregister from reconnection callbacks"""
159
163
  self.connection.remove_reconnect_cb(self.on_nats_reconnect)
160
164
  if self.subscription is not None:
161
- await self.subscription.unsubscribe()
165
+ with nats_errors_translated(f"Unsubscribing RPC responder for '{self.subject}' failed"):
166
+ await self.subscription.unsubscribe()
162
167
  self._callback = None # Clear callback reference
163
168
  return await super().close()
164
169
 
File without changes
File without changes