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 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