Skip to content

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

Merged
Zaimwa9 merged 6 commits into
mainfrom
openhands/sdk-spec-v0.9.0
Nov 13, 2026
Merged

Zaimwa9 merged 6 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 11:07
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: 0cfea441-4eff-457d-a6b6-c85a2db13b2d

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

✅ 12 untouched benchmarks


Comparing openhands/sdk-spec-v0.9.0 (11df3af) with main (72c03c8)

Open in CodSpeed

Implements the SDK spec v0.9.0 for events:

- Event batches are sent up to 3 times, giving up on each attempt after
  3 seconds and backing off from 1 second, doubling, with full jitter.
  Batches the events API rejects are dropped, as are those whose last
  attempt fails with a 500; others are kept for the next flush, ahead of
  events tracked since. A 401 or 403 stops event tracking.
- A full buffer (1000 events) drops its oldest event.
- `Flagsmith.Client.flush_events/1` sends the tracked events and returns
  once they have been delivered, dropped or kept.
- `Flagsmith.Client.close/2` sends the buffered events within a timeout,
  and stops the client's events processor and poller.
- Local evaluation now reads experiment metadata and variant keys from the
  environment document, so experiment flags evaluated locally carry their
  experiment and variant, and send exposures.

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

Copy link
Copy Markdown
Contributor Author

Implemented SDK spec v0.9.0 in b6b19ed. The spec run was failing because the SDK had no steps (and no SDK behaviour) for event delivery retries, flushing, closing the client, or a client with both local evaluation and events.

Event delivery (lib/flagsmith_client_events_processor.ex)

  • A batch is sent up to 3 times. Each attempt gives up after 3 seconds (httpc's timeout, so the connection is closed). Retries back off from 1 s, doubling, with full jitter. One batch is in flight at a time (the GenServer).
  • Statuses 408, 429, 5xx and network errors are retried. Other 4xx drop the batch. A batch whose last attempt gets a 500 is dropped. Otherwise it is kept for the next flush, ahead of events tracked since.
  • 401/403 stop event tracking for the environment key.
  • A full buffer (1000) drops its oldest event instead of growing. Filling the buffer still triggers a send.
  • The events client no longer goes through Tesla.Middleware.Retry, since retrying is now done by the processor.

New public API (lib/flagsmith_client.ex)

  • Client.flush_events/1 sends the tracked events and returns once each is delivered, dropped, or kept.
  • Client.close/2 sends the buffered events within a timeout (5 s by default), then stops the client's events processor and poller. Events not sent by then are dropped. The processor's restart strategy is now :transient so that closing stops it for good.

Local evaluation of experiment flags (needed by the new @local-evaluation @events scenarios)

  • The environment document's feature-state metadata.experiment and the multivariate options' key were dropped during parsing. As a result, flags evaluated locally had no experiment or variant name, and no exposure was sent.
  • Both are now parsed, and the engine's selected variant is carried onto the flag. As in the reference engine, an enrolled identity that is served no variant gets "control".

Steps (sdk-spec/steps.exs): the four new steps are wired to the SDK calls above, with no spec values hardcoded.

Tests: unit tests cover retries and the 3 s attempt timeout, kept/dropped batches (400, 500 last attempt, 503 last attempt), 401/403, full-buffer dropping, close (sends the buffer, and returns within its timeout), and locally evaluated experiment exposures. Locally I ran mix format --check-formatted, mix test (425 tests, 0 failures) and mix dialyzer (all clean), and the spec at v0.9.0 with core events local-evaluation (24 passed).

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

@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, but a flush already running keeps sending after close/2 times out.

`close/2` stopped waiting when its timeout expired, but a flush already
running kept retrying for up to ~12 seconds. The processor is now killed
on timeout, and is started as a temporary child so that its supervisor
doesn't restart it; tracking an event starts a new one, as before.

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

Copy link
Copy Markdown
Contributor Author

Requesting changes. Spec passes, but a flush already running keeps sending after close/2 times out.

Addressed in f81d3f2 (details in the inline reply). When close/2 times out, it now kills the events processor and waits until the processor is gone. The processor is now a :temporary child so it isn't restarted; tracking an event starts a new one, as before. I added a unit test for a close that times out while a flush is retrying.

Checks run locally:

  • mix format --check-formatted: clean
  • mix test: 426 tests, 0 failures
  • mix dialyzer: clean
  • Spec v0.9.0 with core events local-evaluation: 24 passed

One more thing: test/flagsmith_poller_test.exs "the refresh works accordingly" fails some of the time here. It does the same on main (9 of 15 runs of that file under Docker). It waits only 15 ms, and I haven't changed it.

@flagsmithsmith
flagsmithsmith Bot requested a review from Zaimwa9 November 12, 2026 17:47
@Zaimwa9

Zaimwa9 commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

@themis-blindfold review

@themis-blindfold

Copy link
Copy Markdown

Review failed after 2 attempt(s) (OutputError). Check the worker logs.

@flagsmithsmith

Copy link
Copy Markdown
Contributor Author

@themis-blindfold review

The review run didn't produce any findings. It failed with "Review failed after 2 attempt(s) (OutputError)", so I have nothing to address and haven't changed anything. Re-running it is up to you.

The branch is unchanged at f81d3f2, and every check passes (spec v0.9.0, build and test, benchmarks). Your earlier point about close/2 timing out mid-flush is addressed in that commit.

@Zaimwa9

Zaimwa9 commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

@themis-blindfold review

@themis-blindfold

Copy link
Copy Markdown

⚖️ Themis review: 🟠 Fix before merge

The retry and shutdown APIs cover the main delivery flow, but several edge cases undermine the promised delivery and resource bounds. The build, SDK specification, and benchmark checks passed. Local tests could not be run because Elixir and Mix are unavailable.

Area Score
🎯 Correctness 2/5
🧪 Test coverage 3/5
📐 Code quality 3/5
🚀 Product impact 3/5

🟠 Majors

  • lib/flagsmith_client_events_processor.ex: Non-retryable server responses are retried or retained.
  • lib/flagsmith_client_events_processor.ex: An explicit flush can leave a failed batch unsent.
  • lib/flagsmith_client_events_processor.ex: A concurrent close can terminate a flush caller.
  • lib/flagsmith_client_events_processor.ex: Events awaiting processing bypass the buffer limit.
📝 Walkthrough
  • Event delivery - adds retries, bounded buffering, explicit flush and close operations.
  • Local evaluation - carries experiment metadata and variant keys into returned flags.
  • SDK specification - updates the reusable workflow and adds lifecycle steps.
🧪 How to verify
  1. Return 500 and 501 from the events service and check that each batch is dropped after one request.
  2. Exhaust retries, restore the service, and explicitly flush again without tracking another event.
  3. Start a flush against a slow service, then close with a short timeout and check the flush caller survives.
  4. Flood tracking while a request is stalled and inspect total pending events and memory.
  5. Automate: add regression tests for these delivery and concurrency cases.

Product take: Reliable delivery and predictable shutdown matter for experiment exposure data; these gaps can delay or lose events and affect host applications.

🧭 Assumptions & unverified claims

No unverified assumptions or claims.

May the next flush be a clean one · reviewed at f81d3f2

1 finding(s) anchored outside the diff (not posted inline)
  • lib/flagsmith_client_events_processor.ex:203 🟠 Major · 🏗️ Heavy lift

Bound events queued while delivery is in progress

Observed: delivery and retry sleeps run inside the GenServer, while tracking sends asynchronous casts; the 1,000-event limit is applied only after those messages are processed. Predicted: sustained tracking during a slow request would accumulate an unbounded mailbox, exceeding the advertised buffer limit and potentially exhausting host memory. Apply admission and eviction to all pending events, including those arriving during a send, and test a stalled delivery under load.

…'t wait on them

The events processor sent batches, retries included, inside the
GenServer, for up to ~12 seconds. Events tracked meanwhile piled up in
its mailbox, past the buffer's 1000-event limit, and a flush caller
exited when closing the client killed the processor.

Batches are now sent by a task. The processor keeps buffering events
while one is in flight, dropping the oldest once full; flush callers
wait for the batch in flight; and closing abandons it at the deadline,
replying `{:error, :closed}` to flush callers instead of killing the
processor. `flush/1` no longer exits if the processor stops between
being looked up and called.

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

Copy link
Copy Markdown
Contributor Author

Replies to the Themis review of f81d3f2. The three inline findings have their own replies. Here is where each point landed:

Non-retryable server responses are retried or retained.

No change. The spec at v0.9.0 retries 500s: in "A batch whose last attempt fails otherwise is kept, after server errors", the proxy answers 500, 500, 503, then 202 for the same batch. It drops the batch only when the last attempt gets a 500. How to treat other 5xx (such as 501) is left open in the spec; I've offered that choice in the inline reply.

An explicit flush can leave a failed batch unsent.

No change, because the spec's "A full buffer drops its oldest event" requires it. Re-sending the kept full-buffer batch on the explicit flush would deliver event_1, and that scenario expects exactly event_2…event_1001. I've offered two alternatives in the inline reply, if you want different behaviour.

A concurrent close can terminate a flush caller.

Fixed in 19b4272. close/2 now cancels the batch being sent instead of killing the processor. Waiting flush/1 callers get {:error, :closed}, and flush/1 catches exits from its call.

Bound events queued while delivery is in progress (anchored outside the diff)

Fixed in 19b4272. This was real, both in this PR and before it: delivery and retry backoff ran inside the GenServer, so tracking casts piled up in its mailbox while a batch was retried. Batches are now sent by a task. The processor keeps handling tracked events during a send and drops the oldest once its buffer holds 1,000. At most one full buffer plus the batch in flight is held in memory, and a kept batch is merged back to 1,000.

The new test "events tracked while a batch is being sent are buffered, up to the buffer's limit" holds a batch of 1,000 at the events API, then tracks 1,500 more. It checks that the processor's mailbox is empty and it holds 1,000 buffered events. It also checks that the newest 1,000 are sent once the held batch completes.

Checks:

  • mix format --check-formatted: clean
  • mix test: 429 tests, 0 failures (3 runs)
  • Events tests: 6 more runs, all passing
  • mix dialyzer: clean
  • CI on 19b4272: all checks pass, including the v0.9.0 spec run

My last local spec run got 15 of 24 through; the other 9 failed in the runner's setup. The runner's admin calls to Flagsmith failed, with 403 on POST /api/v1/projects/ and timeouts fetching environment documents. All 15 event delivery and close scenarios passed, and CI ran all 24.

@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. Sending batches in a task fixes the close and mailbox problems, but it introduces two regressions: under steady tracking flush_events/1 can wait indefinitely, and exposures dropped from a full buffer are never tracked again.

…s again

- A flush arriving while a batch is being sent, with events tracked since,
  now waits for one more batch at most, then gets a reply. Previously it
  waited until a batch ended with nothing tracked meanwhile, which steady
  tracking could postpone indefinitely. A flush arriving with nothing
  tracked since only waits for the batch in flight.
- Exposures dropped by a full buffer, or when a kept batch no longer fits
  it, are forgotten, so that they're tracked again. Previously their keys
  stayed, and those exposures were skipped as duplicates for the life of
  the processor.

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

Copy link
Copy Markdown
Contributor Author

Requesting changes. Sending batches in a task fixes the close and mailbox problems, but it introduces two regressions: under steady tracking flush_events/1 can wait indefinitely, and exposures dropped from a full buffer are never tracked again.

Both are fixed in 11df3af; details in the inline replies.

  • Flush wait: a flush that arrives while a batch is in flight, with events tracked since, now waits for one more batch at most, then gets a reply. With nothing tracked since, it only waits for the batch in flight.
  • Dropped exposures: exposures dropped by a full buffer, or when a kept batch no longer fits it, now have their keys forgotten, so they're tracked again.

There are 3 new regression tests, and all 3 fail on 19b4272.

Checks:

  • mix format --check-formatted: clean
  • mix test: 432 tests, 0 failures
  • Events tests: 10 runs in a row, all passing
  • mix dialyzer: clean
  • CI on 11df3af: all checks pass, including the v0.9.0 spec run

@flagsmithsmith
flagsmithsmith Bot requested a review from Zaimwa9 November 13, 2026 15:10
@Zaimwa9
Zaimwa9 merged commit 6d42e90 into main Nov 13, 2026
5 checks passed
@Zaimwa9
Zaimwa9 deleted the openhands/sdk-spec-v0.9.0 branch November 13, 2026 15:15
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