featureflip 2.4.2__tar.gz → 2.5.1__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 (48) hide show
  1. {featureflip-2.4.2 → featureflip-2.5.1}/CHANGELOG.md +31 -0
  2. {featureflip-2.4.2 → featureflip-2.5.1}/PKG-INFO +3 -3
  3. {featureflip-2.4.2 → featureflip-2.5.1}/README.md +1 -1
  4. {featureflip-2.4.2 → featureflip-2.5.1}/pyproject.toml +1 -1
  5. {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/_core.py +46 -22
  6. featureflip-2.5.1/src/featureflip/_events.py +304 -0
  7. featureflip-2.5.1/src/featureflip/_http.py +416 -0
  8. {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/client.py +5 -1
  9. {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/evaluation.py +140 -21
  10. featureflip-2.5.1/src/featureflip/exceptions.py +45 -0
  11. {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/models.py +12 -5
  12. featureflip-2.5.1/tests/golden/vectors.json +3049 -0
  13. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_client.py +49 -0
  14. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_evaluation.py +29 -0
  15. featureflip-2.5.1/tests/test_events.py +769 -0
  16. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_golden.py +72 -0
  17. featureflip-2.5.1/tests/test_golden_malformed.py +102 -0
  18. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_http.py +160 -1
  19. featureflip-2.4.2/src/featureflip/_events.py +0 -123
  20. featureflip-2.4.2/src/featureflip/_http.py +0 -259
  21. featureflip-2.4.2/src/featureflip/exceptions.py +0 -19
  22. featureflip-2.4.2/tests/golden/vectors.json +0 -1044
  23. featureflip-2.4.2/tests/test_events.py +0 -279
  24. {featureflip-2.4.2 → featureflip-2.5.1}/.gitignore +0 -0
  25. {featureflip-2.4.2 → featureflip-2.5.1}/LICENSE +0 -0
  26. {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/__init__.py +0 -0
  27. {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/_polling.py +0 -0
  28. {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/_semver.py +0 -0
  29. {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/_streaming.py +0 -0
  30. {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/_version.py +0 -0
  31. {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/config.py +0 -0
  32. {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/context.py +0 -0
  33. {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/detail.py +0 -0
  34. {featureflip-2.4.2 → featureflip-2.5.1}/src/featureflip/inspector.py +0 -0
  35. {featureflip-2.4.2 → featureflip-2.5.1}/tests/__init__.py +0 -0
  36. {featureflip-2.4.2 → featureflip-2.5.1}/tests/integration/__init__.py +0 -0
  37. {featureflip-2.4.2 → featureflip-2.5.1}/tests/integration/test_api.py +0 -0
  38. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_config.py +0 -0
  39. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_context.py +0 -0
  40. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_core.py +0 -0
  41. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_detail.py +0 -0
  42. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_exceptions.py +0 -0
  43. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_factory.py +0 -0
  44. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_inspector.py +0 -0
  45. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_models.py +0 -0
  46. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_polling.py +0 -0
  47. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_semver.py +0 -0
  48. {featureflip-2.4.2 → featureflip-2.5.1}/tests/test_streaming.py +0 -0
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.5.1 — 2026-08-24
4
+
5
+ ### Fixed
6
+
7
+ - A date operand is now trimmed of exactly the whitespace the evaluation engine trims (tab, newline, vertical tab, form feed, carriage return and space), and is rejected outright if it still carries a NUL, another control character, or a non-ASCII whitespace character. Each SDK had been relying on its own language's `trim`, and no two of those cover the same set, so the same operand could match on one SDK and match nothing on another. ([#2468](https://github.com/canopy-labs/featureflip/issues/2468))
8
+ - The ISO-8601 grammar no longer shifts with the interpreter version. `fromisoformat` now only ever sees a canonicalised string, so a nanosecond fraction and a basic `+0500` offset parse identically on 3.10 and 3.13 — previously they were accepted on newer interpreters and rejected on 3.10. ([#2468](https://github.com/canopy-labs/featureflip/issues/2468))
9
+ - An ISO-8601 operand naming hour 24 (`2024-01-01T24:00:00`) matches nothing, rather than rolling over to the next day. ([#2468](https://github.com/canopy-labs/featureflip/issues/2468))
10
+
11
+ ## 2.5.0 — 2026-08-24
12
+
13
+ ### Fixed
14
+
15
+ - Analytics events survive a transient failure of the events endpoint. The queue was emptied before the POST and the batch discarded on any error, so every 503 the edge returned silently lost a batch. A retryable failure — any 5xx, a 429, or a network error or timeout — now puts the batch back at the front of the queue for the next flush. ([#2456](https://github.com/canopy-labs/featureflip/issues/2456))
16
+ - A permanently rejected batch is still dropped, and now says so. A 400, 401 or 403 fails identically however often it is resent, so the batch is discarded with a warning naming it as non-retryable instead of being retried forever at the expense of every later event. ([#2456](https://github.com/canopy-labs/featureflip/issues/2456))
17
+ - A failing endpoint no longer receives one request per tracked event. A re-queued batch leaves the queue at its flush threshold, so the batch-size trigger would otherwise have fired on every subsequent event; it now backs off for one flush interval after a retryable failure, leaving the periodic flush to do the retrying. ([#2456](https://github.com/canopy-labs/featureflip/issues/2456))
18
+ - `stop()` makes a single final flush attempt rather than retrying, so shutdown cannot hang while the endpoint is down. ([#2456](https://github.com/canopy-labs/featureflip/issues/2456))
19
+
20
+ ### Changed
21
+
22
+ - A flush sends at most `flush_batch_size` events per request, looping until the queue is empty, rather than posting the whole queue in one body. Now that failed batches are kept, the queue can sit at its bound through an outage, and a body that large invites a 413 — which is not retryable, so the entire backlog would have been dropped by the path meant to preserve it. ([#2456](https://github.com/canopy-labs/featureflip/issues/2456))
23
+ - The event queue is bounded at 10,000 events, overridable with `EventProcessor(max_queue_size=...)`. Past the bound the oldest events are shed and the number dropped is logged, so an extended outage sheds the stalest analytics instead of growing the queue without limit. ([#2456](https://github.com/canopy-labs/featureflip/issues/2456))
24
+
25
+ ## 2.4.3 — 2026-08-23
26
+
27
+ ### Fixed
28
+
29
+ - An unrecognised `FlagType` no longer fails the entire config fetch. One unknown value in one flag discarded the whole payload, so a single new server-side type could blank out every flag the SDK served. ([#2401](https://github.com/canopy-labs/featureflip/issues/2401))
30
+ - An unrecognised condition operator now fails closed instead of matching every user. The default arm returned `false`, which a negated condition then inverted to `true` — so a config naming an operator the SDK did not know could silently target everyone. ([#2262](https://github.com/canopy-labs/featureflip/issues/2262))
31
+ - An unrecognised condition operator is tolerated at deserialization instead of discarding the whole config, matching the string-typed SDKs. ([#2372](https://github.com/canopy-labs/featureflip/issues/2372))
32
+ - `identify()` and `track()` put the same payload on the wire as every other server SDK. The field set and shapes had drifted per language, so the same call produced different events depending on which SDK sent it. ([#2359](https://github.com/canopy-labs/featureflip/issues/2359))
33
+
3
34
  ## 2.4.2 — 2026-08-05
4
35
 
5
36
  ### Fixed
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: featureflip
3
- Version: 2.4.2
3
+ Version: 2.5.1
4
4
  Summary: Python SDK for Featureflip - a feature flag SaaS platform
5
5
  Project-URL: Homepage, https://featureflip.io
6
6
  Project-URL: Documentation, https://featureflip.io/docs/sdks/python/
@@ -122,7 +122,7 @@ print(detail.rule_id) # Rule ID if reason is RULE_MATCH
122
122
  # Track custom events
123
123
  client.track("checkout-completed", {"user_id": "123"}, metadata={"total": 99.99})
124
124
 
125
- # Identify users for segment building
125
+ # Record an identify event for analytics (does not affect flag evaluation)
126
126
  client.identify({"user_id": "123", "email": "user@example.com", "plan": "pro"})
127
127
 
128
128
  # Force flush pending events
@@ -90,7 +90,7 @@ print(detail.rule_id) # Rule ID if reason is RULE_MATCH
90
90
  # Track custom events
91
91
  client.track("checkout-completed", {"user_id": "123"}, metadata={"total": 99.99})
92
92
 
93
- # Identify users for segment building
93
+ # Record an identify event for analytics (does not affect flag evaluation)
94
94
  client.identify({"user_id": "123", "email": "user@example.com", "plan": "pro"})
95
95
 
96
96
  # Force flush pending events
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "featureflip"
7
- version = "2.4.2"
7
+ version = "2.5.1"
8
8
  description = "Python SDK for Featureflip - a feature flag SaaS platform"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -39,6 +39,20 @@ logger = structlog.get_logger()
39
39
  T = TypeVar("T")
40
40
 
41
41
 
42
+ def _compact_event(event: dict[str, Any]) -> dict[str, Any]:
43
+ """Drop the optional wire fields when they carry nothing.
44
+
45
+ ``userId`` and ``metadata`` are both optional on the backend DTO. Every
46
+ server SDK omits them rather than sending ``null`` or an empty object, so
47
+ the same call produces the same bytes whichever SDK made it (#2359).
48
+ """
49
+ if event.get("userId") is None:
50
+ event.pop("userId", None)
51
+ if not event.get("metadata"):
52
+ event.pop("metadata", None)
53
+ return event
54
+
55
+
42
56
  def _resolve_user_id(context: dict[str, Any]) -> str | None:
43
57
  """Resolve the built-in user id from a raw context dict.
44
58
 
@@ -493,17 +507,23 @@ class _SharedFeatureflipCore:
493
507
  # Send the backend SdkEventDto shape: top-level userId, no `context`.
494
508
  # See apps/evaluation-api Controllers/SdkController.RecordEvents — the
495
509
  # backend has no context/value/reason field, so anything else is dropped.
496
- event = {
497
- "type": "Custom",
498
- "flagKey": event_name,
499
- "userId": _resolve_user_id(context or {}),
500
- "metadata": metadata or {},
501
- "timestamp": datetime.now(timezone.utc).isoformat(),
502
- }
510
+ event = _compact_event(
511
+ {
512
+ "type": "Custom",
513
+ "flagKey": event_name,
514
+ "userId": _resolve_user_id(context or {}),
515
+ "metadata": metadata,
516
+ "timestamp": datetime.now(timezone.utc).isoformat(),
517
+ }
518
+ )
503
519
  self._event_processor.queue_event(event)
504
520
 
505
521
  def identify(self, context: dict[str, Any]) -> None:
506
- """Send user attributes for segment building."""
522
+ """Record an identify event for analytics.
523
+
524
+ Does not affect flag evaluation: targeting rules and segments are matched
525
+ against the context passed to each variation call.
526
+ """
507
527
  if self._test_mode_values is not None or self._event_processor is None:
508
528
  return
509
529
  # Promote the user id to the top-level userId field and put the remaining
@@ -514,13 +534,15 @@ class _SharedFeatureflipCore:
514
534
  for k, v in (context or {}).items()
515
535
  if k not in ("user_id", "userId")
516
536
  }
517
- event = {
518
- "type": "Identify",
519
- "flagKey": "$identify",
520
- "userId": _resolve_user_id(context or {}),
521
- "metadata": attributes,
522
- "timestamp": datetime.now(timezone.utc).isoformat(),
523
- }
537
+ event = _compact_event(
538
+ {
539
+ "type": "Identify",
540
+ "flagKey": "$identify",
541
+ "userId": _resolve_user_id(context or {}),
542
+ "metadata": attributes,
543
+ "timestamp": datetime.now(timezone.utc).isoformat(),
544
+ }
545
+ )
524
546
  self._event_processor.queue_event(event)
525
547
 
526
548
  def flush(self) -> None:
@@ -543,13 +565,15 @@ class _SharedFeatureflipCore:
543
565
  """
544
566
  if self._event_processor is None:
545
567
  return
546
- event: dict[str, Any] = {
547
- "type": "Evaluation",
548
- "flagKey": key,
549
- "userId": _resolve_user_id(context or {}),
550
- "variation": variation_key,
551
- "timestamp": datetime.now(timezone.utc).isoformat(),
552
- }
568
+ event: dict[str, Any] = _compact_event(
569
+ {
570
+ "type": "Evaluation",
571
+ "flagKey": key,
572
+ "userId": _resolve_user_id(context or {}),
573
+ "variation": variation_key,
574
+ "timestamp": datetime.now(timezone.utc).isoformat(),
575
+ }
576
+ )
553
577
  self._event_processor.queue_event(event)
554
578
 
555
579
  # --- Shutdown ---
@@ -0,0 +1,304 @@
1
+ """Event processor for batching and flushing analytics events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from typing import Any, Protocol
8
+
9
+ import httpx
10
+ import structlog
11
+
12
+ logger = structlog.get_logger()
13
+
14
+ #: Upper bound on buffered events.
15
+ #:
16
+ #: Only reachable once a failed send starts returning batches to the queue faster than
17
+ #: they drain -- that is, a sustained outage of the events endpoint. Past the bound the
18
+ #: OLDEST events are shed, which caps memory and keeps the freshest analytics. It also
19
+ #: means a long outage sheds the re-queued (stale) batches rather than starving the new
20
+ #: events behind them, so the SDK degrades to losing the oldest data instead of hoarding
21
+ #: data it cannot send.
22
+ DEFAULT_MAX_QUEUE_SIZE = 10_000
23
+
24
+
25
+ def _is_retryable_send_failure(error: BaseException) -> bool:
26
+ """Whether the same batch could plausibly succeed if it were sent again.
27
+
28
+ For an HTTP answer the status decides: 5xx (the production edge answers this
29
+ endpoint with a 503 at a low constant rate) and 429, where the server is
30
+ explicitly asking the caller to come back later. Anything else -- 401/403 means
31
+ the SDK key was rejected, 400 means the body is malformed -- will fail
32
+ identically next time, and retrying it forever would pin the queue at its bound
33
+ and starve every later event.
34
+
35
+ Everything httpx raises for a request that never got an answer (connect, read
36
+ and write errors, timeouts, protocol errors) derives from ``httpx.HTTPError``,
37
+ and none of it says the server rejected the batch, so all of it is retryable.
38
+ A failure that is not an ``HTTPError`` at all -- a serialization ``TypeError``,
39
+ a bug in a custom client -- is deterministic and is dropped rather than retried.
40
+ """
41
+ if isinstance(error, httpx.HTTPStatusError):
42
+ status = error.response.status_code
43
+ return status >= 500 or status == 429
44
+ return isinstance(error, httpx.HTTPError)
45
+
46
+
47
+ class HttpClientProtocol(Protocol):
48
+ """Protocol for HTTP client to allow loose coupling."""
49
+
50
+ def post_events(self, events: list[dict[str, Any]]) -> None:
51
+ """Send events to the API."""
52
+ ...
53
+
54
+
55
+ class EventProcessor:
56
+ """Batches and flushes analytics events.
57
+
58
+ This processor collects events in an internal queue and sends them to the API
59
+ either when the batch reaches a threshold size or after a time interval.
60
+ Events are also flushed when the processor is stopped.
61
+
62
+ A send that fails for a transient reason keeps its batch for the next flush
63
+ rather than discarding it, and each request carries at most one batch; see
64
+ :meth:`flush`. The queue is bounded so that a long outage cannot grow it
65
+ without limit.
66
+
67
+ Thread-safe: multiple threads can safely queue events concurrently.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ http_client: HttpClientProtocol,
73
+ flush_interval: float = 30.0,
74
+ flush_batch_size: int = 100,
75
+ max_queue_size: int = DEFAULT_MAX_QUEUE_SIZE,
76
+ ) -> None:
77
+ """Initialize the event processor.
78
+
79
+ Args:
80
+ http_client: HTTP client for sending events to the API.
81
+ flush_interval: Seconds between automatic flushes (default 30).
82
+ flush_batch_size: Number of events that triggers an immediate flush (default 100).
83
+ max_queue_size: Most events to buffer before shedding the oldest
84
+ (default :data:`DEFAULT_MAX_QUEUE_SIZE`).
85
+ """
86
+ self._http = http_client
87
+ self._flush_interval = flush_interval
88
+ # Defence-in-depth against a non-positive batch size: flush() takes
89
+ # flush_batch_size events per pass, so a value below 1 would take nothing
90
+ # and loop forever on a non-empty queue. Config.__post_init__ already
91
+ # rejects such values, but this class is constructed directly too.
92
+ self._flush_batch_size = flush_batch_size if flush_batch_size >= 1 else 1
93
+ # A non-positive bound would shed every event on the way in, silently turning
94
+ # analytics off. Fall back rather than honour it.
95
+ self._max_queue_size = (
96
+ max_queue_size if max_queue_size > 0 else DEFAULT_MAX_QUEUE_SIZE
97
+ )
98
+ self._queue: list[dict[str, Any]] = []
99
+ self._lock = threading.Lock()
100
+ self._stop_event = threading.Event()
101
+ self._thread: threading.Thread | None = None
102
+ # Monotonic deadline before which the batch-size trigger must not start
103
+ # another flush; 0.0 means "not backing off". See _requeue_locked.
104
+ self._next_auto_flush_at = 0.0
105
+ # True while a batch-size-triggered flush is in flight. See queue_event.
106
+ self._auto_flush_in_flight = False
107
+ # True from stop() onward: the queue is closed to re-queues because nothing
108
+ # will flush it again.
109
+ self._stopping = False
110
+
111
+ def queue_event(self, event: dict[str, Any]) -> None:
112
+ """Add an event to the queue.
113
+
114
+ Thread-safe. If the queue reaches the flush_batch_size threshold,
115
+ an immediate flush is triggered -- unless a retryable failure has put that
116
+ trigger in backoff, or an earlier size-triggered flush is still running.
117
+
118
+ Args:
119
+ event: Event dictionary to queue. Should contain at minimum a 'type' field.
120
+ """
121
+ should_flush = False
122
+ with self._lock:
123
+ self._queue.append(event)
124
+ dropped = self._trim_to_bound_locked()
125
+ if (
126
+ len(self._queue) >= self._flush_batch_size
127
+ and not self._auto_flush_in_flight
128
+ and time.monotonic() >= self._next_auto_flush_at
129
+ ):
130
+ # Claim the latch under the same lock that read it, so two threads
131
+ # tracking concurrently cannot both start a flush.
132
+ self._auto_flush_in_flight = True
133
+ should_flush = True
134
+
135
+ if dropped:
136
+ logger.warning(
137
+ "event_queue_overflow",
138
+ dropped=dropped,
139
+ max_queue_size=self._max_queue_size,
140
+ )
141
+
142
+ if should_flush:
143
+ try:
144
+ self.flush()
145
+ finally:
146
+ with self._lock:
147
+ self._auto_flush_in_flight = False
148
+
149
+ def flush(self) -> None:
150
+ """Flush all queued events immediately.
151
+
152
+ Blocks until the flush is complete. If the queue is empty, no API call is
153
+ made. Send failures are logged and never raised: a retryable one keeps its
154
+ batch for the next flush, a permanent one drops it.
155
+ """
156
+ # One request per batch rather than one for the whole queue. Re-queuing
157
+ # failed batches is what lets the queue grow to its bound during an outage,
158
+ # and posting 10,000 events in a single body risks the server rejecting it
159
+ # outright -- a 413 is not retryable, so the entire backlog would be dropped
160
+ # by the very path that exists to preserve it (#2456).
161
+ while True:
162
+ with self._lock:
163
+ if not self._queue:
164
+ return
165
+ # Drained BEFORE the send, so the batch has to be held here to be
166
+ # put back if the send fails. Without that a single transient
167
+ # failure discarded it outright -- and the production edge answers
168
+ # this endpoint with a 503 at a low constant rate, so evaluation
169
+ # analytics were being lost steadily rather than exceptionally.
170
+ events_to_send = self._drain_batch_locked(self._flush_batch_size)
171
+
172
+ try:
173
+ logger.debug("flushing_events", count=len(events_to_send))
174
+ self._http.post_events(events_to_send)
175
+ logger.debug("events_flushed_successfully", count=len(events_to_send))
176
+ with self._lock:
177
+ # The endpoint is answering again; let the size trigger work.
178
+ self._next_auto_flush_at = 0.0
179
+ except Exception as e:
180
+ if _is_retryable_send_failure(e):
181
+ with self._lock:
182
+ dropped = self._requeue_locked(events_to_send)
183
+ logger.warning(
184
+ "event_flush_error",
185
+ error=str(e),
186
+ count=len(events_to_send),
187
+ dropped=dropped,
188
+ )
189
+ # Stop here. The batch is back at the head of the queue this
190
+ # loop is draining, so continuing would re-send it immediately
191
+ # and spin for as long as the endpoint stayed down.
192
+ return
193
+ logger.warning(
194
+ "event_flush_dropped",
195
+ error=str(e),
196
+ count=len(events_to_send),
197
+ )
198
+ # Dropped, so the queue shrank and the loop still terminates. Carry
199
+ # on to the next batch rather than letting one poison batch block
200
+ # the backlog queued behind it.
201
+
202
+ def _drain_batch_locked(self, max_count: int) -> list[dict[str, Any]]:
203
+ """Take up to ``max_count`` of the OLDEST events. Caller holds ``_lock``.
204
+
205
+ Oldest-first so the wire order matches the order the events happened in.
206
+ """
207
+ take = min(max_count, len(self._queue))
208
+ batch = self._queue[:take]
209
+ del self._queue[:take]
210
+ return batch
211
+
212
+ def _requeue_locked(self, events: list[dict[str, Any]]) -> int:
213
+ """Return a failed batch to the FRONT of the queue. Caller holds ``_lock``.
214
+
215
+ The front, so the next flush retries it ahead of newer events and rough
216
+ chronological order survives an outage.
217
+
218
+ Deliberately does NOT retry inline. The batch is back in the queue the
219
+ caller just drained, so re-sending it here would spin against a down
220
+ endpoint for as long as it stayed down, on whichever thread called
221
+ :meth:`flush` -- an evaluation thread, in the batch-size case. The periodic
222
+ flush thread is the retry vehicle.
223
+
224
+ Returns:
225
+ How many events were shed to stay within the bound.
226
+ """
227
+ # Shutting down: nothing will flush again, so buffering here would only
228
+ # leak. Report the whole batch as dropped instead.
229
+ if self._stopping:
230
+ return len(events)
231
+
232
+ self._queue[:0] = events
233
+
234
+ # A re-queued batch leaves the queue at or above flush_batch_size, so without
235
+ # this gate the next tracked event would trigger another flush -- turning a
236
+ # failing endpoint into one request per evaluation, which is worse for the
237
+ # server than the dropping this replaced. It only suppresses the size
238
+ # trigger between periodic flushes; the flush loop keeps retrying.
239
+ self._next_auto_flush_at = time.monotonic() + self._flush_interval
240
+
241
+ return self._trim_to_bound_locked()
242
+
243
+ def _trim_to_bound_locked(self) -> int:
244
+ """Shed oldest-first until the queue fits the bound. Caller holds ``_lock``.
245
+
246
+ Returns:
247
+ How many events were dropped.
248
+ """
249
+ overflow = len(self._queue) - self._max_queue_size
250
+ if overflow <= 0:
251
+ return 0
252
+ del self._queue[:overflow]
253
+ return overflow
254
+
255
+ def start(self) -> None:
256
+ """Start the background flush thread.
257
+
258
+ The background thread will periodically flush events at the configured interval.
259
+ """
260
+ self._stop_event.clear()
261
+ with self._lock:
262
+ # A restart re-opens the queue, and any backoff left over from the outage
263
+ # that preceded the stop is stale.
264
+ self._stopping = False
265
+ self._next_auto_flush_at = 0.0
266
+ self._thread = threading.Thread(target=self._run, daemon=True)
267
+ self._thread.start()
268
+ logger.info("event_processor_started", flush_interval=self._flush_interval)
269
+
270
+ def stop(self) -> None:
271
+ """Stop the background thread, make one final flush attempt, then let go.
272
+
273
+ Blocks until the thread has stopped. Exactly one attempt: retrying until the
274
+ queue drained would hang shutdown for as long as the endpoint stayed down.
275
+ Nothing will flush after this, so whatever the attempt could not deliver is
276
+ discarded rather than kept.
277
+ """
278
+ self._stop_event.set()
279
+ if self._thread and self._thread.is_alive():
280
+ self._thread.join(timeout=5.0)
281
+
282
+ # Closes the queue to re-queues, so the final flush below cannot put a failed
283
+ # batch back into a queue no one will drain.
284
+ with self._lock:
285
+ self._stopping = True
286
+
287
+ self.flush()
288
+
289
+ # Anything a concurrent queue_event slipped in during the flush.
290
+ with self._lock:
291
+ discarded = len(self._queue)
292
+ self._queue = []
293
+ if discarded:
294
+ logger.warning("events_discarded_on_stop", count=discarded)
295
+
296
+ logger.info("event_processor_stopped")
297
+
298
+ def _run(self) -> None:
299
+ """Main loop for the background flush thread."""
300
+ while not self._stop_event.is_set():
301
+ # Wait for either the interval or stop signal
302
+ self._stop_event.wait(self._flush_interval)
303
+ if not self._stop_event.is_set():
304
+ self.flush()