Skip to content

Commit d8a8c09

Browse files
nbudinclaude
andcommitted
Fix race condition in bearer-rejection retry link that triggered "link chain completed without emitting a value"
When the server sends X-Bearer-Token-Rejected, BatchHttpLink calls observer.next(result) and then observer.complete() synchronously in the same Promise .then(). The next handler detected the rejection and kicked off an async token refresh without forwarding the value — but the synchronous complete: () => observer.complete() fired before the retry could emit, triggering Apollo Client 4's validateDidEmitValue guard. Fix: call activeSubscription.unsubscribe() synchronously inside the next handler the moment bearer rejection is detected. This marks the rxjs Subscriber as isStopped before BatchHttpLink's .complete() runs, so that call becomes a no-op. The retry then proceeds normally and emits a value through a fresh subscription. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 97194c1 commit d8a8c09

1 file changed

Lines changed: 9 additions & 2 deletions

File tree

app/javascript/useIntercodeApolloClient.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,21 @@ export function buildRefreshOnRejectedBearerLink(authenticationManager?: Authent
123123
let activeSubscription: { unsubscribe(): void } | undefined;
124124

125125
const runOnce = () => {
126-
// Tear down the previous attempt so its `complete`/`error` events
127-
// can't race the retry.
126+
// Tear down any previous attempt before starting a new one.
128127
activeSubscription?.unsubscribe();
129128
activeSubscription = next(operation).subscribe({
130129
next: (result) => {
131130
const response = operation.getContext().response as Response | undefined;
132131
if (!attemptedRefresh && response?.headers?.get(BEARER_REJECTED_HEADER) === 'true') {
133132
attemptedRefresh = true;
133+
// Unsubscribe synchronously so that BatchHttpLink's upcoming
134+
// synchronous `complete` call (which immediately follows `next`
135+
// in its promise chain) is a no-op on the now-closed subscriber.
136+
// Without this, `observer.complete()` would fire before the retry
137+
// emits a value, triggering Apollo's "link chain completed without
138+
// emitting a value" error.
139+
activeSubscription?.unsubscribe();
140+
activeSubscription = undefined;
134141
// Mark the access token as known-bad so the auth-headers link
135142
// can't reuse it, then refresh and re-run the chain.
136143
authenticationManager.jwtToken = undefined;

0 commit comments

Comments
 (0)