intempt 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.
- intempt/__init__.py +34 -0
- intempt/_buffer.py +330 -0
- intempt/_client.py +456 -0
- intempt/_config.py +212 -0
- intempt/_errors.py +60 -0
- intempt/_transport.py +211 -0
- intempt/_util.py +101 -0
- intempt-1.0.0.dist-info/METADATA +202 -0
- intempt-1.0.0.dist-info/RECORD +12 -0
- intempt-1.0.0.dist-info/WHEEL +4 -0
- intempt-1.0.0.dist-info/licenses/LICENSE +202 -0
- intempt-1.0.0.dist-info/licenses/NOTICE +59 -0
intempt/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Intempt Python SDK — server-side. Data in, decisions out.
|
|
2
|
+
|
|
3
|
+
Copyright 2026 Intempt Technologies
|
|
4
|
+
Licensed under the Apache License, Version 2.0.
|
|
5
|
+
|
|
6
|
+
Contains code derived from mixpanel-python (Apache License 2.0); see NOTICE.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ._buffer import Buffer
|
|
12
|
+
from ._client import COMMERCE_EVENTS, IDENTIFY_EVENT, Consent, Ecommerce, Intempt
|
|
13
|
+
from ._config import BatchOptions, ResolvedConfig
|
|
14
|
+
from ._errors import IntemptApiError, IntemptConfigError, IntemptError
|
|
15
|
+
from ._transport import ApiKeyCredentials, Transport
|
|
16
|
+
|
|
17
|
+
__version__ = "1.0.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Intempt",
|
|
21
|
+
"IntemptError",
|
|
22
|
+
"IntemptApiError",
|
|
23
|
+
"IntemptConfigError",
|
|
24
|
+
"BatchOptions",
|
|
25
|
+
"ResolvedConfig",
|
|
26
|
+
"ApiKeyCredentials",
|
|
27
|
+
"Transport",
|
|
28
|
+
"Buffer",
|
|
29
|
+
"Consent",
|
|
30
|
+
"Ecommerce",
|
|
31
|
+
"COMMERCE_EVENTS",
|
|
32
|
+
"IDENTIFY_EVENT",
|
|
33
|
+
"__version__",
|
|
34
|
+
]
|
intempt/_buffer.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Opt-in event buffer for long-lived processes.
|
|
2
|
+
|
|
3
|
+
Portions derived from mixpanel-python (Apache License 2.0), as recorded in
|
|
4
|
+
NOTICE: the consumer/buffer split, the chunking and the retry-with-backoff loop
|
|
5
|
+
follow its BufferedConsumer and Consumer.
|
|
6
|
+
|
|
7
|
+
Changed substantially. The retry policy is Intempt's: a 413 halves the batch
|
|
8
|
+
width and recovers by doubling, 429 honours Retry-After, a circuit breaker opens
|
|
9
|
+
after five consecutive failures, and a close-initiated drain is bounded.
|
|
10
|
+
mixpanel-python retries on a fixed schedule with no width adaptation and no
|
|
11
|
+
breaker.
|
|
12
|
+
|
|
13
|
+
Deliberately in memory. Crash durability needs disk with fsync, file locking and
|
|
14
|
+
boot-time recovery, which is a different design and is not in scope.
|
|
15
|
+
|
|
16
|
+
Copyright 2026 Intempt Technologies
|
|
17
|
+
Licensed under the Apache License, Version 2.0.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import atexit
|
|
23
|
+
import contextlib
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
from typing import Any, Callable
|
|
28
|
+
|
|
29
|
+
from ._config import BatchOptions
|
|
30
|
+
from ._errors import IntemptApiError
|
|
31
|
+
|
|
32
|
+
MAX_RETRY_INTERVAL_MS = 10 * 60 * 1000
|
|
33
|
+
#: Floor for any retry, so a zero or past Retry-After cannot become a hot loop.
|
|
34
|
+
MIN_RETRY_INTERVAL_MS = 100
|
|
35
|
+
MAX_CONSECUTIVE_FAILURES = 5
|
|
36
|
+
|
|
37
|
+
#: Consecutive single-event 413 drops before saying so once.
|
|
38
|
+
#:
|
|
39
|
+
#: Diagnostic only. Using this tally to change behaviour was tried twice on the
|
|
40
|
+
#: Node SDK and both attempts were worse than what they fixed: stopping stranded
|
|
41
|
+
#: the queue and discarded every later event, and pinning the width to 1 capped
|
|
42
|
+
#: throughput to one event per round trip so a fast producer overflowed the
|
|
43
|
+
#: queue. Trading delivered events for a lower request count is the wrong
|
|
44
|
+
#: direction.
|
|
45
|
+
DROPS_BEFORE_WARNING = 3
|
|
46
|
+
|
|
47
|
+
#: Successful full-width sends before trying a wider batch again.
|
|
48
|
+
SUCCESSES_BEFORE_WIDENING = 10
|
|
49
|
+
|
|
50
|
+
#: How long close() keeps draining before it gives up and reports the loss.
|
|
51
|
+
CLOSE_DRAIN_BUDGET_S = 30.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Buffer:
|
|
55
|
+
"""Queues events and drains them, applying the retry policy."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
options: BatchOptions,
|
|
61
|
+
max_request_events: int,
|
|
62
|
+
logger: Any,
|
|
63
|
+
send: Callable[[list[dict[str, Any]]], None],
|
|
64
|
+
close_budget_s: float = CLOSE_DRAIN_BUDGET_S,
|
|
65
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""
|
|
68
|
+
``sleep`` is injectable so tests can assert the backoff *decision*
|
|
69
|
+
without paying the wall clock for it. Asserting how long a test took is
|
|
70
|
+
a worse check than asserting what the code chose, and a suite full of
|
|
71
|
+
real sleeps makes mutation testing intractable — the retry table alone
|
|
72
|
+
was costing seconds per run.
|
|
73
|
+
"""
|
|
74
|
+
self._options = options
|
|
75
|
+
self._max_request_events = max_request_events
|
|
76
|
+
self._logger = logger
|
|
77
|
+
self._send = send
|
|
78
|
+
self._close_budget_s = close_budget_s
|
|
79
|
+
self._sleep = sleep
|
|
80
|
+
|
|
81
|
+
self._queue: list[dict[str, Any]] = []
|
|
82
|
+
self._batch_size = min(options.size, max_request_events)
|
|
83
|
+
self._consecutive_failures = 0
|
|
84
|
+
self._consecutive_successes = 0
|
|
85
|
+
self._consecutive_drops = 0
|
|
86
|
+
self._stopped = False
|
|
87
|
+
self._close_deadline: float | None = None
|
|
88
|
+
|
|
89
|
+
# One lock guards the queue; one guards draining, so two callers can
|
|
90
|
+
# never drain the same slice.
|
|
91
|
+
self._lock = threading.Lock()
|
|
92
|
+
self._drain_lock = threading.RLock()
|
|
93
|
+
|
|
94
|
+
self._timer: threading.Timer | None = None
|
|
95
|
+
self._exit_hook: Callable[[], None] | None = None
|
|
96
|
+
if options.flush_on_exit:
|
|
97
|
+
self._exit_hook = self._on_exit
|
|
98
|
+
atexit.register(self._exit_hook)
|
|
99
|
+
|
|
100
|
+
# -- queueing ---------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def enqueue(self, event: dict[str, Any]) -> None:
|
|
103
|
+
"""Buffer an event, or log and drop it.
|
|
104
|
+
|
|
105
|
+
Returns nothing on purpose: a drop is reported through the logger, which
|
|
106
|
+
is the channel callers actually have.
|
|
107
|
+
"""
|
|
108
|
+
with self._lock:
|
|
109
|
+
if self._stopped:
|
|
110
|
+
self._logger.error(
|
|
111
|
+
"[intempt] batching is stopped; event dropped",
|
|
112
|
+
extra={"name": event.get("name")},
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
if len(self._queue) >= self._options.max_queue:
|
|
116
|
+
self._logger.error(
|
|
117
|
+
"[intempt] batch queue full; event dropped",
|
|
118
|
+
extra={"name": event.get("name"), "max_queue": self._options.max_queue},
|
|
119
|
+
)
|
|
120
|
+
return
|
|
121
|
+
self._queue.append(event)
|
|
122
|
+
full = len(self._queue) >= self._batch_size
|
|
123
|
+
|
|
124
|
+
if full:
|
|
125
|
+
self.flush()
|
|
126
|
+
else:
|
|
127
|
+
self._schedule_flush()
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def size(self) -> int:
|
|
131
|
+
with self._lock:
|
|
132
|
+
return len(self._queue)
|
|
133
|
+
|
|
134
|
+
# -- draining ---------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def flush(self) -> None:
|
|
137
|
+
"""Drain until the queue is empty or the batcher has stopped."""
|
|
138
|
+
with self._drain_lock:
|
|
139
|
+
self._cancel_timer()
|
|
140
|
+
while True:
|
|
141
|
+
if self._out_of_close_budget():
|
|
142
|
+
return
|
|
143
|
+
with self._lock:
|
|
144
|
+
if self._stopped or not self._queue:
|
|
145
|
+
return
|
|
146
|
+
batch = self._queue[: self._batch_size]
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
self._send(batch)
|
|
150
|
+
except Exception as exc: # noqa: BLE001 - classified below
|
|
151
|
+
action = self._handle_failure(exc, batch)
|
|
152
|
+
if action == "requeue":
|
|
153
|
+
continue
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
with self._lock:
|
|
157
|
+
del self._queue[: len(batch)]
|
|
158
|
+
self._consecutive_failures = 0
|
|
159
|
+
self._consecutive_drops = 0
|
|
160
|
+
self._widen_if_earned(len(batch))
|
|
161
|
+
|
|
162
|
+
def _widen_if_earned(self, sent: int) -> None:
|
|
163
|
+
"""Grow the width back after a run of successes at the current width.
|
|
164
|
+
|
|
165
|
+
Comparing against the full width instead would be unreachable: the batch
|
|
166
|
+
is sliced to the current width, so once a 413 halves it the condition can
|
|
167
|
+
never be true again and the reduction lasts for the life of the client.
|
|
168
|
+
|
|
169
|
+
Only a send that filled the current width counts, so a trickle producer
|
|
170
|
+
does not earn a widening from batches that never tested the width. At a
|
|
171
|
+
width of 1 that filter cannot bite, which is the intended floor.
|
|
172
|
+
"""
|
|
173
|
+
full = min(self._options.size, self._max_request_events)
|
|
174
|
+
if self._batch_size < full and sent >= self._batch_size:
|
|
175
|
+
self._consecutive_successes += 1
|
|
176
|
+
if self._consecutive_successes >= SUCCESSES_BEFORE_WIDENING:
|
|
177
|
+
self._batch_size = min(full, self._batch_size * 2)
|
|
178
|
+
self._consecutive_successes = 0
|
|
179
|
+
|
|
180
|
+
def _handle_failure(self, error: Exception, batch: Sequence[dict[str, Any]]) -> str:
|
|
181
|
+
"""Apply the retry table. Returns 'requeue' or 'stop'.
|
|
182
|
+
|
|
183
|
+
413 batch > 1 halve the width and retry
|
|
184
|
+
413 batch = 1 drop the event, log it, return the width to full
|
|
185
|
+
429 honour Retry-After, else exponential backoff
|
|
186
|
+
5xx/408/timeout exponential backoff
|
|
187
|
+
other 4xx drop the batch, surface status and body
|
|
188
|
+
"""
|
|
189
|
+
api_error = error if isinstance(error, IntemptApiError) else None
|
|
190
|
+
status = api_error.status if api_error else None
|
|
191
|
+
|
|
192
|
+
# Any failure ends the run of successes, whichever branch handles it.
|
|
193
|
+
self._consecutive_successes = 0
|
|
194
|
+
|
|
195
|
+
if status == 413:
|
|
196
|
+
return self._handle_too_large(batch)
|
|
197
|
+
|
|
198
|
+
if api_error is not None and not api_error.retryable:
|
|
199
|
+
self._logger.error(
|
|
200
|
+
"[intempt] non-retryable error; dropping batch",
|
|
201
|
+
extra={"status": status, "body": api_error.body, "count": len(batch)},
|
|
202
|
+
)
|
|
203
|
+
with self._lock:
|
|
204
|
+
del self._queue[: len(batch)]
|
|
205
|
+
# Dropping a malformed batch is not a transient failure, so it must
|
|
206
|
+
# not count toward the breaker.
|
|
207
|
+
self._consecutive_failures = 0
|
|
208
|
+
return "requeue"
|
|
209
|
+
|
|
210
|
+
self._consecutive_failures += 1
|
|
211
|
+
if self._consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
|
212
|
+
self._logger.error(
|
|
213
|
+
"[intempt] %d consecutive failures; stopping batching. "
|
|
214
|
+
"%d event(s) remain buffered.",
|
|
215
|
+
self._consecutive_failures,
|
|
216
|
+
self.size,
|
|
217
|
+
)
|
|
218
|
+
self._stopped = True
|
|
219
|
+
return "stop"
|
|
220
|
+
|
|
221
|
+
backoff_ms = self._backoff_ms(api_error)
|
|
222
|
+
# Starting a wait that outlives the close budget burns the remaining time
|
|
223
|
+
# and gives up anyway.
|
|
224
|
+
if (
|
|
225
|
+
self._close_deadline is not None
|
|
226
|
+
and time.monotonic() + backoff_ms / 1000 >= self._close_deadline
|
|
227
|
+
):
|
|
228
|
+
return "stop"
|
|
229
|
+
self._logger.warning("[intempt] send failed; retrying in %dms", backoff_ms)
|
|
230
|
+
self._sleep(backoff_ms / 1000)
|
|
231
|
+
return "requeue"
|
|
232
|
+
|
|
233
|
+
def _handle_too_large(self, batch: Sequence[dict[str, Any]]) -> str:
|
|
234
|
+
if len(batch) > 1:
|
|
235
|
+
self._batch_size = max(1, len(batch) // 2)
|
|
236
|
+
self._logger.warning(
|
|
237
|
+
"[intempt] 413 received; reducing batch size to %d", self._batch_size
|
|
238
|
+
)
|
|
239
|
+
return "requeue"
|
|
240
|
+
|
|
241
|
+
self._logger.error(
|
|
242
|
+
"[intempt] single event too large; dropping",
|
|
243
|
+
extra={"name": batch[0].get("name") if batch else None},
|
|
244
|
+
)
|
|
245
|
+
with self._lock:
|
|
246
|
+
del self._queue[:1]
|
|
247
|
+
self._consecutive_failures = 0
|
|
248
|
+
# The offending event is gone, so the width was never the problem. Any
|
|
249
|
+
# policy that keeps the width down here costs delivered events, because
|
|
250
|
+
# the widening ramp then has to climb back while the producer keeps
|
|
251
|
+
# filling the queue.
|
|
252
|
+
self._batch_size = min(self._options.size, self._max_request_events)
|
|
253
|
+
self._consecutive_drops += 1
|
|
254
|
+
if self._consecutive_drops == DROPS_BEFORE_WARNING:
|
|
255
|
+
# Hedged deliberately: this tally cannot tell a gateway whose limit
|
|
256
|
+
# is below one event from a burst of individually oversized events,
|
|
257
|
+
# and in the second case everything behind them sends fine.
|
|
258
|
+
self._logger.error(
|
|
259
|
+
"[intempt] %d events rejected as too large with none accepted in "
|
|
260
|
+
"between. Either those events are individually oversized, or the "
|
|
261
|
+
"gateway's request body limit is below a single event — if it is "
|
|
262
|
+
"the latter, every event will be dropped until the limit is raised.",
|
|
263
|
+
self._consecutive_drops,
|
|
264
|
+
)
|
|
265
|
+
return "requeue"
|
|
266
|
+
|
|
267
|
+
def _backoff_ms(self, api_error: IntemptApiError | None) -> int:
|
|
268
|
+
advised = None
|
|
269
|
+
if api_error is not None and api_error.retry_after_ms:
|
|
270
|
+
# Only a positive value. A zero or already-past Retry-After arrives
|
|
271
|
+
# here as 0 and would otherwise burn every attempt in milliseconds.
|
|
272
|
+
advised = api_error.retry_after_ms
|
|
273
|
+
computed = self._options.flush_ms * (2**self._consecutive_failures)
|
|
274
|
+
return min(MAX_RETRY_INTERVAL_MS, max(MIN_RETRY_INTERVAL_MS, advised or computed))
|
|
275
|
+
|
|
276
|
+
# -- close ------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
def _out_of_close_budget(self) -> bool:
|
|
279
|
+
return self._close_deadline is not None and time.monotonic() >= self._close_deadline
|
|
280
|
+
|
|
281
|
+
def close(self) -> None:
|
|
282
|
+
"""Drain within the budget, then report anything left behind."""
|
|
283
|
+
self._close_deadline = time.monotonic() + self._close_budget_s
|
|
284
|
+
try:
|
|
285
|
+
self.flush()
|
|
286
|
+
finally:
|
|
287
|
+
self._close_deadline = None
|
|
288
|
+
|
|
289
|
+
remaining = self.size
|
|
290
|
+
if remaining:
|
|
291
|
+
self._logger.error(
|
|
292
|
+
"[intempt] close() gave up after %.0fs with %d event(s) unsent.",
|
|
293
|
+
self._close_budget_s,
|
|
294
|
+
remaining,
|
|
295
|
+
)
|
|
296
|
+
self._stopped = True
|
|
297
|
+
self._cancel_timer()
|
|
298
|
+
if self._exit_hook is not None:
|
|
299
|
+
with contextlib.suppress(Exception): # pragma: no cover
|
|
300
|
+
atexit.unregister(self._exit_hook)
|
|
301
|
+
self._exit_hook = None
|
|
302
|
+
|
|
303
|
+
def _on_exit(self) -> None: # pragma: no cover - exercised by the exit hook
|
|
304
|
+
# An exit hook must never raise: it runs while the interpreter is
|
|
305
|
+
# shutting down and an exception there is reported without context.
|
|
306
|
+
with contextlib.suppress(Exception):
|
|
307
|
+
self.flush()
|
|
308
|
+
|
|
309
|
+
# -- timer ------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
def _schedule_flush(self) -> None:
|
|
312
|
+
if self._timer is not None or self._stopped:
|
|
313
|
+
return
|
|
314
|
+
timer = threading.Timer(self._options.flush_ms / 1000, self._on_timer)
|
|
315
|
+
# Never hold the process open just to wait for a flush.
|
|
316
|
+
timer.daemon = True
|
|
317
|
+
self._timer = timer
|
|
318
|
+
timer.start()
|
|
319
|
+
|
|
320
|
+
def _on_timer(self) -> None:
|
|
321
|
+
self._timer = None
|
|
322
|
+
# The timer thread must not die on a send failure: the retry policy has
|
|
323
|
+
# already logged it, and a dead timer stops every later auto-flush.
|
|
324
|
+
with contextlib.suppress(Exception): # pragma: no cover
|
|
325
|
+
self.flush()
|
|
326
|
+
|
|
327
|
+
def _cancel_timer(self) -> None:
|
|
328
|
+
if self._timer is not None:
|
|
329
|
+
self._timer.cancel()
|
|
330
|
+
self._timer = None
|