Skip to content

Commit 6aef3ac

Browse files
authored
fix: Http clients getting stuck on 401 (#1034)
1 parent 97358d0 commit 6aef3ac

4 files changed

Lines changed: 53 additions & 3 deletions

File tree

.changeset/witty-students-cross.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/shared-internals': patch
3+
---
4+
5+
Fixed an issue where clients using the HTTP connection method could get stuck in an endless auth-error retry loop when their token expired while disconnected (e.g. after network interruptions or device sleep).

packages/node/tests/sync.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,43 @@ describe('Sync', () => {
9292
]);
9393
});
9494

95+
mockSyncServiceTest('refetches credentials when HTTP stream returns 401', async ({ syncService }) => {
96+
// The /sync/stream 401 should invalidate the cached credentials so the retry fetches a fresh token.
97+
let fetchCredentialsCount = 0;
98+
const connector = new TestConnector();
99+
connector.fetchCredentials = async () => {
100+
fetchCredentialsCount++;
101+
return {
102+
endpoint: 'https://powersync.example.org',
103+
token: `token-${fetchCredentialsCount}`
104+
};
105+
};
106+
107+
syncService.installRequestInterceptor(async (request) => {
108+
if (request.url.endsWith('/sync/stream') && request.headers.get('Authorization') === 'Token token-1') {
109+
return new Response(
110+
JSON.stringify({ error: { code: 'PSYNC_S2103', description: 'Authentication required' } }),
111+
{
112+
status: 401,
113+
headers: { 'content-type': 'application/json' }
114+
}
115+
);
116+
}
117+
return undefined;
118+
});
119+
120+
const database = await syncService.createDatabase();
121+
database.connect(connector, {
122+
connectionMethod: SyncStreamConnectionMethod.HTTP,
123+
retryDelayMs: 100
124+
});
125+
126+
// The first attempt uses token-1 and gets a 401. The SDK must invalidate the cached
127+
// credentials and retry with a freshly fetched token-2, which connects successfully.
128+
await vi.waitFor(() => expect(fetchCredentialsCount).toBeGreaterThanOrEqual(2), { timeout: 2000 });
129+
await vi.waitFor(() => expect(syncService.connectedListeners).toHaveLength(1), { timeout: 2000 });
130+
});
131+
95132
mockSyncServiceTest('reconnects immediately after changed connection', async ({ syncService }) => {
96133
let database = await syncService.createDatabase();
97134
database.connect(new TestConnector(), {

packages/node/tests/utils.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,14 @@ export function createMockSyncServiceTest(bson: boolean) {
110110
const databaseName = `test-${crypto.randomUUID()}.db`;
111111

112112
const listeners: Listener[] = [];
113-
let requestInterceptor: (request: Request) => Promise<void> = async () => {};
113+
let requestInterceptor: (request: Request) => Promise<Response | void> = async () => {};
114114

115115
const inMemoryFetch: typeof fetch = async (info, init?) => {
116116
const request = new Request(info, init);
117-
await requestInterceptor(request);
117+
const intercepted = await requestInterceptor(request);
118+
if (intercepted) {
119+
return intercepted;
120+
}
118121

119122
if (request.url.endsWith('/sync/stream')) {
120123
const body = await request.json();
@@ -201,7 +204,7 @@ export function createMockSyncServiceTest(bson: boolean) {
201204
export const mockSyncServiceTest = createMockSyncServiceTest(false);
202205

203206
export interface MockSyncService {
204-
installRequestInterceptor(interceptor: (request: Request) => Promise<void>): void;
207+
installRequestInterceptor(interceptor: (request: Request) => Promise<Response | void>): void;
205208
pushLine: (line: StreamingSyncLine) => void;
206209
connectedListeners: any[];
207210

packages/shared-internals/src/client/sync/stream/AbstractRemote.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,11 @@ export abstract class AbstractRemote {
315315
message: `Could not POST streaming to ${path} - ${res.status} - ${res.statusText}: ${text}`,
316316
error
317317
});
318+
319+
if (res.status === 401) {
320+
this.invalidateCredentials();
321+
}
322+
318323
throw error;
319324
}
320325

0 commit comments

Comments
 (0)