Skip to content

feat: Retry event delivery and flush on close - #129

Open
flagsmithsmith[bot] wants to merge 5 commits into
mainfrom
openhands/sdk-spec-v0.9.0
Open

flagsmithsmith[bot] wants to merge 5 commits into
mainfrom
openhands/sdk-spec-v0.9.0

Conversation

@flagsmithsmith

Copy link
Copy Markdown
Contributor

Contributes to Flagsmith/sdk-spec#23

renovate Bot and others added 2 commits November 12, 2026 09:37
Its entry in Flagsmith/sdk-spec's compatibility matrix at v0.9.0: `core events local-evaluation`.
@flagsmithsmith flagsmithsmith Bot added the sdk-spec Bumps of the Flagsmith SDK behavioural spec label Nov 12, 2026
@coderabbitai

coderabbitai Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: fe40e990-634d-4e04-b3d4-b65b2cdad94c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@codspeed

codspeed Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 15 untouched benchmarks


Comparing openhands/sdk-spec-v0.9.0 (daead64) with main (e968b8c)

Open in CodSpeed

Implement SDK spec v0.9.0:

- Send a batch of events up to 3 times, one batch at a time, backing off
  from 1 second with full jitter, with a 3 second request timeout. Keep a
  batch whose last attempt failed transiently for the next flush, unless
  that attempt got a 500; drop a batch the events API rejects; stop event
  tracking when it refuses the environment key (401/403). A full buffer
  drops its oldest event.
- Add `Client#flush_events`, which returns once the tracked events are
  delivered, dropped or kept, and `Client#close(timeout_seconds:)`, which
  stops background work and sends the buffered events within the timeout.
- Record exposures under local evaluation: carry the environment
  document's `metadata.experiment` and multivariate option keys through to
  identity flags' `experiment` and `variant`.
- Wire the new spec steps in sdk-spec/steps.rb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@flagsmithsmith

Copy link
Copy Markdown
Contributor Author

Implemented SDK spec v0.9.0 in 4b281a4. CI had failed because the SDK was missing 4 steps: the local evaluation + events client, flushing events, closing the client, and tracking a numbered range of events. Behind those steps were 2 missing SDK features.

Event delivery (Flagsmith::EventProcessor)

  • Replaced Faraday's single retry with explicit delivery. A batch is sent up to 3 times, one batch at a time, with a 3 s request timeout. Retries back off from 1 s, doubling, with full jitter.
  • 5xx, 408, 429 and connection errors are retried. If the last attempt fails transiently, the batch is kept for the next flush, ahead of newer events. The exception is a 500 on the last attempt: that batch is dropped, so it is not resent forever. Other 4xx responses drop the batch. 401/403 stops event tracking.
  • When the buffer is full, the oldest event is dropped. A flush started by a full buffer no longer sends again events that a concurrent flush already attempted.
  • stop(timeout_seconds) stops the interval thread without killing a batch in flight. It sends the buffered events, waits at most the timeout, then cancels any pending retry.

Client

  • New Client#flush_events: returns once the tracked events are delivered, dropped or kept, retries included.
  • New Client#close(timeout_seconds: 5): stops polling and realtime, then the event processor.

Exposures under local evaluation

  • The environment document's feature states carry metadata.experiment, and multivariate options carry key. The SDK used to drop both.
  • Both are now mapped into the evaluation context. Identity flags from the document get experiment and variant, so get_experiment_flag records exposures, including control.
  • Environment flags carry no experiment, matching the API.
  • This follows feat: Record exposures under local evaluation flagsmith-python-client#276.

Tests and steps

  • Unit tests cover retries, keep/drop rules, 401/403, the full buffer, the close timeout, flush_events/close, and local-evaluation exposures (new experiment_environment.json fixture).
  • sdk-spec/steps.rb maps the new steps to these SDK calls.
  • Locally, rspec (450 examples) and rubocop pass. My local spec run wasn't reliable (the Flagsmith container kept timing out), so CI is the check.

Unrelated to this change, possibly a bug: under local evaluation, identity overrides get priority Float::INFINITY. With "lowest wins", a segment override with priority 0 beats them. I left this alone.

@flagsmithsmith
flagsmithsmith Bot marked this pull request as ready for review November 12, 2026 10:26
@flagsmithsmith
flagsmithsmith Bot requested a review from a team as a code owner November 12, 2026 10:26
@flagsmithsmith
flagsmithsmith Bot requested review from a team and emyller and removed request for a team November 12, 2026 10:26

@Zaimwa9 Zaimwa9 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. Spec passes and the threading is sound, but once a kept batch fills the buffer, every track restarts a retry cycle. Please also make close terminal.

Comment thread lib/flagsmith/sdk/event_processor.rb Outdated
Comment on lines +274 to +282
drop_oldest if @events.length >= @max_buffer_items
@events << event
next false if @flush_pending || @events.length < @max_buffer_items
@tracked_count += 1
next if @flush_pending || @events.length < @max_buffer_items

@flush_pending = true
@tracked_count
end
Thread.new { scheduled_flush } if schedule_flush
Thread.new { scheduled_flush(tracked_count) } if tracked_count

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once a kept batch fills the buffer, every track starts a new retry cycle. Skip the full-buffer flush while a failed batch is held.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6aa2ec4. While the buffer holds a batch whose attempts all failed (@holding_failed_batch, set when a batch is kept and cleared when it is taken again), a full buffer no longer schedules a flush. Tracking still drops the oldest event, and the held batch goes out with the next interval or explicit flush. Covered by "waits for the next flush rather than attempting the kept batch again" and "sends a full buffer without waiting again once the kept batch is sent".

Writing that second test found a related bug, also fixed in 6aa2ec4. A flush skipped any event already attempted, even when the attempt happened before the flush was called. So flush on a kept batch with no new events sent nothing. A flush now only skips events sent by a batch taken after it was requested. New test: "sends a kept batch with the next flush, even with no events tracked since".

Comment on lines +83 to 90
def stop(timeout_seconds = CLOSE_TIMEOUT_SECONDS)
RunningEventProcessors.delete(self)
flush
change_state { @stopping = true }
@interval = nil
final_flush = Thread.new { send_batch }
@logger.warn "Gave up sending events after #{timeout_seconds} seconds while closing" unless final_flush.join(timeout_seconds)
change_state { @closed = true }
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After close, track still buffers and flush still sends. Make both no-ops once stopping.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6aa2ec4. Once stop (and so Client#close) begins, track_event/track_exposure_event buffer nothing, and flush (and so Client#flush_events) sends nothing; each logs at debug level ("Not tracking … event: the client is closed" / "Not flushing events: the client is closed"). start also does nothing after a stop. stop itself still sends what was buffered before the close, through its own final flush. Covered by the new "when stopped" specs.

…minal

- While the buffer holds a batch whose attempts all failed, a full buffer
  waits for the next flush instead of starting another round of attempts
  on every tracked event.
- Once the processor is stopping, tracking and flushing do nothing, and it
  does not start again.
- A flush now only skips events a batch taken after it was requested
  already sent, so a flush resends a kept batch even when no event was
  tracked since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@flagsmithsmith

Copy link
Copy Markdown
Contributor Author

Requesting changes. Spec passes and the threading is sound, but once a kept batch fills the buffer, every track restarts a retry cycle. Please also make close terminal.

Both are fixed in 6aa2ec4; details are in the inline replies.

  • While a kept batch is held, a full buffer waits for the next flush.
  • After close, tracking and flushing do nothing.

That change also fixes a related bug: a flush with no events tracked since a kept batch failed used to send nothing. It now resends the kept batch.

Locally, rspec (456 examples, 6 consecutive runs) and rubocop pass.

A flush requested while a full-buffer flush was sending a batch counted
no batch taken since, so it sent the kept batch again once that batch's
attempts failed. Count settled batches instead: one in flight at the
request settles after it, and covers the events tracked up to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@flagsmithsmith

Copy link
Copy Markdown
Contributor Author

A follow-up to 6aa2ec4, in daead64. In CI, the spec run on 6aa2ec4 failed "A full buffer drops its oldest event": event_1 was delivered.

The cause was in 6aa2ec4's new rule for when a flush skips events another flush already sent. It counted batches taken. If the full-buffer flush had already taken the batch when the application called flush, that flush saw no batch taken since its request. So it resent the kept batch, and the resend got through.

It now counts batches settled. A batch in flight when a flush is requested settles after the request, so the flush skips the events it covered. The new spec "does not send again a batch in flight when the flush was requested" fails on 6aa2ec4 and passes now. All checks are green on daead64.

@flagsmithsmith
flagsmithsmith Bot requested a review from Zaimwa9 November 12, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sdk-spec Bumps of the Flagsmith SDK behavioural spec

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant