Skip to content

Commit 5ab1145

Browse files
committed
ADD: Live client stream exception warning
1 parent f050f6b commit 5ab1145

5 files changed

Lines changed: 164 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44

55
#### Enhancements
66
- Added a property `Live.session_id` which returns the streaming session ID when the client is connected
7+
- Streams added with `Live.add_stream()` which do not define an exception handler will now emit a warning if an exception is raised while executing the callback
78
- Upgraded `databento-dbn` to 0.44.0
89
- Added logic to set `code` when upgrading version 1 `SystemMsg` to newer versions
910

11+
#### Bug fixes
12+
- Streams opened by `Live.add_stream()` will now close properly when the streaming session is closed
13+
1014
## 0.65.0 - 2025-11-11
1115

1216
#### Deprecations

databento/common/types.py

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import datetime as dt
2+
import logging
3+
import warnings
24
from collections.abc import Callable
3-
from typing import Generic
5+
from os import PathLike
6+
import pathlib
7+
from typing import IO, Generic
48
from typing import TypedDict
59
from typing import TypeVar
610

711
import databento_dbn
812
import pandas as pd
913

14+
from databento.common.error import BentoWarning
15+
16+
logger = logging.getLogger(__name__)
1017

1118
DBNRecord = (
1219
databento_dbn.BBOMsg
@@ -88,3 +95,121 @@ class MappingIntervalDict(TypedDict):
8895
start_date: dt.date
8996
end_date: dt.date
9097
symbol: str
98+
99+
100+
class ClientStream:
101+
def __init__(
102+
self,
103+
stream: IO[bytes] | PathLike[str] | str,
104+
exc_fn: ExceptionCallback | None = None,
105+
max_warnings: int = 10,
106+
) -> None:
107+
is_managed = False
108+
109+
if isinstance(stream, (str, PathLike)):
110+
stream = pathlib.Path(stream).open("xb")
111+
is_managed = True
112+
113+
if not hasattr(stream, "write"):
114+
raise ValueError(f"{type(stream).__name__} does not support write()")
115+
116+
if not hasattr(stream, "writable") or not stream.writable():
117+
raise ValueError(f"{type(stream).__name__} is not a writable stream")
118+
119+
if exc_fn is not None and not callable(exc_fn):
120+
raise ValueError(f"{exc_fn} is not callable")
121+
122+
self._stream = stream
123+
self._exc_fn = exc_fn
124+
self._max_warnings = max(0, max_warnings)
125+
self._warning_count = 0
126+
self._is_managed = is_managed
127+
128+
@property
129+
def stream_name(self) -> str:
130+
return getattr(self._stream, "__name__", str(self._stream))
131+
132+
@property
133+
def is_closed(self) -> bool:
134+
"""
135+
Return `True` if the underlying stream is closed.
136+
137+
Returns
138+
-------
139+
bool
140+
141+
"""
142+
return self._stream.closed
143+
144+
@property
145+
def is_managed(self) -> bool:
146+
"""
147+
Return `True` if the underlying stream was opened by the
148+
`ClientStream`. This can be used to determine if the stream should be
149+
closed automatically.
150+
151+
Returns
152+
-------
153+
bool
154+
155+
"""
156+
return self._is_managed
157+
158+
@property
159+
def exc_callback_name(self) -> str:
160+
return getattr(self._exc_fn, "__name__", str(self._exc_fn))
161+
162+
def close(self) -> None:
163+
"""
164+
Close the underlying stream.
165+
"""
166+
self._stream.close()
167+
168+
def flush(self) -> None:
169+
"""
170+
Flush the underlying stream.
171+
"""
172+
self._stream.flush()
173+
174+
def write(self, data: bytes) -> None:
175+
"""
176+
Write data to the underlying stream. Any exceptions encountered will be
177+
dispatched to the exception callback, if defined.
178+
179+
Parameters
180+
----------
181+
data : bytes
182+
183+
"""
184+
try:
185+
self._stream.write(data)
186+
except Exception as exc:
187+
if self._exc_fn is None:
188+
self._warn(
189+
f"stream '{self.stream_name}' encountered an exception without an exception handler: {repr(exc)}",
190+
)
191+
else:
192+
try:
193+
self._exc_fn(exc)
194+
except Exception as inner_exc:
195+
self._warn(
196+
f"exception callback '{self.exc_callback_name}' encountered an exception: {repr(inner_exc)}",
197+
)
198+
raise inner_exc from exc
199+
raise exc
200+
201+
def _warn(self, msg: str) -> None:
202+
logger.warning(msg)
203+
if self._warning_count < self._max_warnings:
204+
self._warning_count += 1
205+
warnings.warn(
206+
msg,
207+
BentoWarning,
208+
stacklevel=3,
209+
)
210+
if self._warning_count == self._max_warnings:
211+
warnings.warn(
212+
f"suppressing further warnings for '{self.stream_name}'",
213+
BentoWarning,
214+
stacklevel=3,
215+
)

databento/live/client.py

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import asyncio
44
import logging
55
import os
6-
import pathlib
76
import queue
87
import threading
98
from collections.abc import Iterable
@@ -24,7 +23,7 @@
2423
from databento.common.error import BentoError
2524
from databento.common.parsing import optional_datetime_to_unix_nanoseconds
2625
from databento.common.publishers import Dataset
27-
from databento.common.types import DBNRecord
26+
from databento.common.types import ClientStream, DBNRecord
2827
from databento.common.types import ExceptionCallback
2928
from databento.common.types import ReconnectCallback
3029
from databento.common.types import RecordCallback
@@ -307,7 +306,9 @@ def add_stream(
307306
The IO stream to write to when handling live records as they arrive.
308307
exception_callback : Callable[[Exception], None], optional
309308
An error handling callback to process exceptions that are raised
310-
when writing to the stream.
309+
when writing to the stream. If no exception callback is provided,
310+
any exceptions encountered will be logged and raised as warnings
311+
for visibility.
311312
312313
Raises
313314
------
@@ -322,23 +323,12 @@ def add_stream(
322323
Live.add_callback
323324
324325
"""
325-
if isinstance(stream, (str, PathLike)):
326-
stream = pathlib.Path(stream).open("xb")
326+
client_stream = ClientStream(stream=stream, exc_fn=exception_callback)
327327

328-
if not hasattr(stream, "write"):
329-
raise ValueError(f"{type(stream).__name__} does not support write()")
330-
331-
if not hasattr(stream, "writable") or not stream.writable():
332-
raise ValueError(f"{type(stream).__name__} is not a writable stream")
333-
334-
if exception_callback is not None and not callable(exception_callback):
335-
raise ValueError(f"{exception_callback} is not callable")
336-
337-
stream_name = getattr(stream, "name", str(stream))
338-
logger.info("adding user stream %s", stream_name)
328+
logger.info("adding user stream %s", client_stream.stream_name)
339329
if self.metadata is not None:
340-
stream.write(bytes(self.metadata))
341-
self._session._user_streams.append((stream, exception_callback))
330+
client_stream.write(self.metadata.encode())
331+
self._session._user_streams.append(client_stream)
342332

343333
def add_reconnect_callback(
344334
self,

databento/live/session.py

Lines changed: 25 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import threading
99
from collections.abc import Iterable
1010
from functools import partial
11-
from typing import IO
1211
from typing import Final
1312

1413
import databento_dbn
@@ -20,7 +19,7 @@
2019
from databento.common.enums import ReconnectPolicy
2120
from databento.common.error import BentoError
2221
from databento.common.publishers import Dataset
23-
from databento.common.types import DBNRecord
22+
from databento.common.types import ClientStream, DBNRecord
2423
from databento.common.types import ExceptionCallback
2524
from databento.common.types import ReconnectCallback
2625
from databento.common.types import RecordCallback
@@ -148,6 +147,12 @@ class SessionMetadata:
148147
def __bool__(self) -> bool:
149148
return self.data is not None
150149

150+
@property
151+
def has_ts_out(self) -> bool:
152+
if self.data is None:
153+
return False
154+
return self.data.ts_out
155+
151156
def check(self, other: databento_dbn.Metadata) -> None:
152157
"""
153158
Verify the Metadata is compatible with another Metadata message. This
@@ -191,7 +196,7 @@ def __init__(
191196
dataset: Dataset | str,
192197
dbn_queue: DBNQueue,
193198
user_callbacks: list[tuple[RecordCallback, ExceptionCallback | None]],
194-
user_streams: list[tuple[IO[bytes], ExceptionCallback | None]],
199+
user_streams: list[ClientStream],
195200
loop: asyncio.AbstractEventLoop,
196201
metadata: SessionMetadata,
197202
ts_out: bool = False,
@@ -210,21 +215,15 @@ def received_metadata(self, metadata: databento_dbn.Metadata) -> None:
210215
if self._metadata:
211216
self._metadata.check(metadata)
212217
else:
213-
metadata_bytes = metadata.encode()
214-
for stream, exc_callback in self._user_streams:
218+
for stream in self._user_streams:
215219
try:
216-
stream.write(metadata_bytes)
220+
stream.write(metadata.encode())
217221
except Exception as exc:
218-
stream_name = getattr(stream, "name", str(stream))
219222
logger.error(
220-
"error writing %d bytes to `%s` stream",
221-
len(metadata_bytes),
222-
stream_name,
223+
"error writing metadata to `%s` stream",
224+
stream.stream_name,
223225
exc_info=exc,
224226
)
225-
if exc_callback is not None:
226-
exc_callback(exc)
227-
228227
self._metadata.data = metadata
229228
return super().received_metadata(metadata)
230229

@@ -252,26 +251,20 @@ def _dispatch_callbacks(self, record: DBNRecord) -> None:
252251
exc_callback(exc)
253252

254253
def _dispatch_writes(self, record: DBNRecord) -> None:
255-
if hasattr(record, "ts_out"):
256-
ts_out_bytes = struct.pack("Q", record.ts_out)
257-
else:
258-
ts_out_bytes = b""
259-
260-
record_bytes = bytes(record) + ts_out_bytes
261-
262-
for stream, exc_callback in self._user_streams:
254+
record_bytes = bytes(record)
255+
ts_out_bytes = struct.pack("Q", record.ts_out) if self._metadata.has_ts_out else b""
256+
for stream in self._user_streams:
263257
try:
264258
stream.write(record_bytes)
259+
stream.write(ts_out_bytes)
265260
except Exception as exc:
266-
stream_name = getattr(stream, "name", str(stream))
267261
logger.error(
268-
"error writing %d bytes to `%s` stream",
269-
len(record_bytes),
270-
stream_name,
262+
"error writing %s record (%d bytes) to `%s` stream",
263+
type(record).__name__,
264+
len(record_bytes) + len(ts_out_bytes),
265+
stream.stream_name,
271266
exc_info=exc,
272267
)
273-
if exc_callback is not None:
274-
exc_callback(exc)
275268

276269
def _queue_for_iteration(self, record: DBNRecord) -> None:
277270
self._dbn_queue.put(record)
@@ -323,7 +316,7 @@ def __init__(
323316
self._metadata = SessionMetadata()
324317
self._user_gateway: str | None = user_gateway
325318
self._user_callbacks: list[tuple[RecordCallback, ExceptionCallback | None]] = []
326-
self._user_streams: list[tuple[IO[bytes], ExceptionCallback | None]] = []
319+
self._user_streams: list[ClientStream] = []
327320
self._user_reconnect_callbacks: list[tuple[ReconnectCallback, ExceptionCallback | None]] = (
328321
[]
329322
)
@@ -551,10 +544,11 @@ async def wait_for_close(self) -> None:
551544
def _cleanup(self) -> None:
552545
logger.debug("cleaning up session_id=%s", self.session_id)
553546
self._user_callbacks.clear()
554-
for item in self._user_streams:
555-
stream, _ = item
556-
if not stream.closed:
547+
for stream in self._user_streams:
548+
if not stream.is_closed:
557549
stream.flush()
550+
if stream.is_managed:
551+
stream.close()
558552

559553
self._user_callbacks.clear()
560554
self._user_streams.clear()

tests/test_live_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1008,7 +1008,7 @@ def test_live_add_stream(
10081008

10091009
# Assert
10101010
assert len(live_client._session._user_streams) == 1
1011-
assert (stream, None) in live_client._session._user_streams
1011+
assert stream == live_client._session._user_streams[0]._stream
10121012

10131013

10141014
def test_live_add_stream_invalid(

0 commit comments

Comments
 (0)