datacurrent 1.0.0__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.
Files changed (43) hide show
  1. datacurrent/__init__.py +157 -0
  2. datacurrent/_version.py +6 -0
  3. datacurrent/buffering/__init__.py +8 -0
  4. datacurrent/buffering/backpressure.py +47 -0
  5. datacurrent/buffering/queue.py +304 -0
  6. datacurrent/concurrency/__init__.py +8 -0
  7. datacurrent/concurrency/limits.py +106 -0
  8. datacurrent/concurrency/tasks.py +86 -0
  9. datacurrent/config.py +86 -0
  10. datacurrent/core/__init__.py +17 -0
  11. datacurrent/core/_util.py +42 -0
  12. datacurrent/core/lifecycle.py +81 -0
  13. datacurrent/core/operators.py +844 -0
  14. datacurrent/core/pipeline.py +64 -0
  15. datacurrent/core/sink.py +70 -0
  16. datacurrent/core/stream.py +566 -0
  17. datacurrent/events/__init__.py +9 -0
  18. datacurrent/events/bus.py +380 -0
  19. datacurrent/events/routing.py +59 -0
  20. datacurrent/events/subscriptions.py +75 -0
  21. datacurrent/exceptions.py +191 -0
  22. datacurrent/integrations/__init__.py +18 -0
  23. datacurrent/integrations/fastapi.py +76 -0
  24. datacurrent/integrations/starlette.py +75 -0
  25. datacurrent/observability/__init__.py +17 -0
  26. datacurrent/observability/logging.py +128 -0
  27. datacurrent/observability/metrics.py +127 -0
  28. datacurrent/py.typed +1 -0
  29. datacurrent/reliability/__init__.py +14 -0
  30. datacurrent/reliability/retry.py +134 -0
  31. datacurrent/reliability/timeout.py +39 -0
  32. datacurrent/transports/__init__.py +31 -0
  33. datacurrent/transports/_deps.py +23 -0
  34. datacurrent/transports/base.py +80 -0
  35. datacurrent/transports/http.py +211 -0
  36. datacurrent/transports/memory.py +171 -0
  37. datacurrent/transports/sse.py +374 -0
  38. datacurrent/transports/websocket.py +277 -0
  39. datacurrent-1.0.0.dist-info/METADATA +250 -0
  40. datacurrent-1.0.0.dist-info/RECORD +43 -0
  41. datacurrent-1.0.0.dist-info/WHEEL +5 -0
  42. datacurrent-1.0.0.dist-info/licenses/LICENSE +20 -0
  43. datacurrent-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,191 @@
1
+ """Exception hierarchy used across DataCurrent.
2
+
3
+ Every exception raised deliberately by the library derives from
4
+ :class:`DataCurrentError`. Applications can therefore catch one base class
5
+ without accidentally swallowing programming errors such as ``TypeError`` or
6
+ control-flow exceptions such as ``asyncio.CancelledError``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ __all__ = [
14
+ "BufferOverflowError",
15
+ "ConfigurationError",
16
+ "ConnectionFailedError",
17
+ "DataCurrentError",
18
+ "EmptyStreamError",
19
+ "EventBusError",
20
+ "MessageTooLargeError",
21
+ "NonReplayableStreamError",
22
+ "OptionalDependencyError",
23
+ "QueueClosedError",
24
+ "QueueEmptyError",
25
+ "RetryError",
26
+ "SSEProtocolError",
27
+ "StreamClosedError",
28
+ "StreamConsumedError",
29
+ "StreamError",
30
+ "StreamProtocolError",
31
+ "StreamTimeoutError",
32
+ "SubscriptionClosedError",
33
+ "TransportError",
34
+ "WebSocketClosedError",
35
+ ]
36
+
37
+
38
+ class DataCurrentError(Exception):
39
+ """Base class for every error raised by DataCurrent."""
40
+
41
+
42
+ class ConfigurationError(DataCurrentError, ValueError):
43
+ """The caller supplied an invalid configuration value."""
44
+
45
+
46
+ class OptionalDependencyError(DataCurrentError, ImportError):
47
+ """An optional integration was used without its dependency installed."""
48
+
49
+ def __init__(
50
+ self,
51
+ package: str,
52
+ *,
53
+ extra: str | None = None,
54
+ purpose: str = "This feature",
55
+ ) -> None:
56
+ self.package = package
57
+ self.extra = extra
58
+ hint = f'pip install "datacurrent[{extra}]"' if extra else f"pip install {package}"
59
+ super().__init__(
60
+ f"{purpose} requires the optional dependency {package!r}. Install it with: {hint}"
61
+ )
62
+
63
+
64
+ # --------------------------------------------------------------------------- #
65
+ # Streams
66
+ # --------------------------------------------------------------------------- #
67
+ class StreamError(DataCurrentError):
68
+ """Base class for errors related to stream consumption."""
69
+
70
+
71
+ class StreamConsumedError(StreamError):
72
+ """A single-consumer stream was iterated more than once."""
73
+
74
+
75
+ class NonReplayableStreamError(StreamError):
76
+ """An operation required restarting a stream that cannot be replayed."""
77
+
78
+
79
+ class EmptyStreamError(StreamError):
80
+ """A terminal operation required at least one item but the stream was empty."""
81
+
82
+
83
+ class StreamTimeoutError(StreamError, TimeoutError):
84
+ """No item arrived within the configured timeout."""
85
+
86
+
87
+ class StreamClosedError(StreamError):
88
+ """The stream or its underlying queue has been closed."""
89
+
90
+
91
+ # --------------------------------------------------------------------------- #
92
+ # Buffering
93
+ # --------------------------------------------------------------------------- #
94
+ class BufferOverflowError(StreamError, BufferError):
95
+ """A bounded buffer was full and the configured policy is ``error``.
96
+
97
+ Attributes:
98
+ maxsize: Capacity of the buffer that overflowed.
99
+ policy: Overflow policy that produced the error.
100
+ dropped: Number of items dropped in total by that buffer.
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ maxsize: int,
106
+ *,
107
+ policy: Any = "error",
108
+ dropped: int = 0,
109
+ ) -> None:
110
+ self.maxsize = maxsize
111
+ self.policy = getattr(policy, "value", policy)
112
+ self.dropped = dropped
113
+ super().__init__(
114
+ f"buffer is full (maxsize={maxsize}, policy={self.policy!r}, "
115
+ f"dropped={dropped}); slow down the producer or enlarge the buffer"
116
+ )
117
+
118
+
119
+ class QueueClosedError(DataCurrentError):
120
+ """An operation was attempted on a closed queue."""
121
+
122
+
123
+ class QueueEmptyError(DataCurrentError):
124
+ """A non-blocking read was attempted on an empty queue."""
125
+
126
+
127
+ # --------------------------------------------------------------------------- #
128
+ # Transports
129
+ # --------------------------------------------------------------------------- #
130
+ class TransportError(DataCurrentError):
131
+ """Base class for networking and transport failures."""
132
+
133
+
134
+ class ConnectionFailedError(TransportError):
135
+ """A connection could not be established or was lost."""
136
+
137
+
138
+ class StreamProtocolError(TransportError):
139
+ """The peer sent data that violates the expected protocol."""
140
+
141
+
142
+ class SSEProtocolError(StreamProtocolError):
143
+ """A Server-Sent Events response was malformed or had the wrong content type."""
144
+
145
+
146
+ class MessageTooLargeError(TransportError):
147
+ """A received message exceeded the configured limit."""
148
+
149
+ def __init__(self, size: int, limit: int) -> None:
150
+ self.size = size
151
+ self.limit = limit
152
+ super().__init__(f"message of {size} bytes exceeds the limit of {limit} bytes")
153
+
154
+
155
+ class WebSocketClosedError(TransportError):
156
+ """The WebSocket connection was closed before reading finished."""
157
+
158
+ def __init__(self, code: int | None = None, reason: str | None = None) -> None:
159
+ self.code = code
160
+ self.reason = reason
161
+ detail = []
162
+ if code is not None:
163
+ detail.append(f"code={code}")
164
+ if reason:
165
+ detail.append(f"reason={reason}")
166
+ suffix = f" ({', '.join(detail)})" if detail else ""
167
+ super().__init__(f"websocket connection closed{suffix}")
168
+
169
+
170
+ # --------------------------------------------------------------------------- #
171
+ # Reliability
172
+ # --------------------------------------------------------------------------- #
173
+ class RetryError(DataCurrentError):
174
+ """All retry attempts were exhausted."""
175
+
176
+ def __init__(self, attempts: int, last_error: BaseException | None = None) -> None:
177
+ self.attempts = attempts
178
+ self.last_error = last_error
179
+ suffix = f": {last_error!r}" if last_error is not None else ""
180
+ super().__init__(f"giving up after {attempts} attempt(s){suffix}")
181
+
182
+
183
+ # --------------------------------------------------------------------------- #
184
+ # Events
185
+ # --------------------------------------------------------------------------- #
186
+ class EventBusError(DataCurrentError):
187
+ """Base class for event bus failures."""
188
+
189
+
190
+ class SubscriptionClosedError(EventBusError):
191
+ """The subscription is no longer able to receive events."""
@@ -0,0 +1,18 @@
1
+ """Framework integrations.
2
+
3
+ The helpers here import FastAPI/Starlette lazily, so ``import datacurrent`` and
4
+ this package work in applications that do not use a web framework.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .fastapi import sse_response, websocket_messages, websocket_stream
10
+ from .starlette import StreamLifespan, streaming_response
11
+
12
+ __all__ = [
13
+ "StreamLifespan",
14
+ "sse_response",
15
+ "streaming_response",
16
+ "websocket_messages",
17
+ "websocket_stream",
18
+ ]
@@ -0,0 +1,76 @@
1
+ """FastAPI helpers: SSE responses and WebSocket message streams.
2
+
3
+ FastAPI is optional and imported lazily inside the functions.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import AsyncIterable, AsyncIterator, Mapping
9
+ from typing import Any
10
+
11
+ from ..core._util import aclose
12
+ from ..core.stream import Stream
13
+ from ..transports.sse import SSEEvent, format_sse
14
+
15
+ __all__ = ["sse_response", "websocket_messages", "websocket_stream"]
16
+
17
+
18
+ def sse_response(
19
+ events: AsyncIterable[SSEEvent] | Stream[SSEEvent],
20
+ *,
21
+ headers: Mapping[str, str] | None = None,
22
+ status_code: int = 200,
23
+ ) -> Any:
24
+ """Return a ``StreamingResponse`` that encodes events as ``text/event-stream``."""
25
+ from starlette.responses import StreamingResponse
26
+
27
+ default_headers = {
28
+ "Cache-Control": "no-cache",
29
+ "Connection": "keep-alive",
30
+ "X-Accel-Buffering": "no",
31
+ }
32
+ merged = {**default_headers, **dict(headers or {})}
33
+
34
+ async def body() -> AsyncIterator[bytes]:
35
+ source: AsyncIterable[SSEEvent] = events
36
+ iterator = source.__aiter__()
37
+ try:
38
+ async for event in iterator:
39
+ yield format_sse(event).encode("utf-8")
40
+ finally:
41
+ await aclose(iterator)
42
+
43
+ return StreamingResponse(
44
+ body(),
45
+ media_type="text/event-stream",
46
+ headers=merged,
47
+ status_code=status_code,
48
+ )
49
+
50
+
51
+ async def websocket_messages(websocket: Any) -> AsyncIterator[Any]:
52
+ """Yield text and binary messages from a Starlette/FastAPI WebSocket."""
53
+ while True:
54
+ message = await websocket.receive()
55
+ message_type = message.get("type")
56
+ if message_type == "websocket.disconnect":
57
+ return
58
+ if "text" in message and message["text"] is not None:
59
+ yield message["text"]
60
+ elif "bytes" in message and message["bytes"] is not None:
61
+ yield message["bytes"]
62
+
63
+
64
+ def websocket_stream(websocket: Any, *, config: Any = None) -> Stream[Any]:
65
+ """Expose an incoming WebSocket as a single-consumer stream."""
66
+
67
+ async def factory() -> AsyncIterator[Any]:
68
+ try:
69
+ async for message in websocket_messages(websocket):
70
+ yield message
71
+ finally:
72
+ closer = getattr(websocket, "close", None)
73
+ if closer is not None:
74
+ await aclose(websocket)
75
+
76
+ return Stream(factory, config=config, replayable=False)
@@ -0,0 +1,75 @@
1
+ """Starlette helpers (also used by the FastAPI integration).
2
+
3
+ Starlette is optional: importing this module never imports Starlette itself.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import AsyncIterable, Mapping
9
+ from typing import Any
10
+
11
+ from ..core._util import aclose
12
+ from ..core.stream import Stream
13
+
14
+ __all__ = ["StreamLifespan", "streaming_response"]
15
+
16
+
17
+ def streaming_response(
18
+ stream: Stream[Any] | AsyncIterable[Any],
19
+ *,
20
+ media_type: str = "text/plain; charset=utf-8",
21
+ headers: Mapping[str, str] | None = None,
22
+ status_code: int = 200,
23
+ ) -> Any:
24
+ """Return a Starlette ``StreamingResponse`` backed by a stream.
25
+
26
+ Bytes are forwarded untouched and strings are encoded as UTF-8. The stream
27
+ is closed when the response finishes or the client disconnects.
28
+ """
29
+ from starlette.responses import StreamingResponse
30
+
31
+ source = stream.__aiter__() if isinstance(stream, Stream) else stream
32
+
33
+ async def body() -> Any:
34
+ iterator = source.__aiter__()
35
+ try:
36
+ async for chunk in iterator:
37
+ yield chunk.encode("utf-8") if isinstance(chunk, str) else chunk
38
+ finally:
39
+ await aclose(iterator)
40
+
41
+ return StreamingResponse(
42
+ body(),
43
+ media_type=media_type,
44
+ headers=dict(headers or {}),
45
+ status_code=status_code,
46
+ )
47
+
48
+
49
+ class StreamLifespan:
50
+ """Run async closables during application startup/shutdown.
51
+
52
+ Example::
53
+
54
+ lifespan = StreamLifespan(bus, channel)
55
+
56
+ app = FastAPI(lifespan=lifespan.as_context_manager())
57
+ """
58
+
59
+ def __init__(self, *resources: Any) -> None:
60
+ self._resources = list(resources)
61
+
62
+ async def __aenter__(self) -> StreamLifespan:
63
+ return self
64
+
65
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
66
+ await self.aclose()
67
+
68
+ async def aclose(self) -> None:
69
+ resources, self._resources = self._resources, []
70
+ for resource in reversed(resources):
71
+ await aclose(resource)
72
+
73
+ def as_context_manager(self) -> Any:
74
+ """Return the lifespan as an async context manager for ASGI frameworks."""
75
+ return self
@@ -0,0 +1,17 @@
1
+ """Observability: metrics, statistics and safe logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .logging import get_logger, redact_headers, redact_secret, redact_url
6
+ from .metrics import Counter, Metrics, StreamStats, snapshot_stats
7
+
8
+ __all__ = [
9
+ "Counter",
10
+ "Metrics",
11
+ "StreamStats",
12
+ "get_logger",
13
+ "redact_headers",
14
+ "redact_secret",
15
+ "redact_url",
16
+ "snapshot_stats",
17
+ ]
@@ -0,0 +1,128 @@
1
+ """Logging helpers.
2
+
3
+ DataCurrent attaches a :class:`logging.NullHandler` so importing the library
4
+ never changes application logging. URLs and headers are redacted before they
5
+ are handed to the logging module: tokens in query strings, user info in URLs,
6
+ ``Authorization`` and cookie headers never reach log records.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from collections.abc import Mapping
13
+ from typing import Any
14
+ from urllib.parse import parse_qsl, urlsplit
15
+
16
+ __all__ = [
17
+ "LOGGER_NAME",
18
+ "get_logger",
19
+ "redact_headers",
20
+ "redact_secret",
21
+ "redact_url",
22
+ ]
23
+
24
+ LOGGER_NAME = "datacurrent"
25
+
26
+ _SENSITIVE_FRAGMENTS = (
27
+ "token",
28
+ "secret",
29
+ "password",
30
+ "passwd",
31
+ "api_key",
32
+ "apikey",
33
+ "access_key",
34
+ "private_key",
35
+ "credential",
36
+ "signature",
37
+ "session",
38
+ "authorization",
39
+ "auth",
40
+ "code",
41
+ "key",
42
+ )
43
+ _SENSITIVE_HEADERS = {
44
+ "authorization",
45
+ "proxy-authorization",
46
+ "cookie",
47
+ "set-cookie",
48
+ "x-api-key",
49
+ "x-auth-token",
50
+ "api-key",
51
+ "sec-websocket-key",
52
+ "sec-websocket-protocol",
53
+ "x-amz-security-token",
54
+ }
55
+
56
+ _root_logger = logging.getLogger(LOGGER_NAME)
57
+ if not any(isinstance(handler, logging.NullHandler) for handler in _root_logger.handlers):
58
+ _root_logger.addHandler(logging.NullHandler())
59
+
60
+
61
+ def get_logger(name: str | None = None) -> logging.Logger:
62
+ """Return a namespaced logger. Never configure the logging system here."""
63
+ if not name:
64
+ return _root_logger
65
+ if name.startswith(LOGGER_NAME):
66
+ return logging.getLogger(name)
67
+ return logging.getLogger(f"{LOGGER_NAME}.{name}")
68
+
69
+
70
+ def _is_sensitive(name: str) -> bool:
71
+ lowered = name.lower()
72
+ return any(fragment in lowered for fragment in _SENSITIVE_FRAGMENTS)
73
+
74
+
75
+ def redact_url(url: str) -> str:
76
+ """Return ``url`` with user info and sensitive query values replaced by ``***``.
77
+
78
+ The structure (scheme, host, port, path, non-sensitive parameters) is kept so
79
+ the value is still useful in logs.
80
+ """
81
+ if not isinstance(url, str): # defensive: never log an object's repr
82
+ return "<redacted-url>"
83
+ try:
84
+ parts = urlsplit(url)
85
+ except ValueError:
86
+ return "<invalid-url>"
87
+
88
+ netloc = parts.netloc
89
+ if "@" in netloc:
90
+ host = netloc.rsplit("@", 1)[1]
91
+ netloc = f"***@{host}"
92
+
93
+ query = parts.query
94
+ if query:
95
+ rendered: list[str] = []
96
+ try:
97
+ pairs = parse_qsl(query, keep_blank_values=True)
98
+ except ValueError:
99
+ pairs = []
100
+ if pairs:
101
+ for key, value in pairs:
102
+ rendered.append(f"{key}=***" if _is_sensitive(key) else f"{key}={value}")
103
+ query = "&".join(rendered)
104
+ else:
105
+ query = "***"
106
+
107
+ return parts._replace(netloc=netloc, query=query).geturl()
108
+
109
+
110
+ def redact_headers(headers: Mapping[str, Any] | None) -> dict[str, str]:
111
+ """Return a copy of ``headers`` with sensitive values replaced by ``***``."""
112
+ if not headers:
113
+ return {}
114
+ redacted: dict[str, str] = {}
115
+ for key, value in headers.items():
116
+ name = str(key)
117
+ redacted[name] = "***" if name.lower() in _SENSITIVE_HEADERS else str(value)
118
+ return redacted
119
+
120
+
121
+ def redact_secret(value: Any) -> str:
122
+ """Render a secret for logs without revealing its contents."""
123
+ if value is None:
124
+ return "<none>"
125
+ text = str(value)
126
+ if not text:
127
+ return "<empty>"
128
+ return f"*** ({len(text)} chars)"
@@ -0,0 +1,127 @@
1
+ """Small, dependency-free metrics primitives.
2
+
3
+ DataCurrent never ships metrics to a hosted service on its own. You expose the
4
+ snapshot to whatever system you already use (Prometheus, OpenTelemetry, logs).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ __all__ = ["Counter", "Metrics", "StreamStats", "snapshot_stats"]
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class StreamStats:
20
+ """Mutable counters for a single stream instance."""
21
+
22
+ name: str | None = None
23
+ items_out: int = 0
24
+ errors: int = 0
25
+ retries: int = 0
26
+ dropped: int = 0
27
+
28
+ def snapshot(self) -> dict[str, Any]:
29
+ return {
30
+ "name": self.name,
31
+ "items_out": self.items_out,
32
+ "errors": self.errors,
33
+ "retries": self.retries,
34
+ "dropped": self.dropped,
35
+ }
36
+
37
+
38
+ def snapshot_stats(stats: StreamStats | None) -> dict[str, Any] | None:
39
+ return None if stats is None else stats.snapshot()
40
+
41
+
42
+ class Counter:
43
+ """A monotonically increasing counter."""
44
+
45
+ __slots__ = ("_value",)
46
+
47
+ def __init__(self) -> None:
48
+ self._value = 0
49
+
50
+ def inc(self, amount: int = 1) -> int:
51
+ self._value += amount
52
+ return self._value
53
+
54
+ @property
55
+ def value(self) -> int:
56
+ return self._value
57
+
58
+ def reset(self) -> None:
59
+ self._value = 0
60
+
61
+ def __int__(self) -> int:
62
+ return self._value
63
+
64
+ def __repr__(self) -> str: # pragma: no cover - trivial
65
+ return f"Counter(value={self._value})"
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class Metrics:
70
+ """A tiny registry of counters and gauges.
71
+
72
+ Keys are plain strings; tags are folded into the key so the output can be
73
+ logged as-is or exported to a metrics backend.
74
+
75
+ Example::
76
+
77
+ metrics = Metrics()
78
+ metrics.inc("stream.items", tags={"stream": "orders"})
79
+ metrics.snapshot()["stream.items{stream=orders}"] == 1
80
+ """
81
+
82
+ prefix: str = "datacurrent"
83
+ counters: dict[str, int] = field(default_factory=dict)
84
+ gauges: dict[str, float] = field(default_factory=dict)
85
+
86
+ @staticmethod
87
+ def _key(name: str, tags: dict[str, Any] | None) -> str:
88
+ if not tags:
89
+ return name
90
+ rendered = ",".join(f"{key}={value}" for key, value in sorted(tags.items()))
91
+ return f"{name}{{{rendered}}}"
92
+
93
+ def inc(self, name: str, amount: int = 1, **tags: Any) -> int:
94
+ key = self._key(name, tags or None)
95
+ self.counters[key] = self.counters.get(key, 0) + amount
96
+ return self.counters[key]
97
+
98
+ def set(self, name: str, value: float, **tags: Any) -> float:
99
+ key = self._key(name, tags or None)
100
+ self.gauges[key] = float(value)
101
+ return self.gauges[key]
102
+
103
+ def get(self, name: str, default: int = 0, **tags: Any) -> int:
104
+ return self.counters.get(self._key(name, tags or None), default)
105
+
106
+ @contextmanager
107
+ def timer(self, name: str, **tags: Any) -> Iterator[None]:
108
+ started = time.perf_counter()
109
+ try:
110
+ yield
111
+ finally:
112
+ self.observe_duration(name, time.perf_counter() - started, **tags)
113
+
114
+ def observe_duration(self, name: str, seconds: float, **tags: Any) -> None:
115
+ key = self._key(f"{name}.milliseconds", tags or None)
116
+ self.gauges[key] = seconds * 1000.0
117
+
118
+ def snapshot(self) -> dict[str, Any]:
119
+ return {
120
+ "prefix": self.prefix,
121
+ "counters": dict(self.counters),
122
+ "gauges": dict(self.gauges),
123
+ }
124
+
125
+ def reset(self) -> None:
126
+ self.counters.clear()
127
+ self.gauges.clear()
datacurrent/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker file for PEP 561: DataCurrent ships inline type information.
@@ -0,0 +1,14 @@
1
+ """Reliability: retries, backoff and timeouts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .retry import RetryPolicy, is_retryable, retry_async
6
+ from .timeout import validate_timeout, with_timeout
7
+
8
+ __all__ = [
9
+ "RetryPolicy",
10
+ "is_retryable",
11
+ "retry_async",
12
+ "validate_timeout",
13
+ "with_timeout",
14
+ ]