feat: honor the FDv1 fallback directive on success, error, and goodbye#312
feat: honor the FDv1 fallback directive on success, error, and goodbye#312kinyoklion wants to merge 5 commits into
Conversation
306fef8 to
2343975
Compare
b320efb to
df4624d
Compare
2343975 to
6bcf11f
Compare
df4624d to
be0061f
Compare
be0061f to
d2ba583
Compare
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.
d2ba583 to
44ff900
Compare
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.
| // An initializer is one-shot and cannot retry itself; | ||
| // let the chain exhaust and leave retry timing to the | ||
| // synchronizer tier. | ||
| break; |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 41ee799. Configure here.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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).
❌ 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)); |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 2dff40f. Configure here.


Stacked on #311 (the
SseHttpErrorsurfacing this depends on).What
Honors the FDv1 fallback directive across every way the server can deliver it:
SseHttpError, recoverable or not): the streaming source closes the connection — which stops the client's own retry — and routes to the fallback tier.goodbyeevent withprotocolFallbackTTL: 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
SseHttpErrorby itsrecoverableflag: 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
0means remain paused with no retry) rather than halting or reconnecting immediately. Source results carry the fallback TTL.Tests
streaming_base: defer-on-success, the TTL header, the goodbye/TTL directive, and theSseHttpErrorpaths (recoverable → interrupted/stays open, unrecoverable → terminal/closes, directive → terminal+TTL regardless of recoverability).protocol_handlercoversprotocolFallbackTTLparsing.fdv1-fallbacksuite passes end-to-end (816 total, 792 ran, exit 0), with no regression.The contract-test-service capability +
fdv1Fallbackconfig 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
readFallbackDirectiveforx-ld-fd-fallback/x-ld-fd-fallback-ttland threadsfdv1FallbackTtlthroughFDv2SourceResult.protocolFallbackTTLon 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. FDv1StreamingDataSourceno 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/_scheduleFallbackRetryengage 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.