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.
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/PKG-INFO +16 -2
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/README.md +15 -1
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/pyproject.toml +1 -1
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/__init__.py +11 -3
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/decorator.py +9 -4
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/_retry.py +103 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_socket.py +13 -2
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/clickhouse.py +13 -4
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/eventhubs.py +13 -4
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/filtering.py +26 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/firehose.py +17 -1
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/http.py +37 -4
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/kinesis.py +17 -1
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/logstash.py +24 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/mongodb.py +12 -3
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/multi.py +29 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/postgres.py +13 -4
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/rabbitmq.py +13 -4
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/redis.py +13 -4
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sentry.py +22 -1
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sns.py +19 -2
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sqs.py +24 -2
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/syslog.py +19 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/transform.py +26 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/worker.py +129 -5
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/LICENSE +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/_diag.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/api.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/config.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/console.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/context.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/model.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sanitize.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.10.2.dev19 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/stdout.py +0 -0
- {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.
|
|
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 |
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
223
|
-
|
|
224
|
-
|
|
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."""
|