Skip to content

Commit 72f648a

Browse files
authored
Merge pull request #35 from code-rabi/refactor/auth-polling-deadline-and-error-propagation
refactor(auth): tighten browser auth polling and error propagation (followup to #25)
2 parents cbf0e0f + bbd8147 commit 72f648a

4 files changed

Lines changed: 134 additions & 25 deletions

File tree

src/ib-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,12 @@ export class IBClient {
239239
} else {
240240
Logger.warn("[REAUTH] Re-authentication request sent but auth status is still false, will retry via interceptor");
241241
this.isAuthenticated = false;
242+
this.stopTickle();
242243
}
243244
} catch (error) {
244245
Logger.warn("[REAUTH] Re-authentication failed, will fall back to interceptor-based auth:", error);
245246
this.isAuthenticated = false;
247+
this.stopTickle();
246248
}
247249
}
248250

src/tool-handlers.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,17 @@ export class ToolHandlers {
121121
try {
122122
await this.context.ibClient.reauthenticate();
123123
} catch (e) {
124-
Logger.warn("[ENSURE-AUTH] Reauthenticate after status check failed, but status is true:", e);
124+
Logger.warn("[ENSURE-AUTH] Reauthenticate after status check failed, will re-check auth status:", e);
125+
}
126+
// Re-check auth status to honor the ensureAuth contract: if reauthenticate left
127+
// the session unauthenticated, propagate the error rather than silently proceeding.
128+
const finalAuthStatus = await this.context.ibClient.checkAuthenticationStatus();
129+
if (!finalAuthStatus) {
130+
const port = this.context.gatewayManager
131+
? this.context.gatewayManager.getCurrentPort()
132+
: this.context.config.IB_GATEWAY_PORT;
133+
const authUrl = `https://${this.context.config.IB_GATEWAY_HOST}:${port}`;
134+
throw new Error(`Authentication required. Please use the 'authenticate' tool to complete the authentication process at ${authUrl}.`);
125135
}
126136
}
127137
}
@@ -159,35 +169,40 @@ export class ToolHandlers {
159169
* After browser opens for OAuth, poll the gateway until authenticated,
160170
* then trigger reauthenticate to establish the REST API session.
161171
* This bridges the gap between browser-based OAuth and the REST API auth state.
172+
*
173+
* Polling is bounded by a deadline (~2 minutes from start) rather than by attempt
174+
* count, so the upper bound matches the documented timeout regardless of backoff.
162175
*/
163176
private startBrowserAuthPolling(authUrl: string, port: number): void {
164-
const maxAttempts = 60; // 2 minutes total
177+
const pollWindowMs = 120_000; // 2 minutes total
165178
const initialDelay = 2000; // 2 second initial delay
179+
const maxDelay = 10_000;
180+
const deadline = Date.now() + pollWindowMs;
166181
let attempts = 0;
167182

168183
const poll = async () => {
169184
attempts++;
170-
Logger.log(`[BROWSER-AUTH-POLL] Attempt ${attempts}/${maxAttempts}`);
185+
Logger.log(`[BROWSER-AUTH-POLL] Polling ${authUrl} (port ${port}) attempt ${attempts} until ${new Date(deadline).toISOString()}`);
171186

172187
try {
173188
const isAuth = await this.context.ibClient.checkAuthenticationStatus();
174-
189+
175190
if (isAuth) {
176-
Logger.log("[BROWSER-AUTH-POLL] Authentication detected, reauthenticating REST session");
191+
Logger.log(`[BROWSER-AUTH-POLL] Authentication detected for ${authUrl} (port ${port}), reauthenticating REST session`);
177192
// Trigger reauthenticate to establish REST API session
178193
await this.context.ibClient.reauthenticate();
179-
Logger.log("[BROWSER-AUTH-POLL] Reauthentication successful, REST session established");
194+
Logger.log(`[BROWSER-AUTH-POLL] Reauthentication successful for ${authUrl} (port ${port}), REST session established`);
180195
return; // Success, stop polling
181196
}
182197
} catch (error) {
183-
Logger.warn("[BROWSER-AUTH-POLL] Poll attempt failed:", error);
198+
Logger.warn(`[BROWSER-AUTH-POLL] Poll attempt ${attempts} failed for ${authUrl} (port ${port}):`, error);
184199
}
185200

186-
if (attempts < maxAttempts) {
187-
const delay = Math.min(initialDelay + (attempts * 500), 10000);
201+
const delay = Math.min(initialDelay + (attempts * 500), maxDelay);
202+
if (Date.now() + delay < deadline) {
188203
setTimeout(poll, delay);
189204
} else {
190-
Logger.warn("[BROWSER-AUTH-POLL] Timed out waiting for browser authentication");
205+
Logger.warn(`[BROWSER-AUTH-POLL] Timed out waiting for browser authentication at ${authUrl} (port ${port}) after ${attempts} attempts`);
191206
}
192207
};
193208

test/ib-client.test.ts

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -377,9 +377,9 @@ describe('IBClient', () => {
377377
data: { authenticated: false },
378378
}),
379379
};
380-
380+
381381
vi.mocked(axios.create).mockReturnValueOnce(mockAuthClient as any);
382-
382+
383383
// Should not throw — handled internally
384384
await expect(client.reauthenticate()).resolves.not.toThrow();
385385
expect(mockAuthClient.post).toHaveBeenCalledWith('/iserver/reauthenticate');
@@ -390,12 +390,79 @@ describe('IBClient', () => {
390390
post: vi.fn().mockRejectedValue(new Error('Connection refused')),
391391
get: vi.fn(),
392392
};
393-
393+
394394
vi.mocked(axios.create).mockReturnValueOnce(mockAuthClient as any);
395-
395+
396396
// Should not throw — errors are caught internally
397397
await expect(client.reauthenticate()).resolves.not.toThrow();
398398
});
399+
400+
it('should stop tickle when reauth status returns false', async () => {
401+
vi.useFakeTimers();
402+
try {
403+
// Step 1: bring tickle up by simulating a successful auth status check.
404+
const checkClient = {
405+
get: vi.fn().mockResolvedValue({ data: { authenticated: true } }),
406+
};
407+
vi.mocked(axios.create).mockReturnValueOnce(checkClient as any);
408+
await client.checkAuthenticationStatus();
409+
410+
// Capture the tickle axios instance the next time axios.create is called
411+
// (startTickle uses axios.create per ping).
412+
const tickleClient = {
413+
post: vi.fn().mockResolvedValue({ data: {} }),
414+
};
415+
// Step 2: trigger reauth failure (status=false). reauthenticate() itself
416+
// calls axios.create once; subsequent tickle pings (which should NOT happen)
417+
// would also call axios.create.
418+
const reauthClient = {
419+
post: vi.fn().mockResolvedValue({ data: {} }),
420+
get: vi.fn().mockResolvedValue({ data: { authenticated: false } }),
421+
};
422+
vi.mocked(axios.create).mockReturnValueOnce(reauthClient as any);
423+
// Any further axios.create calls (e.g., from a stray tickle) would hit this
424+
// mock and produce visible POST /tickle calls.
425+
vi.mocked(axios.create).mockReturnValue(tickleClient as any);
426+
427+
await client.reauthenticate();
428+
429+
// Advance well past the tickle interval (30s). If tickle were still running
430+
// we would see /tickle POSTs against tickleClient.
431+
await vi.advanceTimersByTimeAsync(120_000);
432+
expect(tickleClient.post).not.toHaveBeenCalled();
433+
} finally {
434+
vi.useRealTimers();
435+
}
436+
});
437+
438+
it('should stop tickle when reauth throws', async () => {
439+
vi.useFakeTimers();
440+
try {
441+
// Bring tickle up first.
442+
const checkClient = {
443+
get: vi.fn().mockResolvedValue({ data: { authenticated: true } }),
444+
};
445+
vi.mocked(axios.create).mockReturnValueOnce(checkClient as any);
446+
await client.checkAuthenticationStatus();
447+
448+
const tickleClient = {
449+
post: vi.fn().mockResolvedValue({ data: {} }),
450+
};
451+
const reauthClient = {
452+
post: vi.fn().mockRejectedValue(new Error('Connection refused')),
453+
get: vi.fn(),
454+
};
455+
vi.mocked(axios.create).mockReturnValueOnce(reauthClient as any);
456+
vi.mocked(axios.create).mockReturnValue(tickleClient as any);
457+
458+
await client.reauthenticate();
459+
460+
await vi.advanceTimersByTimeAsync(120_000);
461+
expect(tickleClient.post).not.toHaveBeenCalled();
462+
} finally {
463+
vi.useRealTimers();
464+
}
465+
});
399466
});
400467

401468
describe('Cleanup', () => {

test/tool-handlers.test.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -352,25 +352,39 @@ describe('ToolHandlers', () => {
352352

353353
it('should call reauthenticate when auth status flips to true', async () => {
354354
mockIBClient.checkAuthenticationStatus = vi.fn()
355-
.mockResolvedValueOnce(false) // Continue past early return
356-
.mockResolvedValueOnce(true); // Browser path: now authenticated
355+
.mockResolvedValueOnce(false) // Initial check: not yet authenticated (skip early return)
356+
.mockResolvedValueOnce(true) // Browser path: now authenticated
357+
.mockResolvedValueOnce(true); // Final re-check after reauth: still authenticated
357358
mockIBClient.reauthenticate = vi.fn().mockResolvedValue(undefined);
358359

359360
await (handlers as any).ensureAuth();
360361

361362
expect(mockIBClient.reauthenticate).toHaveBeenCalled();
362363
});
363364

364-
it('should proceed even if reauthenticate fails', async () => {
365+
it('should proceed when reauthenticate fails but session is still authenticated', async () => {
365366
mockIBClient.checkAuthenticationStatus = vi.fn()
366-
.mockResolvedValueOnce(false)
367-
.mockResolvedValueOnce(true);
367+
.mockResolvedValueOnce(false) // Initial check: skip early return
368+
.mockResolvedValueOnce(true) // Browser path: authenticated
369+
.mockResolvedValueOnce(true); // Final re-check still passes
368370
mockIBClient.reauthenticate = vi.fn().mockRejectedValue(new Error('Reauth failed'));
369371

370-
// Should not throw — error is caught and logged
372+
// Reauth error is swallowed because the final auth status check still succeeds.
371373
await expect((handlers as any).ensureAuth()).resolves.not.toThrow();
372374
expect(mockIBClient.reauthenticate).toHaveBeenCalled();
373375
});
376+
377+
it('should throw when reauthenticate fails and session is no longer authenticated', async () => {
378+
mockIBClient.checkAuthenticationStatus = vi.fn()
379+
.mockResolvedValueOnce(false) // Initial check: skip early return
380+
.mockResolvedValueOnce(true) // Browser path: authenticated
381+
.mockResolvedValueOnce(false); // Final re-check: session lost
382+
mockIBClient.reauthenticate = vi.fn().mockRejectedValue(new Error('Reauth failed'));
383+
384+
await expect((handlers as any).ensureAuth())
385+
.rejects.toThrow('Authentication required');
386+
expect(mockIBClient.reauthenticate).toHaveBeenCalled();
387+
});
374388
});
375389

376390
describe('startBrowserAuthPolling', () => {
@@ -389,21 +403,32 @@ describe('ToolHandlers', () => {
389403
mockIBClient.reauthenticate = vi.fn().mockResolvedValue(undefined);
390404

391405
(handlers as any).startBrowserAuthPolling('https://localhost:5000', 5000);
392-
await vi.runAllTimersAsync();
406+
await vi.advanceTimersByTimeAsync(120_000);
393407

394408
expect(mockIBClient.checkAuthenticationStatus).toHaveBeenCalledTimes(2);
395409
expect(mockIBClient.reauthenticate).toHaveBeenCalledTimes(1);
396410
});
397411

398-
it('should not call reauthenticate when auth never detected', async () => {
412+
it('should stop polling once the deadline passes when auth never detected', async () => {
399413
mockIBClient.checkAuthenticationStatus = vi.fn().mockResolvedValue(false);
400414
mockIBClient.reauthenticate = vi.fn();
401415

402416
(handlers as any).startBrowserAuthPolling('https://localhost:5000', 5000);
403-
await vi.runAllTimersAsync();
417+
// Advance past the 2-minute deadline.
418+
await vi.advanceTimersByTimeAsync(120_000);
404419

405-
expect(mockIBClient.checkAuthenticationStatus).toHaveBeenCalledTimes(60);
420+
// Deadline-based loop produces fewer than the legacy 60 attempts since the
421+
// backoff caps at 10s. We assert (a) reauthenticate was never called and
422+
// (b) polling stayed within the documented 2-minute upper bound.
406423
expect(mockIBClient.reauthenticate).not.toHaveBeenCalled();
424+
const attemptsWithinDeadline = (mockIBClient.checkAuthenticationStatus as any).mock.calls.length;
425+
expect(attemptsWithinDeadline).toBeGreaterThan(0);
426+
expect(attemptsWithinDeadline).toBeLessThan(60);
427+
428+
// Advancing further must not produce additional polls.
429+
await vi.advanceTimersByTimeAsync(120_000);
430+
expect((mockIBClient.checkAuthenticationStatus as any).mock.calls.length)
431+
.toBe(attemptsWithinDeadline);
407432
});
408433

409434
it('should handle checkAuthenticationStatus throwing without stopping', async () => {
@@ -413,7 +438,7 @@ describe('ToolHandlers', () => {
413438
mockIBClient.reauthenticate = vi.fn().mockResolvedValue(undefined);
414439

415440
(handlers as any).startBrowserAuthPolling('https://localhost:5000', 5000);
416-
await vi.runAllTimersAsync();
441+
await vi.advanceTimersByTimeAsync(120_000);
417442

418443
expect(mockIBClient.checkAuthenticationStatus).toHaveBeenCalledTimes(2);
419444
expect(mockIBClient.reauthenticate).toHaveBeenCalledTimes(1);

0 commit comments

Comments
 (0)