Repository navigation
feat: Retry event delivery and flush on close - #283
flagsmithsmith[bot] wants to merge 6 commits into
Conversation
Its entry in Flagsmith/sdk-spec's compatibility matrix at v0.9.0: `core events local-evaluation`.
Event delivery now survives transient failures of the events API, as SDK spec v0.8.0 requires: - A batch is sent up to 3 times with a 3 s request timeout, backing off from 1 s, doubling, with full jitter, one batch in flight at a time. - A batch whose last attempt fails transiently is kept for the next flush, ahead of newer events, unless that attempt got a 500. - A batch the events API rejects is dropped, and a 401/403 stops event tracking. - A full buffer drops its oldest event. `Flagsmith.flush_events()` sends the tracked events and waits for the outcome, and `Flagsmith.close(timeout_seconds)` stops background work and sends the buffered events within the timeout. The spec steps file wires the new steps, and a client with both local evaluation and events. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit d019e76)
A timer flush running while the client closed scheduled another one, so the client kept flushing after `close()`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit ca330ed)
|
Important Review skippedBot user detected. To trigger a single review, invoke the
|
Zaimwa9
left a comment
There was a problem hiding this comment.
I started a review but will stop here, grab the first comments to understand the issues in the implementation.
Overall spec passes, but the event processor's structure is not good and needs a rework, not patches: threads are created three ways, with a pile of locks and flags. Use one long-lived worker thread, like EnvironmentDataPollingManager. __del__ must not block, flush_events() must never raise, and close() must be terminal.
The event processor created threads three ways (a timer, a thread per full buffer, a thread per close) behind a pile of locks and flags. One worker thread now sends the buffered events on a timer, when the buffer fills, on flush, and once more on close. - `close()` is terminal and idempotent: tracking and flushing stop, and no request starts once it returns. - `__del__` signals the worker without waiting for it. - `flush_events()` never raises, and logs when events are not enabled. - Events are serialised when tracked: one that isn't JSON serialisable is logged and not tracked, instead of failing every later flush. - The retry policy is one set of status codes (408, 429, 500, 502, 503, 504), and only connection errors and timeouts are retried. Other statuses, e.g. 501, drop the batch instead of keeping it forever. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Replying to the review:
Reworked rather than patched, in cd3aa8c.
It now calls
It logs
Once it is called, tracking and flushing are no-ops. It waits at most Checks: 398 unit tests pass, with full coverage of the changed lines. |
|
@themis-blindfold review |
|
Review failed after 2 attempt(s) (OutputError). Check the worker logs. |
This asked the themis-blindfold bot for a review, and its run failed before it produced one ("Review failed after 2 attempt(s) (OutputError)"). That failure is in the bot's worker, not in this repository, so there is no review feedback to address and I haven't changed any code. I haven't re-triggered it, so that you decide whether to run it again. The branch is unchanged at cd3aa8c, and every check is green, including |
|
@themis-blindfold review |
|
Review failed after 2 attempt(s) (OutputError). Check the worker logs. |
The re-run failed the same way ("Review failed after 2 attempt(s) (OutputError)"), so there is still no review feedback to address, and the code is unchanged at cd3aa8c. Both failures happened in the bot's worker, and its logs aren't visible from this pull request, so I can't tell why it fails here. All checks are green, including |
|
@themis-blindfold review |
|
Review failed after 2 attempt(s) (OutputError). Check the worker logs. |
The third run also failed before producing a review ("Review failed after 2 attempt(s) (OutputError)"), so there is nothing new to address. The code is unchanged at cd3aa8c, and all checks are green. The bot fails the same way every time on this pull request, so the cause is likely on its side; its worker logs would show why. |
|
@themis-blindfold review |
| "X-Environment-Key": self._environment_key, | ||
| "Flagsmith-SDK-User-Agent": DEFAULT_USER_AGENT, | ||
| }, | ||
| flush_count = self._tracked_count |
There was a problem hiding this comment.
🟠 Major · 🏗️ Heavy lift
Flush retained events even when no new events were tracked
Observed: after three 503 responses, the batch is retained but _sent_count already equals _tracked_count; a second flush() returns without another request. This violates the requirement to retry the retained batch on the next flush, so applications cannot explicitly drain it until a new event arrives or the timer fires. Track flush requests independently of the tracked-event count and wait for the worker to process the retained batch.
There was a problem hiding this comment.
Right, fixed in de59e68. flush() used to wait only for events tracked since the last send, so a flush right after a failed send returned without sending the kept batch. It now asks the worker for a send of the whole buffer, kept events included. The exception is a send already running that covers every tracked event: then it waits for that send instead. That exception keeps the spec's "A full buffer drops its oldest event" passing, where a flush called while the full-buffer send is in flight must not send the batch again.
Tests: test_flush__kept_batch_and_no_new_events__kept_batch_sent (fails before the fix) and test_flush__send_in_flight_covers_tracked_events__waits_without_resending.
There was a problem hiding this comment.
Fixed: a subsequent flush requests another send of the retained batch (flagsmith/analytics.py:278), with a regression test covering three 503 responses (tests/test_event_processor.py:374). Resolving this thread.
|
|
||
| def _drop_oldest_events(self) -> None: | ||
| while len(self._buffer) > self._max_buffer: | ||
| self._buffer.popleft() |
There was a problem hiding this comment.
🟠 Major · 🏗️ Heavy lift
Remove deduplication keys when exposures are evicted
Observed: with a one-item buffer, tracking an exposure, then a custom event, then the same exposure leaves only the custom event buffered: eviction removes the exposure but leaves its key in _buffered_exposure_keys. The later exposure is discarded as a duplicate even though no copy remains to send. Store enough information with each buffered event to remove its exposure key on eviction, and keep the keys consistent when retained batches are requeued.
There was a problem hiding this comment.
Right, fixed in de59e68. Each buffered event now carries its exposure key. Dropping the oldest event forgets its key, and a kept batch puts its keys back when it is re-queued, so the keys always match the exposures in the buffer.
Test: test_track_exposure_event__equal_exposure_dropped_from_full_buffer__sent, which follows your scenario and fails before the fix.
There was a problem hiding this comment.
The eviction case is fixed, but requeuing can still leave keys inconsistent. If an exposure is tracked again while its earlier batch is in flight, a kept batch can contain the same key as the buffer. When the older copy is evicted, flagsmith/analytics.py:371 removes the shared key even though the newer copy remains, so another duplicate is accepted. Account for duplicate keys when requeuing and evicting (flagsmith/analytics.py:353).
| EVENTS_MAX_ATTEMPTS: typing.Final[int] = 3 | ||
| EVENTS_RETRY_BASE_SECONDS: typing.Final[float] = 1 | ||
| DEFAULT_EVENTS_CLOSE_TIMEOUT_SECONDS: typing.Final[float] = 5 | ||
| RETRIED_STATUS_CODES: typing.Final[typing.FrozenSet[int]] = frozenset( |
There was a problem hiding this comment.
🟠 Major · ⚡ Quick win
Drop a batch immediately on HTTP 500
Observed: 500 is in the retry set, so the worker retries it and can retain the batch if a later attempt fails differently. The linked delivery requirements specify dropping the batch on any 500 without retrying. Predicted: repeated requests could duplicate events if the server processed the first request before returning 500. Remove 500 from the retry set and update the retry tests to require an immediate drop.
There was a problem hiding this comment.
Not changed: the spec this PR implements requires retrying a 500. The issue's first summary did say "Drop the batch on any other status, including 500", but the spec was later changed on purpose (sdk-spec commit "Retry a 500, then drop the batch instead of keeping it"). features/event_delivery.feature at v0.9.0 now has:
- "A batch whose last attempt gets a server error is dropped": 503, 503, 500, so the batch is dropped after the 500.
- "A batch whose last attempt fails otherwise is kept, after server errors": the SDK must send 500, 500, 503, then 202 for the same batch.
Dropping on the first 500 would fail the second scenario, and the human review here also asked for 500 in the retry set. The duplicate-delivery risk you raise is real, though, so if the events API cannot deduplicate, it would be worth raising in sdk-spec.
There was a problem hiding this comment.
You're right; I relied on the outdated issue summary. I withdraw the finding. The checkout tests both sequences in tests/test_event_processor.py:335 and tests/test_event_processor.py:501.
⚖️ Themis review: 🟠 Fix before mergeThe single-worker delivery path adds bounded shutdown and retries, but retained batches cannot be retried by a subsequent explicit flush, and buffer eviction can suppress an exposure that is no longer buffered. The 500 response policy also differs from the linked delivery requirements. The recorded CI checks passed.
🟠 Majors
⚖️ Acknowledged
📝 Walkthrough
🧪 How to verify
Product take: Reliable event delivery is a solid improvement for experimentation analytics; missed or repeated delivery can affect the data users rely on. 🧭 Assumptions & unverified claimsNo unverified assumptions or claims. May the next flush actually flush · reviewed at cd3aa8c |
A flush right after a failed send returned without sending the kept batch, because it only waited for events tracked since. It now asks the worker for a send, unless a send already running covers every tracked event. An exposure dropped from a full buffer left its key behind, so the same exposure tracked again was skipped as a duplicate of an event that was no longer buffered. Each buffered event now carries its exposure key, which is forgotten when the event is dropped and restored when a failed batch is kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This run produced a review (🟠 Fix before merge, at cd3aa8c). Replies to its three majors, also answered inline:
Fixed in de59e68. A flush now always sends the buffer, kept events included, unless a send already running covers every tracked event.
Fixed in de59e68. An exposure's key is forgotten when the event is dropped from a full buffer, and restored when a failed batch is kept.
Not changed. The spec at v0.9.0 requires retrying a 500: "A batch whose last attempt fails otherwise is kept, after server errors" expects 500, 500, 503, then 202 for the same batch. Only a 500 on the last attempt drops the batch. The text the bot quotes is from the issue's earlier summary, which the spec later changed on purpose. Details are in the inline reply. Checks: 401 unit tests pass, stable over 40 repeated runs, and the new regression tests fail without the fix. |
Zaimwa9
left a comment
There was a problem hiding this comment.
Requesting changes. During an events API outage the full-buffer wake restarts the retry cycle back to back instead of waiting for the flush interval, and max_buffer_items=0 now drops every event (inline).
| len(self._buffer) >= self._max_buffer | ||
| and self._tracked_count > self._sent_count |
There was a problem hiding this comment.
During an outage the buffer stays full of kept events, so any event tracked during a retry cycle wakes the worker for another full cycle as soon as the current one ends, ignoring the flush interval (in a scaled-down 503 outage: 62 requests where timer pacing gives about 22). Wake on a full buffer only once a full buffer of new events has arrived, e.g. self._tracked_count - self._sent_count >= self._max_buffer.
| return | ||
|
|
||
| def _drop_oldest_events(self) -> None: | ||
| while len(self._buffer) > self._max_buffer: |
There was a problem hiding this comment.
With max_buffer_items=0 every tracked event is dropped here as soon as it is buffered, whereas on main each event was sent straight away. Clamp values below 1 in __init__.
Contributes to Flagsmith/sdk-spec#23