Skip to content

feat: honor the FDv1 fallback directive on success, error, and goodbye#312

Open
kinyoklion wants to merge 5 commits into
rlamb/sdk-2189/sse-fallback-directivefrom
rlamb/sdk-2189/fdv2-fallback-directive
Open

feat: honor the FDv1 fallback directive on success, error, and goodbye#312
kinyoklion wants to merge 5 commits into
rlamb/sdk-2189/sse-fallback-directivefrom
rlamb/sdk-2189/fdv2-fallback-directive

Conversation

@kinyoklion

@kinyoklion kinyoklion commented Jun 24, 2026

Copy link
Copy Markdown
Member

Stacked on #311 (the SseHttpError surfacing this depends on).

What

Honors the FDv1 fallback directive across every way the server can deliver it:

  • Successful connection: the directive is emitted with the basis change set, so the streamed payload is applied before the SDK falls back — previously the basis was dropped the moment the header was seen.
  • Any error response carrying the header (SseHttpError, recoverable or not): the streaming source closes the connection — which stops the client's own retry — and routes to the fallback tier.
  • goodbye event with protocolFallbackTTL: treated as an in-band fallback directive, for transports that cannot read response headers.

A single helper parses the directive (presence + TTL) from response headers, used for both the successful and error paths; the goodbye path reads its TTL in-band.

The streaming source maps SseHttpError by its recoverable flag: recoverable → interrupted (the client retries), unrecoverable → terminal. The FDv1 streaming source ignores recoverable errors so a transient 5xx no longer shuts it down.

When a fallback tier is configured the orchestrator engages it. When none is configured, the SDK stays interrupted and retries FDv2 after the directive's TTL (default 1 hour; a TTL of 0 means remain paused with no retry) rather than halting or reconnecting immediately. Source results carry the fallback TTL.

Tests

  • Orchestrator: apply-then-engage from a directive-bearing change set, and the three no-fallback cases (finite TTL retries, absent TTL defers, zero TTL pauses).
  • streaming_base: defer-on-success, the TTL header, the goodbye/TTL directive, and the SseHttpError paths (recoverable → interrupted/stays open, unrecoverable → terminal/closes, directive → terminal+TTL regardless of recoverability). protocol_handler covers protocolFallbackTTL parsing.
  • v3 contract harness fdv1-fallback suite passes end-to-end (816 total, 792 ran, exit 0), with no regression.

The contract-test-service capability + fdv1Fallback config wiring that exercises this in the harness lives on the e2e branch and will land with the v3 contract-tests PR.


Note

Medium Risk
Touches core FDv2 connection lifecycle, fallback tier selection, and flag payload application order; behavior changes on server-directed fallback are user-visible but covered by expanded unit tests.

Overview
Honors the server’s FDv1 fallback directive across headers, successful streams, HTTP errors, and in-band goodbye events, with TTL-aware orchestration when no FDv1 tier is configured.

Adds shared readFallbackDirective for x-ld-fd-fallback / x-ld-fd-fallback-ttl and threads fdv1FallbackTtl through FDv2SourceResult. protocolFallbackTTL on goodbye events is parsed and surfaced the same way for transports that cannot read headers.

Streaming: On a successful connection, the directive is deferred and emitted with the next change set so the basis payload is applied before fallback (previously the stream terminated immediately and dropped data). On SseHttpError, a fallback header wins over recoverability—connection closes and routes to fallback; recoverable errors stay interrupted with the client retrying. FDv1 StreamingDataSource no longer treats recoverable SSE HTTP errors as permanent shutdown.

Polling uses the shared header helper and maps fallback goodbyes to terminal fallback results (orchestrator goodbye recycle would ignore the directive).

Orchestrator: _processDirective / _scheduleFallbackRetry engage the FDv1 tier when configured; otherwise the SDK stays interrupted and retries FDv2 after TTL (default 1 hour), pauses indefinitely on TTL zero, with stoppable delay on shutdown.

Reviewed by Cursor Bugbot for commit 2dff40f. Bugbot is set up for automated code reviews on this repo. Configure here.

@kinyoklion kinyoklion force-pushed the rlamb/sdk-2189/sse-fallback-directive branch from 306fef8 to 2343975 Compare June 24, 2026 16:12
@kinyoklion kinyoklion force-pushed the rlamb/sdk-2189/fdv2-fallback-directive branch from b320efb to df4624d Compare June 24, 2026 16:12
@kinyoklion kinyoklion force-pushed the rlamb/sdk-2189/sse-fallback-directive branch from 2343975 to 6bcf11f Compare June 24, 2026 16:18
@kinyoklion kinyoklion force-pushed the rlamb/sdk-2189/fdv2-fallback-directive branch from df4624d to be0061f Compare June 24, 2026 16:18
Comment thread packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart
@kinyoklion kinyoklion force-pushed the rlamb/sdk-2189/fdv2-fallback-directive branch from be0061f to d2ba583 Compare June 24, 2026 16:22
Comment thread packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart Outdated
Comment thread packages/common_client/lib/src/data_sources/fdv2/streaming_base.dart Outdated
Comment thread packages/common_client/lib/src/data_sources/fdv2/streaming_base.dart Outdated
The directive (x-ld-fd-fallback header, or a goodbye event's
protocolFallbackTTL) is now handled in three places:

- On a successful connection the directive is emitted with the basis
  change set so the streamed payload is applied before the SDK falls back.
- On any error response (SseHttpError, recoverable or not) carrying the
  header, the streaming source closes the connection -- which stops the
  client's own retry -- and routes to the fallback tier.
- A goodbye carrying protocolFallbackTTL is treated as an in-band
  fallback directive (for transports that cannot read response headers).

A single helper parses the directive (presence + TTL) from response
headers, used for both successful and error responses; the goodbye path
reads its TTL in-band. The streaming source maps SseHttpError by its
recoverable flag: recoverable -> interrupted (the client retries),
unrecoverable -> terminal. The FDv1 streaming source ignores recoverable
errors so a transient 5xx no longer shuts it down.

When a fallback tier is configured the orchestrator engages it; when none
is configured it stays interrupted and retries FDv2 after the directive's
TTL (default one hour; zero means remain paused with no retry). Results
carry the fallback TTL.
@kinyoklion kinyoklion force-pushed the rlamb/sdk-2189/fdv2-fallback-directive branch from d2ba583 to 44ff900 Compare June 24, 2026 16:40
Comment thread packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart Outdated
Comment thread packages/common_client/lib/src/data_sources/fdv2/orchestrator.dart
The function classifies the directive and returns an action; the handling
is done by the caller based on that result.
Extract readFallbackDirective (presence + TTL) into fallback_directive.dart
so the streaming and polling sources interpret x-ld-fd-fallback /
x-ld-fd-fallback-ttl the same way. The polling source previously stamped
only fdv1Fallback and dropped the TTL; it now carries fdv1FallbackTtl too,
so a directive delivered on a polling response honors the server's
retry-after the same as on streaming.
@kinyoklion kinyoklion marked this pull request as ready for review June 24, 2026 19:52
@kinyoklion kinyoklion requested a review from a team as a code owner June 24, 2026 19:52
Comment thread packages/common_client/lib/src/data_sources/fdv2/polling_base.dart Outdated
// An initializer is one-shot and cannot retry itself;
// let the chain exhaust and leave retry timing to the
// synchronizer tier.
break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Initializer deferRetry skips TTL scheduling

Medium Severity

When a synchronizer reports a fallback directive with no FDv1 tier, _scheduleFallbackRetry sets _pendingRetryDelay or pauses on zero TTL. The new initializer deferRetry branch only breaks and never records TTL or pause, and initializer StatusResult paths only short-circuit on engageFdv1Fallback. A directive from FDv2PollingInitializer therefore does not get the defer/pause behavior this PR adds for synchronizers.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 41ee799. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I am skeptical on this one. It does mean, in this specific case, there is an extra request.

The orchestrator's goodbye path recycles without consulting the directive,
so a polling goodbye that carries a fallback directive -- in-band via the
goodbye's protocolFallbackTTL, or via the x-ld-fd-fallback response header
-- must be surfaced as a terminal fallback result, the same as the
streaming source does. Previously polling returned a plain goodbye and the
directive was dropped (the SDK re-polled instead of engaging/deferring).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2dff40f. Configure here.

fdv1Fallback: true,
fdv1FallbackTtl: protocolFallbackTtl,
)
: FDv2SourceResults.goodbyeResult(message: reason));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Streaming goodbye ignores header directive

High Severity

On ActionGoodbye, the streaming source only treats protocolFallbackTtl as a fallback directive and emits a normal goodbye otherwise. It does not consider _pendingDirective from the connection’s x-ld-fd-fallback headers, unlike FDv2PollingBase. A header-only directive on open followed by goodbye never reaches the orchestrator as fdv1Fallback, so the client keeps recycling instead of engaging or deferring FDv2 retry.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2dff40f. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant