log-foundry 0.10.2.dev19__tar.gz → 0.10.2.dev20__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 (55) hide show
  1. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/PKG-INFO +16 -2
  2. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/README.md +15 -1
  3. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/__init__.py +11 -3
  5. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/decorator.py +9 -4
  6. log_foundry-0.10.2.dev20/src/log_foundry/sinks/_retry.py +103 -0
  7. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_socket.py +13 -2
  8. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/clickhouse.py +13 -4
  9. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/eventhubs.py +13 -4
  10. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/filtering.py +26 -0
  11. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/firehose.py +17 -1
  12. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/http.py +37 -4
  13. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/kinesis.py +17 -1
  14. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/logstash.py +24 -0
  15. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/mongodb.py +12 -3
  16. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/multi.py +29 -0
  17. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/postgres.py +13 -4
  18. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/rabbitmq.py +13 -4
  19. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/redis.py +13 -4
  20. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sentry.py +22 -1
  21. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sns.py +19 -2
  22. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sqs.py +24 -2
  23. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/syslog.py +19 -0
  24. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/transform.py +26 -0
  25. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/worker.py +129 -5
  26. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/LICENSE +0 -0
  27. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/_diag.py +0 -0
  28. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/api.py +0 -0
  29. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/config.py +0 -0
  30. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/console.py +0 -0
  31. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/context.py +0 -0
  32. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/ids.py +0 -0
  33. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/model.py +0 -0
  34. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/py.typed +0 -0
  35. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sanitize.py +0 -0
  36. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/__init__.py +0 -0
  37. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_batch.py +0 -0
  38. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_chunk.py +0 -0
  39. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_time.py +0 -0
  40. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/base.py +0 -0
  41. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/callback.py +0 -0
  42. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/datadog.py +0 -0
  43. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/elasticsearch.py +0 -0
  44. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/file.py +0 -0
  45. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/honeycomb.py +0 -0
  46. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/kafka.py +0 -0
  47. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/logging_sink.py +0 -0
  48. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/loki.py +0 -0
  49. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/nats.py +0 -0
  50. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/newrelic.py +0 -0
  51. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/pubsub.py +0 -0
  52. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/splunk.py +0 -0
  53. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sqlite.py +0 -0
  54. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/stdout.py +0 -0
  55. {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev19
3
+ Version: 0.10.2.dev20
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -780,9 +780,23 @@ which one you want depends on whether the process is about to end:
780
780
  import log_foundry as lf
781
781
 
782
782
  lf.flush() # drain to the sink and keep going; returns True when everything landed
783
- lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
783
+ lf.shutdown() # drain, close the sink, and stop for good; blocks until drained (30s cap)
784
784
  ```
785
785
 
786
+ Both are bounded, because both can be called somewhere with a deadline. `flush(timeout=5.0)`
787
+ returns `False` if the drain did not complete; `shutdown(timeout=30.0)` returns having stopped
788
+ what it could, and reports `health().stopped_reason == "ShutdownTimeout"`. Passing `None` to
789
+ either waits indefinitely, which is unsafe in any environment with an execution deadline.
790
+
791
+ **What a broken destination can cost you.** There is one drain thread, so a sink's backoff pauses
792
+ *all* log delivery, and it spans `shutdown()`. At the defaults (`max_retries=3`) that is 0.7 s of
793
+ backoff per batch for most sinks (per *message* for the socket-backed ones — ~70 s for a
794
+ 100-message batch against a dead syslog host), and up to 90 s for an HTTP sink whose destination
795
+ is sending
796
+ `Retry-After` — clamped to `max_retry_after=30.0` per wait, which you can lower. Every wait is cut
797
+ short by a shutdown, and `shutdown()`'s own timeout bounds the total either way. Each sink's class
798
+ docstring states its own worst case.
799
+
786
800
  | | `flush()` | `shutdown()` |
787
801
  |---|---|---|
788
802
  | Drains buffered events | yes | yes |
@@ -744,9 +744,23 @@ which one you want depends on whether the process is about to end:
744
744
  import log_foundry as lf
745
745
 
746
746
  lf.flush() # drain to the sink and keep going; returns True when everything landed
747
- lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
747
+ lf.shutdown() # drain, close the sink, and stop for good; blocks until drained (30s cap)
748
748
  ```
749
749
 
750
+ Both are bounded, because both can be called somewhere with a deadline. `flush(timeout=5.0)`
751
+ returns `False` if the drain did not complete; `shutdown(timeout=30.0)` returns having stopped
752
+ what it could, and reports `health().stopped_reason == "ShutdownTimeout"`. Passing `None` to
753
+ either waits indefinitely, which is unsafe in any environment with an execution deadline.
754
+
755
+ **What a broken destination can cost you.** There is one drain thread, so a sink's backoff pauses
756
+ *all* log delivery, and it spans `shutdown()`. At the defaults (`max_retries=3`) that is 0.7 s of
757
+ backoff per batch for most sinks (per *message* for the socket-backed ones — ~70 s for a
758
+ 100-message batch against a dead syslog host), and up to 90 s for an HTTP sink whose destination
759
+ is sending
760
+ `Retry-After` — clamped to `max_retry_after=30.0` per wait, which you can lower. Every wait is cut
761
+ short by a shutdown, and `shutdown()`'s own timeout bounds the total either way. Each sink's class
762
+ docstring states its own worst case.
763
+
750
764
  | | `flush()` | `shutdown()` |
751
765
  |---|---|---|
752
766
  | Drains buffered events | yes | yes |
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.10.2.dev19"
23
+ version = "0.10.2.dev20"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -21,7 +21,7 @@ from log_foundry.context import (
21
21
  )
22
22
  from log_foundry.decorator import continue_trace, trace
23
23
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
24
- from log_foundry.worker import Health
24
+ from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, Health
25
25
 
26
26
  try:
27
27
  # Distribution name ("log-foundry") differs from the import name ("log_foundry").
@@ -92,7 +92,7 @@ def health() -> Health:
92
92
  return _worker_health()
93
93
 
94
94
 
95
- def shutdown() -> None:
95
+ def shutdown(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
96
96
  """Flush buffered events and close the sink, blocking until drained. Idempotent.
97
97
 
98
98
  Also registered via ``atexit``; call it explicitly before a fast process exit when you
@@ -101,13 +101,21 @@ def shutdown() -> None:
101
101
  This is **terminal** — the worker does not come back. Do not call it per-invocation in a
102
102
  serverless handler: the first invocation on a warm container would log and every later one
103
103
  would silently log nothing. Use :func:`flush` there, which drains and keeps the worker.
104
+
105
+ ``timeout`` bounds the wait for the background thread (SPEC-027 FR-004). ``None`` waits
106
+ indefinitely, which is what this did unconditionally before and is still available on
107
+ request — but it is unsafe anywhere with an execution deadline, and ``atexit`` is one such
108
+ place: a sink blocked in a network call would hold the process open. An expired shutdown
109
+ reports ``health().stopped_reason == "ShutdownTimeout"`` and leaves the sink **open**, since
110
+ the drain thread may still be inside ``emit``. Never raises.
104
111
  """
105
112
  from log_foundry.decorator import _shutdown_worker
106
113
 
107
- _shutdown_worker()
114
+ _shutdown_worker(timeout)
108
115
 
109
116
 
110
117
  __all__ = [
118
+ "DEFAULT_SHUTDOWN_TIMEOUT",
111
119
  "Health",
112
120
  "SinkDeliveryError",
113
121
  "SinkLosses",
@@ -39,7 +39,7 @@ from log_foundry.ids import (
39
39
  parse_traceparent,
40
40
  )
41
41
  from log_foundry.model import Span, backfill_baggage, end_event, start_event
42
- from log_foundry.worker import Health, Worker
42
+ from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, Health, Worker
43
43
 
44
44
  if TYPE_CHECKING:
45
45
  import contextvars
@@ -209,10 +209,15 @@ def _get_worker() -> Worker:
209
209
  return _worker
210
210
 
211
211
 
212
- def _shutdown_worker() -> None:
213
- """Drain + close the process worker if one was created (idempotent). Backs ``shutdown()``."""
212
+ def _shutdown_worker(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
213
+ """Drain + close the process worker if one was created (idempotent). Backs ``shutdown()``.
214
+
215
+ The ``atexit`` registration binds this function, so the exit path gets the bounded form and
216
+ its default (SPEC-027 FR-004) — an unbounded join in an ``atexit`` handler is a process that
217
+ will not exit.
218
+ """
214
219
  if _worker is not None:
215
- _worker.shutdown()
220
+ _worker.shutdown(timeout)
216
221
 
217
222
 
218
223
  def _flush_worker(timeout: float | None = 5.0) -> bool:
@@ -0,0 +1,103 @@
1
+ """Shared retry waiting for the sinks (SPEC-027).
2
+
3
+ Every sink that retries does so by sleeping the one thread that delivers anything. The worker
4
+ owns a single drain thread by design (arch §9), so a sink's backoff is not a local decision — it
5
+ is a global pause on log delivery, and it is held across ``shutdown()``, which joins that thread.
6
+
7
+ Two rules follow, and this module is where both are applied once rather than at fifteen call
8
+ sites:
9
+
10
+ * **A wait is interruptible.** Sinks wait on an ``Event`` rather than calling ``time.sleep``, so a
11
+ shutdown cuts an in-progress backoff short instead of holding the drain thread for its full
12
+ delay. The worker's own backoff already did this (``worker.py``'s ``_stop.wait``); the sinks had
13
+ no access to the signal.
14
+ * **A server-supplied delay is advice, not an instruction.** ``Retry-After`` arrives from the
15
+ destination and went straight to ``time.sleep`` with no ceiling and no sign check: a measured
16
+ ``Retry-After: 8`` with the default ``max_retries=3`` blocked ``shutdown()`` for 22 seconds, a
17
+ header of ``86400`` would stall logging for a day, and a negative one makes ``time.sleep``
18
+ raise — absorbed inside a span, but reaching the caller on the orphan path.
19
+
20
+ This module imports nothing from its own package (``_diag``'s rule, for the same reason: it sits
21
+ below everything that might want it).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ import time
28
+ from typing import TYPE_CHECKING
29
+
30
+ if TYPE_CHECKING:
31
+ import threading
32
+
33
+ __all__ = ["MAX_WAIT", "clamp_server_delay", "wait"]
34
+
35
+ MAX_WAIT = 86_400.0
36
+ """Hard ceiling on any single wait, in seconds — a day.
37
+
38
+ Not a policy, a totality guard. ``time.sleep`` and ``Event.wait`` both raise ``OverflowError``
39
+ past the platform's ``time_t``, so ``wait(1e18)`` — reachable from a large ``max_retry_after``, or
40
+ from ``0.1 * 2**63`` on an absurd ``max_retries`` — would raise on the drain thread, which is the
41
+ one thing this module exists to prevent. Nothing legitimate waits longer than this, and a caller
42
+ who genuinely wants to is better served by not logging.
43
+ """
44
+
45
+
46
+ def wait(delay: float, stop: threading.Event | None = None) -> None:
47
+ """Wait ``delay`` seconds, returning early if ``stop`` is set (SPEC-027 FR-002).
48
+
49
+ Total. A non-positive or non-finite delay returns immediately rather than raising, so a
50
+ caller that computed one from arithmetic — or from a destination's header — cannot turn a
51
+ backoff into an exception on the drain thread.
52
+
53
+ ``stop`` is the worker's shutdown event when the sink was given one, and ``None`` for a sink
54
+ used standalone, which then waits exactly as it did before this spec. ``Event.wait`` is used
55
+ rather than ``time.sleep`` in both cases: with no event there is nothing to wait on, so the
56
+ ``None`` path falls back to ``time.sleep``.
57
+
58
+ A finite delay larger than :data:`MAX_WAIT` is capped rather than passed through: both
59
+ ``time.sleep`` and ``Event.wait`` raise ``OverflowError`` past the platform's ``time_t``, and
60
+ "total" has to mean total.
61
+
62
+ It does **not** abort an in-flight network call — only the pause between attempts. Cancelling
63
+ a socket mid-write is not something this library attempts.
64
+ """
65
+ if not (delay > 0) or math.isinf(delay):
66
+ return
67
+ delay = min(delay, MAX_WAIT)
68
+ if stop is None:
69
+ time.sleep(delay)
70
+ return
71
+ stop.wait(delay)
72
+
73
+
74
+ def clamp_server_delay(value: float | None, ceiling: float) -> float | None:
75
+ """Bound a server-supplied delay; ``None`` means "fall back to exponential backoff".
76
+
77
+ ``None`` in and ``None`` out is the ordinary case: no ``Retry-After``, or one in HTTP-date
78
+ form that the parser declined. Everything else is validated against what a *delay* can be —
79
+ finite, positive, and no longer than the caller's ceiling.
80
+
81
+ Zero is rejected rather than honoured as "retry now". A destination that is rate-limiting has
82
+ asked us to slow down, and a header saying "wait zero seconds" is far more likely a broken or
83
+ truncated value than a real instruction to hammer it; the exponential backoff is the safer
84
+ reading. ``NaN`` falls out of the same comparison that rejects a negative: every comparison
85
+ against ``NaN`` is ``False``, which is why the test is written ``not (value > 0)`` rather than
86
+ ``value <= 0`` — the latter reads ``False`` for ``NaN``, letting it through to ``min()``,
87
+ which returns it.
88
+
89
+ The ceiling is the caller's, not a constant: a platform that legitimately asks for a
90
+ two-minute pause should be allowed one, and a caller with an execution deadline should be able
91
+ to lower it below the default.
92
+ """
93
+ if value is None or not (value > 0) or math.isinf(value):
94
+ return None
95
+ if not (ceiling > 0):
96
+ # An unusable ceiling is a misconfiguration, not an instruction to stop waiting. Left
97
+ # unchecked, ``min(value, 0)`` returns ``0.0`` — which is *not* ``None``, so the caller
98
+ # reads it as an honoured delay and retries with no backoff at all, and ``min(value,
99
+ # nan)`` returns ``value``, so the ceiling silently vanishes and the header is honoured
100
+ # in full. Both defeat this function in the direction it exists to prevent. Falling back
101
+ # to the sink's own backoff is the same answer a missing header gets.
102
+ return None
103
+ return min(value, ceiling)
@@ -12,9 +12,13 @@ substitute a fake socket without any network access.
12
12
  from __future__ import annotations
13
13
 
14
14
  import socket
15
- import time
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ import threading
16
19
 
17
20
  from log_foundry import _diag
21
+ from log_foundry.sinks._retry import wait
18
22
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
19
23
 
20
24
  __all__ = ["SocketTransport"]
@@ -35,6 +39,11 @@ def _make_udp() -> socket.socket:
35
39
  class SocketTransport:
36
40
  """Send pre-framed messages over a TCP or UDP socket, reconnecting on error within a bound.
37
41
 
42
+ **Worst-case delay** (SPEC-027 FR-005): ``max_retries`` waits of ``0.1 * 2**n`` *per
43
+ message* — 0.7 s per message at the default 3, so a 100-message batch against a dead
44
+ destination is ~70 s of backoff on the single drain thread. The wait is interruptible, so
45
+ ``shutdown()`` cuts it short.
46
+
38
47
  Attributes:
39
48
  failed: Messages abandoned past the reconnect-retry bound.
40
49
  """
@@ -59,6 +68,8 @@ class SocketTransport:
59
68
  self._max_retries = max(max_retries, 0)
60
69
  self._sock: socket.socket | None = None
61
70
  self.failed = 0
71
+ # Set by the worker through the owning sink (SPEC-027 FR-002).
72
+ self.stop_signal: threading.Event | None = None
62
73
 
63
74
  def send_all(self, messages: list[bytes]) -> None:
64
75
  """Send each pre-framed message, reconnecting on error (FR-005, FR-006).
@@ -102,7 +113,7 @@ class SocketTransport:
102
113
  except OSError as err:
103
114
  self._reset() # force a fresh connection on the next attempt
104
115
  if attempt < self._max_retries:
105
- time.sleep(_BACKOFF_BASE * (2**attempt))
116
+ wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
106
117
  continue
107
118
  self.failed += 1
108
119
  # Guarded now (SPEC-029 FR-003): this runs on the worker thread, and the bare
@@ -9,11 +9,14 @@ idempotent ``MergeTree`` ``create_table`` convenience is off by default. Write-o
9
9
  from __future__ import annotations
10
10
 
11
11
  import json
12
- import time
13
- from typing import Any
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ if TYPE_CHECKING:
15
+ import threading
14
16
 
15
17
  from log_foundry import _diag
16
18
  from log_foundry.sinks._chunk import chunk_list, valid_identifier
19
+ from log_foundry.sinks._retry import wait
17
20
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
18
21
 
19
22
  __all__ = ["ClickHouseSink"]
@@ -41,7 +44,11 @@ _COLUMN_TYPES = {
41
44
 
42
45
 
43
46
  class ClickHouseSink:
44
- """A :class:`~log_foundry.sinks.base.Sink` that batch-inserts events into a ClickHouse table."""
47
+ """A :class:`~log_foundry.sinks.base.Sink` that batch-inserts events into a ClickHouse table.
48
+
49
+ **Worst-case delay** (SPEC-027 FR-005): ``max_retries`` waits of ``0.1 * 2**n`` per chunk —
50
+ 0.7 s at the default 3. The waits are interruptible, so ``shutdown()`` cuts one short.
51
+ """
45
52
 
46
53
  def __init__(
47
54
  self,
@@ -58,6 +65,8 @@ class ClickHouseSink:
58
65
  # Floored as ``Worker._emit`` floors its own (SPEC-021): a negative value returned
59
66
  # from ``_insert`` having attempted nothing, and reported success.
60
67
  self.max_retries = max(max_retries, 0)
68
+ # Set by the worker when this sink is the configured one (SPEC-027 FR-002).
69
+ self.stop_signal: threading.Event | None = None
61
70
  self.failed = 0
62
71
  self._closed = False
63
72
  self._owns_client = client is None
@@ -110,7 +119,7 @@ class ClickHouseSink:
110
119
  return 1
111
120
  except Exception as err: # isolation boundary: never crash the worker (FR-006)
112
121
  if attempt < self.max_retries:
113
- time.sleep(_BACKOFF_BASE * (2**attempt))
122
+ wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
114
123
  continue
115
124
  self.failed += len(rows)
116
125
  _diag.lost(
@@ -10,10 +10,13 @@ send errors are retried within a bound. ``close()`` closes the producer.
10
10
  from __future__ import annotations
11
11
 
12
12
  import json
13
- import time
14
- from typing import Any
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ import threading
15
17
 
16
18
  from log_foundry import _diag
19
+ from log_foundry.sinks._retry import wait
17
20
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
18
21
 
19
22
  __all__ = ["AzureEventHubsSink"]
@@ -22,7 +25,11 @@ _BACKOFF_BASE = 0.1
22
25
 
23
26
 
24
27
  class AzureEventHubsSink:
25
- """A :class:`~log_foundry.sinks.base.Sink` that sends events to an Azure Event Hub."""
28
+ """A :class:`~log_foundry.sinks.base.Sink` that sends events to an Azure Event Hub.
29
+
30
+ **Worst-case delay** (SPEC-027 FR-005): ``max_retries`` waits of ``0.1 * 2**n`` per EventDataBatch —
31
+ 0.7 s at the default 3. The waits are interruptible, so ``shutdown()`` cuts one short.
32
+ """
26
33
 
27
34
  def __init__(
28
35
  self,
@@ -48,6 +55,8 @@ class AzureEventHubsSink:
48
55
  # Floored as ``Worker._emit`` floors its own (SPEC-021): a negative value returned
49
56
  # from ``_send`` having attempted nothing, and reported success.
50
57
  self.max_retries = max(max_retries, 0)
58
+ # Set by the worker when this sink is the configured one (SPEC-027 FR-002).
59
+ self.stop_signal: threading.Event | None = None
51
60
  self.failed = 0
52
61
  self.dropped_oversized = 0
53
62
 
@@ -112,7 +121,7 @@ class AzureEventHubsSink:
112
121
  return 1
113
122
  except Exception as err: # isolation boundary: never crash the worker (FR-011)
114
123
  if attempt < self.max_retries:
115
- time.sleep(_BACKOFF_BASE * (2**attempt))
124
+ wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
116
125
  continue
117
126
  self.failed += len(event_batch)
118
127
  _diag.lost(
@@ -10,9 +10,11 @@ from __future__ import annotations
10
10
 
11
11
  from typing import TYPE_CHECKING
12
12
 
13
+ from log_foundry import _diag
13
14
  from log_foundry.sinks.base import read_losses
14
15
 
15
16
  if TYPE_CHECKING:
17
+ import threading
16
18
  from collections.abc import Callable
17
19
 
18
20
  from log_foundry.sinks.base import Sink, SinkLosses
@@ -43,6 +45,7 @@ class FilteringSink:
43
45
  min_level: str | None = None,
44
46
  ) -> None:
45
47
  self._inner = inner
48
+ self._stop_signal: threading.Event | None = None
46
49
  self._predicate = predicate
47
50
  self._min_rank: int | None = None
48
51
  if min_level is not None:
@@ -71,6 +74,29 @@ class FilteringSink:
71
74
  return False
72
75
  return True
73
76
 
77
+ @property
78
+ def stop_signal(self) -> threading.Event | None:
79
+ """The worker's shutdown event, forwarded to whatever actually holds the retry loop.
80
+
81
+ The worker sets this on the *configured* sink (SPEC-027 FR-002), and a wrapper is not
82
+ where the waiting happens. Without the forward the attribute is set on an object that
83
+ never waits, and the backoff one level down stays uninterruptible — which is the whole
84
+ defect, moved rather than fixed.
85
+ """
86
+ return self._stop_signal
87
+
88
+ @stop_signal.setter
89
+ def stop_signal(self, signal: threading.Event | None) -> None:
90
+ self._stop_signal = signal
91
+ try:
92
+ self._inner.stop_signal = signal # type: ignore[attr-defined]
93
+ except Exception as err:
94
+ _diag.absorbed(
95
+ "handing the inner sink its stop signal",
96
+ err,
97
+ f"{type(self._inner).__name__} stays uninterruptible",
98
+ )
99
+
74
100
  def losses(self) -> SinkLosses | None:
75
101
  """Report the inner sink's losses (SPEC-026 FR-002). Never raises.
76
102
 
@@ -9,19 +9,28 @@ retried within a bounded count.
9
9
  from __future__ import annotations
10
10
 
11
11
  import json
12
- from typing import Any
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ if TYPE_CHECKING:
15
+ import threading
13
16
 
14
17
  from log_foundry import _diag
15
18
  from log_foundry.sinks._batch import adjudicate_positional, usable_results
16
19
  from log_foundry.sinks._chunk import chunk_items
20
+ from log_foundry.sinks._retry import wait
17
21
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
18
22
 
19
23
  __all__ = ["FirehoseSink"]
20
24
 
25
+ _BACKOFF_BASE = 0.1 # seconds; delay before retry attempt n is _BACKOFF_BASE * 2**n
26
+
21
27
 
22
28
  class FirehoseSink:
23
29
  """A :class:`~log_foundry.sinks.base.Sink` that writes events to a Firehose delivery stream.
24
30
 
31
+ **Worst-case delay** (SPEC-027 FR-005): ``max_retries`` waits of ``0.1 * 2**n`` per chunk —
32
+ 0.7 s at the default 3. The waits are interruptible, so ``shutdown()`` cuts one short.
33
+
25
34
  Three counters report what was not delivered: ``failed`` (the delivery stream told us these
26
35
  failed, and they still failed after ``max_retries``), ``dropped_oversized`` (too large for the
27
36
  per-record limit to ever accept), and ``dropped_unadjudicated`` (a ``put_record_batch`` response
@@ -45,6 +54,8 @@ class FirehoseSink:
45
54
  # Floored as ``Worker._emit`` floors its own (SPEC-021): a negative value returned
46
55
  # from ``_send`` having sent nothing, and reported success.
47
56
  self.max_retries = max(max_retries, 0)
57
+ # Set by the worker when this sink is the configured one (SPEC-027 FR-002).
58
+ self.stop_signal: threading.Event | None = None
48
59
  self.failed = 0
49
60
  self.dropped_oversized = 0
50
61
  self.dropped_unadjudicated = 0
@@ -153,6 +164,11 @@ class FirehoseSink:
153
164
  records = verdict.retry
154
165
  if not records:
155
166
  return sent
167
+ if attempt < self.max_retries:
168
+ # Before the next attempt, never before abandoning (SPEC-027 FR-003). This loop
169
+ # re-sends the entries the destination flagged, and the canonical reason it
170
+ # flags them is throttling — which an immediate re-send makes worse.
171
+ wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
156
172
  if attempt >= self.max_retries:
157
173
  self.failed += len(records)
158
174
  _diag.lost(
@@ -15,22 +15,32 @@ from __future__ import annotations
15
15
 
16
16
  import gzip as _gzip
17
17
  import json
18
- import time
19
18
  import urllib.error
20
19
  import urllib.request
21
20
  from base64 import b64encode
22
21
  from typing import TYPE_CHECKING, Any, NoReturn
23
22
 
24
23
  from log_foundry import _diag
24
+ from log_foundry.sinks._retry import clamp_server_delay, wait
25
25
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
26
26
 
27
27
  if TYPE_CHECKING:
28
+ import threading
28
29
  from collections.abc import Callable
29
30
 
30
31
  __all__ = ["HTTPSink", "merge_headers"]
31
32
 
32
33
  _BACKOFF_BASE = 0.1 # seconds; delay for retry attempt n is _BACKOFF_BASE * 2**n
33
34
 
35
+ DEFAULT_MAX_RETRY_AFTER = 30.0
36
+ """Ceiling on a server-supplied ``Retry-After``, in seconds (SPEC-027 FR-001).
37
+
38
+ Thirty rather than something smaller because a rate-limited platform asking for half a minute is
39
+ making a reasonable request, and rather than something larger because the *total* is what a caller
40
+ with an execution deadline pays: with the default ``max_retries=3`` the worst case is three waits,
41
+ 90 s, which stays inside a typical serverless timeout while leaving room for the request itself.
42
+ """
43
+
34
44
 
35
45
  def merge_headers(base: dict[str, str], http_kwargs: dict[str, object]) -> dict[str, str]:
36
46
  """Merge a platform sink's own headers with any caller-supplied ``headers`` (caller wins).
@@ -52,6 +62,12 @@ class HTTPSink:
52
62
  requires ``https://`` — over a plaintext endpoint a bearer token, and a basic-auth pair (which
53
63
  is base64, not encryption), travel in the clear. Use ``https://`` for anything off the host.
54
64
 
65
+ **Worst-case delay** (SPEC-027 FR-005): ``max_retries`` waits, each at most
66
+ ``max_retry_after`` when the server sends a ``Retry-After`` and ``0.1 * 2**n`` otherwise —
67
+ so 90 s at the defaults (3 × 30 s), plus the request timeouts themselves. That delay pauses
68
+ the single drain thread, so it is a pause on *all* log delivery, not just this sink's.
69
+ ``shutdown()``'s own timeout bounds the total (SPEC-027 FR-004).
70
+
55
71
  Attributes:
56
72
  failed: Requests abandoned past the retry bound.
57
73
  dropped_oversized: Events dropped for exceeding a destination's hard size limit (used by
@@ -69,6 +85,7 @@ class HTTPSink:
69
85
  timeout: float = 5.0,
70
86
  gzip: bool = False,
71
87
  max_retries: int = 3,
88
+ max_retry_after: float = DEFAULT_MAX_RETRY_AFTER,
72
89
  opener: Callable[..., Any] | None = None,
73
90
  ) -> None:
74
91
  self.url = url
@@ -82,6 +99,13 @@ class HTTPSink:
82
99
  # skipped the loop entirely, so the request was abandoned with no attempt made and no
83
100
  # counter moved — reachable only by misconfiguration, but reachable.
84
101
  self.max_retries = max(max_retries, 0)
102
+ # Not floored or rejected here: ``clamp_server_delay`` refuses an unusable ceiling and
103
+ # falls back to exponential backoff, which keeps the validation in one place (SPEC-027
104
+ # FR-001). Stored as given so a caller can read back what they passed.
105
+ self.max_retry_after = max_retry_after
106
+ # Set by the worker when this sink is the configured one (SPEC-027 FR-002); ``None``
107
+ # standalone, which backs off uninterruptibly exactly as before.
108
+ self.stop_signal: threading.Event | None = None
85
109
  self._opener = opener if opener is not None else urllib.request.urlopen
86
110
  self.failed = 0
87
111
  self.dropped_oversized = 0
@@ -219,9 +243,18 @@ class HTTPSink:
219
243
  return int(status), payload, _parse_retry_after(getattr(response, "headers", None))
220
244
 
221
245
  def _sleep_backoff(self, attempt: int, retry_after: float | None) -> None:
222
- """Sleep before the next attempt: ``Retry-After`` if the server gave one, else backoff."""
223
- delay = retry_after if retry_after is not None else _BACKOFF_BASE * (2**attempt)
224
- time.sleep(delay)
246
+ """Wait before the next attempt: ``Retry-After`` if usable, else exponential backoff.
247
+
248
+ The server's value is clamped and sign-checked first (SPEC-027 FR-001) — it is advice
249
+ from the destination, not an instruction the application must obey. Unbounded, a measured
250
+ ``Retry-After: 8`` held ``shutdown()`` for 22 s and a header of ``86400`` would have held
251
+ it for a day; a negative one made ``time.sleep`` raise, which the orphan path handed to
252
+ the caller. Anything rejected falls back to this sink's own backoff, which is what the
253
+ destination would have got had it sent no header at all.
254
+ """
255
+ server = clamp_server_delay(retry_after, self.max_retry_after)
256
+ delay = server if server is not None else _BACKOFF_BASE * (2**attempt)
257
+ wait(delay, self.stop_signal)
225
258
 
226
259
  def _abandon(self, reason: str) -> NoReturn:
227
260
  """Count, log, then raise for a request abandoned past the retry bound (FR-012).
@@ -10,19 +10,28 @@ unless a sink is built without an injected client. Each incoming batch is re-chu
10
10
  from __future__ import annotations
11
11
 
12
12
  import json
13
- from typing import Any
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ import threading
14
17
 
15
18
  from log_foundry import _diag
16
19
  from log_foundry.sinks._batch import adjudicate_positional, usable_results
17
20
  from log_foundry.sinks._chunk import chunk_items
21
+ from log_foundry.sinks._retry import wait
18
22
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
19
23
 
20
24
  __all__ = ["KinesisSink"]
21
25
 
26
+ _BACKOFF_BASE = 0.1 # seconds; delay before retry attempt n is _BACKOFF_BASE * 2**n
27
+
22
28
 
23
29
  class KinesisSink:
24
30
  """A :class:`~log_foundry.sinks.base.Sink` that writes events to a Kinesis Data Stream.
25
31
 
32
+ **Worst-case delay** (SPEC-027 FR-005): ``max_retries`` waits of ``0.1 * 2**n`` per chunk —
33
+ 0.7 s at the default 3. The waits are interruptible, so ``shutdown()`` cuts one short.
34
+
26
35
  Three counters report what was not delivered: ``failed`` (the stream told us these failed, and
27
36
  they still failed after ``max_retries``), ``dropped_oversized`` (too large for the per-record
28
37
  limit to ever accept), and ``dropped_unadjudicated`` (a ``put_records`` response whose results
@@ -53,6 +62,8 @@ class KinesisSink:
53
62
  # Floored as ``Worker._emit`` floors its own (SPEC-021): a negative value returned
54
63
  # from ``_send`` having sent nothing, and reported success.
55
64
  self.max_retries = max(max_retries, 0)
65
+ # Set by the worker when this sink is the configured one (SPEC-027 FR-002).
66
+ self.stop_signal: threading.Event | None = None
56
67
  self.failed = 0
57
68
  self.dropped_oversized = 0
58
69
  self.dropped_unadjudicated = 0
@@ -158,6 +169,11 @@ class KinesisSink:
158
169
  records = verdict.retry
159
170
  if not records:
160
171
  return sent
172
+ if attempt < self.max_retries:
173
+ # Before the next attempt, never before abandoning (SPEC-027 FR-003). This loop
174
+ # re-sends the entries the destination flagged, and the canonical reason it
175
+ # flags them is throttling — which an immediate re-send makes worse.
176
+ wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
161
177
  if attempt >= self.max_retries:
162
178
  self.failed += len(records)
163
179
  _diag.lost(
@@ -12,6 +12,10 @@ Either backend handles its own bounded retry; ``close()`` releases whichever it
12
12
  from __future__ import annotations
13
13
 
14
14
  import json
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ import threading
15
19
 
16
20
  from log_foundry.sinks._socket import SocketTransport
17
21
  from log_foundry.sinks.base import SinkLosses
@@ -47,6 +51,7 @@ class LogstashSink:
47
51
  )
48
52
  else:
49
53
  raise ValueError("LogstashSink requires either url= (HTTP) or host= + port= (socket)")
54
+ self._stop_signal: threading.Event | None = None
50
55
 
51
56
  def emit(self, batch: list[dict[str, object]]) -> None:
52
57
  """Send the batch over the configured backend (FR-005)."""
@@ -68,6 +73,25 @@ class LogstashSink:
68
73
  elif self._socket is not None:
69
74
  self._socket.close()
70
75
 
76
+ @property
77
+ def stop_signal(self) -> threading.Event | None:
78
+ """The worker's shutdown event, forwarded to whatever actually holds the retry loop.
79
+
80
+ The worker sets this on the *configured* sink (SPEC-027 FR-002), and a wrapper is not
81
+ where the waiting happens. Without the forward the attribute is set on an object that
82
+ never waits, and the backoff one level down stays uninterruptible — which is the whole
83
+ defect, moved rather than fixed.
84
+ """
85
+ return self._stop_signal
86
+
87
+ @stop_signal.setter
88
+ def stop_signal(self, signal: threading.Event | None) -> None:
89
+ self._stop_signal = signal
90
+ if self._http is not None:
91
+ self._http.stop_signal = signal
92
+ elif self._socket is not None:
93
+ self._socket.stop_signal = signal
94
+
71
95
  @property
72
96
  def failed(self) -> int:
73
97
  """Requests/messages abandoned past the retry bound, from the active backend."""