Skip to content

Commit e36b6ab

Browse files
Merge pull request #7055 from Shopify/03-19-fix_infinite_401_retry_loop_when_app_logs_resubscribe_fails
Fix infinite 401 retry loop when app logs resubscribe fails
2 parents dbd5b93 + 59476a1 commit e36b6ab

7 files changed

Lines changed: 239 additions & 7 deletions

File tree

packages/app/src/cli/services/app-logs/dev/poll-app-logs.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {pollAppLogs} from './poll-app-logs.js'
22
import {writeAppLogsToFile} from './write-app-logs.js'
33
import {FunctionRunLog} from '../types.js'
4+
import {MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES} from '../utils.js'
45
import {testDeveloperPlatformClient} from '../../../models/app/app.test-data.js'
56
import {describe, expect, test, vi, beforeEach, afterEach} from 'vitest'
67
import * as components from '@shopify/cli-kit/node/ui/components'
@@ -385,6 +386,36 @@ describe('pollAppLogs', () => {
385386
expect(vi.getTimerCount()).toEqual(1)
386387
})
387388

389+
test('retries at 60s interval when resubscribe throws on 401', async () => {
390+
// Given
391+
const outputDebugSpy = vi.spyOn(output, 'outputDebug')
392+
const outputWarnSpy = vi.spyOn(output, 'outputWarn')
393+
const timeoutSpy = vi.spyOn(global, 'setTimeout')
394+
const response = {errors: ['Unauthorized'], status: 401}
395+
const mockedDeveloperPlatformClient = testDeveloperPlatformClient({
396+
appLogs: vi.fn().mockResolvedValue(response),
397+
})
398+
const failingResubscribe = vi.fn().mockRejectedValue(new Error('Network error'))
399+
400+
// When
401+
await pollAppLogs({
402+
stdout,
403+
appLogsFetchInput: {jwtToken: JWT_TOKEN},
404+
developerPlatformClient: mockedDeveloperPlatformClient,
405+
resubscribeCallback: failingResubscribe,
406+
storeName: 'storeName',
407+
organizationId: 'organizationId',
408+
logsDir: TEST_LOGS_DIR,
409+
})
410+
411+
// Then
412+
expect(failingResubscribe).toHaveBeenCalled()
413+
expect(outputDebugSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to resubscribe'))
414+
expect(outputWarnSpy).toHaveBeenCalledWith('Request throttled while polling app logs.', stdout)
415+
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), 60000)
416+
expect(vi.getTimerCount()).toEqual(1)
417+
})
418+
388419
test('displays error message, waits, and retries if error occurred', async () => {
389420
// Given
390421
const outputDebugSpy = vi.spyOn(output, 'outputDebug')
@@ -412,6 +443,35 @@ describe('pollAppLogs', () => {
412443
expect(vi.getTimerCount()).toEqual(1)
413444
})
414445

446+
test('stops polling after MAX consecutive resubscribe failures', async () => {
447+
// Given
448+
const outputWarnSpy = vi.spyOn(output, 'outputWarn')
449+
const response = {errors: ['Unauthorized'], status: 401}
450+
const mockedDeveloperPlatformClient = testDeveloperPlatformClient({
451+
appLogs: vi.fn().mockResolvedValue(response),
452+
})
453+
const failingResubscribe = vi.fn().mockRejectedValue(new Error('Network error'))
454+
455+
// When - start with failures already at MAX - 1
456+
await pollAppLogs({
457+
stdout,
458+
appLogsFetchInput: {jwtToken: JWT_TOKEN},
459+
developerPlatformClient: mockedDeveloperPlatformClient,
460+
resubscribeCallback: failingResubscribe,
461+
storeName: 'storeName',
462+
organizationId: 'organizationId',
463+
logsDir: TEST_LOGS_DIR,
464+
consecutiveResubscribeFailures: MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES - 1,
465+
})
466+
467+
// Then - should output terminal message and NOT schedule a timer
468+
expect(outputWarnSpy).toHaveBeenCalledWith(
469+
'App log streaming session has expired. Please restart your dev session.',
470+
stdout,
471+
)
472+
expect(vi.getTimerCount()).toEqual(0)
473+
})
474+
415475
test('displays error message, waits, and retries if response contained bad JSON', async () => {
416476
// Given
417477
const outputDebugSpy = vi.spyOn(output, 'outputDebug')

packages/app/src/cli/services/app-logs/dev/poll-app-logs.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
LOG_TYPE_REQUEST_EXECUTION,
1111
REQUEST_EXECUTION_IN_BACKGROUND_NO_CACHED_RESPONSE_REASON,
1212
REQUEST_EXECUTION_IN_BACKGROUND_CACHE_ABOUT_TO_EXPIRE_REASON,
13+
MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES,
1314
handleFetchAppLogsError,
1415
AppLogsOptions,
1516
} from '../utils.js'
@@ -29,6 +30,7 @@ export const pollAppLogs = async ({
2930
organizationId,
3031
abortSignal,
3132
logsDir,
33+
consecutiveResubscribeFailures = 0,
3234
}: {
3335
stdout: Writable
3436
appLogsFetchInput: AppLogsOptions
@@ -38,6 +40,7 @@ export const pollAppLogs = async ({
3840
organizationId: string
3941
abortSignal?: AbortSignal
4042
logsDir: string
43+
consecutiveResubscribeFailures?: number
4144
}) => {
4245
if (abortSignal?.aborted) {
4346
return
@@ -46,11 +49,13 @@ export const pollAppLogs = async ({
4649
try {
4750
let nextJwtToken = jwtToken
4851
let retryIntervalMs = POLLING_INTERVAL_MS
52+
let nextConsecutiveResubscribeFailures = consecutiveResubscribeFailures
4953

5054
const response = await developerPlatformClient.appLogs({jwtToken, cursor}, organizationId)
5155

5256
const {errors, status} = response as AppLogsError
5357
if (status === 200) {
58+
nextConsecutiveResubscribeFailures = 0
5459
const {app_logs: appLogs} = response as AppLogsSuccess
5560

5661
for (const log of appLogs) {
@@ -102,6 +107,16 @@ export const pollAppLogs = async ({
102107
},
103108
})
104109

110+
if (result.resubscribeResult === 'failed') {
111+
nextConsecutiveResubscribeFailures += 1
112+
if (nextConsecutiveResubscribeFailures >= MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES) {
113+
outputWarn('App log streaming session has expired. Please restart your dev session.', stdout)
114+
return
115+
}
116+
} else if (result.resubscribeResult === 'succeeded') {
117+
nextConsecutiveResubscribeFailures = 0
118+
}
119+
105120
if (result.nextJwtToken) {
106121
nextJwtToken = result.nextJwtToken
107122
}
@@ -123,6 +138,7 @@ export const pollAppLogs = async ({
123138
organizationId,
124139
abortSignal,
125140
logsDir,
141+
consecutiveResubscribeFailures: nextConsecutiveResubscribeFailures,
126142
}).catch((error) => {
127143
outputDebug(`Unexpected error during polling: ${error}}\n`)
128144
})

packages/app/src/cli/services/app-logs/logs-command/render-json-logs.test.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {renderJsonLogs} from './render-json-logs.js'
22
import {pollAppLogs} from './poll-app-logs.js'
3-
import {handleFetchAppLogsError} from '../utils.js'
3+
import {handleFetchAppLogsError, MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES} from '../utils.js'
44
import {testDeveloperPlatformClient} from '../../../models/app/app.test-data.js'
55
import {outputInfo, outputResult} from '@shopify/cli-kit/node/output'
66
import {describe, expect, vi, test, beforeEach, afterEach} from 'vitest'
@@ -101,6 +101,72 @@ describe('renderJsonLogs', () => {
101101
expect(outputResult).not.toHaveBeenCalled()
102102
})
103103

104+
test('should retry at throttle interval when handleFetchAppLogsError returns null token', async () => {
105+
const timeoutSpy = vi.spyOn(global, 'setTimeout')
106+
const mockErrorResponse = {
107+
errors: [{status: 401, message: 'Unauthorized'}],
108+
}
109+
const pollAppLogsMock = vi.fn().mockResolvedValue(mockErrorResponse)
110+
vi.mocked(pollAppLogs).mockImplementation(pollAppLogsMock)
111+
const throttleRetryInterval = 60000
112+
const handleFetchAppLogsErrorMock = vi.fn(() => {
113+
return Promise.resolve({
114+
nextJwtToken: null,
115+
retryIntervalMs: throttleRetryInterval,
116+
resubscribeResult: 'not_attempted' as const,
117+
})
118+
})
119+
vi.mocked(handleFetchAppLogsError).mockImplementation(handleFetchAppLogsErrorMock)
120+
121+
const storeNameById = new Map<string, string>()
122+
storeNameById.set('1', 'storeName')
123+
await renderJsonLogs({
124+
pollOptions: {cursor: 'cursor', filters: {status: undefined, sources: undefined}, jwtToken: 'jwtToken'},
125+
options: {
126+
variables: {shopIds: [], apiKey: ''},
127+
developerPlatformClient: testDeveloperPlatformClient(),
128+
},
129+
storeNameById,
130+
organizationId: 'organizationId',
131+
})
132+
133+
expect(handleFetchAppLogsError).toHaveBeenCalled()
134+
expect(pollAppLogs).toHaveBeenCalled()
135+
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), throttleRetryInterval)
136+
expect(vi.getTimerCount()).toEqual(1)
137+
})
138+
139+
test('should stop polling after MAX consecutive resubscribe failures', async () => {
140+
const mockErrorResponse = {
141+
errors: [{status: 401, message: 'Unauthorized'}],
142+
}
143+
const pollAppLogsMock = vi.fn().mockResolvedValue(mockErrorResponse)
144+
vi.mocked(pollAppLogs).mockImplementation(pollAppLogsMock)
145+
const handleFetchAppLogsErrorMock = vi.fn(() => {
146+
return Promise.resolve({nextJwtToken: null, retryIntervalMs: 60000, resubscribeResult: 'failed' as const})
147+
})
148+
vi.mocked(handleFetchAppLogsError).mockImplementation(handleFetchAppLogsErrorMock)
149+
150+
const storeNameById = new Map<string, string>()
151+
storeNameById.set('1', 'storeName')
152+
await renderJsonLogs({
153+
pollOptions: {cursor: 'cursor', filters: {status: undefined, sources: undefined}, jwtToken: 'jwtToken'},
154+
options: {
155+
variables: {shopIds: [], apiKey: ''},
156+
developerPlatformClient: testDeveloperPlatformClient(),
157+
},
158+
storeNameById,
159+
organizationId: 'organizationId',
160+
consecutiveResubscribeFailures: MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES - 1,
161+
})
162+
163+
expect(handleFetchAppLogsError).toHaveBeenCalled()
164+
expect(outputInfo).toHaveBeenCalledWith(
165+
JSON.stringify({message: 'App log streaming session has expired. Please restart your dev session.'}),
166+
)
167+
expect(vi.getTimerCount()).toEqual(0)
168+
})
169+
104170
test('should handle error response and retry as expected', async () => {
105171
const mockErrorResponse = {
106172
errors: [{status: 500, message: 'Server Error'}],
@@ -110,8 +176,12 @@ describe('renderJsonLogs', () => {
110176
const mockRetryInterval = 1000
111177
const handleFetchAppLogsErrorMock = vi.fn((input) => {
112178
input.onUnknownError(mockRetryInterval)
113-
return new Promise<{retryIntervalMs: number; nextJwtToken: string | null}>((resolve, _reject) => {
114-
resolve({nextJwtToken: 'new-jwt-token', retryIntervalMs: mockRetryInterval})
179+
return new Promise<{
180+
retryIntervalMs: number
181+
nextJwtToken: string | null
182+
resubscribeResult: 'succeeded' | 'failed' | 'not_attempted'
183+
}>((resolve, _reject) => {
184+
resolve({nextJwtToken: 'new-jwt-token', retryIntervalMs: mockRetryInterval, resubscribeResult: 'not_attempted'})
115185
})
116186
})
117187
vi.mocked(handleFetchAppLogsError).mockImplementation(handleFetchAppLogsErrorMock)

packages/app/src/cli/services/app-logs/logs-command/render-json-logs.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {pollAppLogs} from './poll-app-logs.js'
22
import {PollOptions, SubscribeOptions, ErrorResponse, SuccessResponse} from '../types.js'
33
import {
44
POLLING_INTERVAL_MS,
5+
MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES,
56
handleFetchAppLogsError,
67
subscribeToAppLogs,
78
toFormattedAppLogJson,
@@ -14,15 +15,18 @@ export async function renderJsonLogs({
1415
options: {variables, developerPlatformClient},
1516
storeNameById,
1617
organizationId,
18+
consecutiveResubscribeFailures = 0,
1719
}: {
1820
pollOptions: PollOptions
1921
options: SubscribeOptions
2022
storeNameById: Map<string, string>
2123
organizationId: string
24+
consecutiveResubscribeFailures?: number
2225
}): Promise<void> {
2326
const response = await pollAppLogs({pollOptions, developerPlatformClient, organizationId})
2427
let retryIntervalMs = POLLING_INTERVAL_MS
2528
let nextJwtToken = pollOptions.jwtToken
29+
let nextConsecutiveResubscribeFailures = consecutiveResubscribeFailures
2630

2731
const errorResponse = response as ErrorResponse
2832

@@ -40,10 +44,22 @@ export async function renderJsonLogs({
4044
},
4145
})
4246

47+
if (result.resubscribeResult === 'failed') {
48+
nextConsecutiveResubscribeFailures += 1
49+
if (nextConsecutiveResubscribeFailures >= MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES) {
50+
outputInfo(JSON.stringify({message: 'App log streaming session has expired. Please restart your dev session.'}))
51+
return
52+
}
53+
} else if (result.resubscribeResult === 'succeeded') {
54+
nextConsecutiveResubscribeFailures = 0
55+
}
56+
4357
if (result.nextJwtToken) {
4458
nextJwtToken = result.nextJwtToken
4559
}
4660
retryIntervalMs = result.retryIntervalMs
61+
} else {
62+
nextConsecutiveResubscribeFailures = 0
4763
}
4864

4965
const {cursor: nextCursor, appLogs} = response as SuccessResponse
@@ -76,6 +92,7 @@ export async function renderJsonLogs({
7692
},
7793
storeNameById,
7894
organizationId,
95+
consecutiveResubscribeFailures: nextConsecutiveResubscribeFailures,
7996
}).catch((error) => {
8097
throw error
8198
})

packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/usePollAppLogs.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
POLLING_ERROR_RETRY_INTERVAL_MS,
88
POLLING_INTERVAL_MS,
99
POLLING_THROTTLE_RETRY_INTERVAL_MS,
10+
MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES,
1011
parseFunctionRunPayload,
1112
} from '../../../../utils.js'
1213
import {
@@ -512,6 +513,46 @@ describe('usePollAppLogs', () => {
512513
expect(hook.lastResult?.errors).toHaveLength(0)
513514
})
514515

516+
test('stops polling after MAX consecutive resubscribe failures', async () => {
517+
const mockedPollAppLogs = vi.fn().mockResolvedValue(POLL_APP_LOGS_FOR_LOGS_401_RESPONSE)
518+
vi.mocked(pollAppLogs).mockImplementation(mockedPollAppLogs)
519+
520+
const mockedDeveloperPlatformClient = testDeveloperPlatformClient()
521+
const resubscribeCallback = vi.fn().mockRejectedValue(new Error('Session expired'))
522+
523+
const hook = renderHook(() =>
524+
usePollAppLogs({
525+
initialJwt: MOCKED_JWT_TOKEN,
526+
filters: EMPTY_FILTERS,
527+
resubscribeCallback,
528+
storeNameById: STORE_NAME_BY_ID,
529+
developerPlatformClient: mockedDeveloperPlatformClient,
530+
organizationId: MOCKED_ORGANIZATION_ID,
531+
}),
532+
)
533+
534+
// needed to await the render
535+
await vi.advanceTimersByTimeAsync(0)
536+
537+
// Wait for the first poll
538+
await waitForMockCalls(mockedPollAppLogs, 1)
539+
540+
// Advance through MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES - 1 more polls
541+
for (let i = 1; i < MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES; i++) {
542+
// eslint-disable-next-line no-await-in-loop
543+
await vi.advanceTimersToNextTimerAsync()
544+
// eslint-disable-next-line no-await-in-loop
545+
await waitForMockCalls(mockedPollAppLogs, i + 1)
546+
}
547+
548+
// Flush React 19 batched state updates
549+
await vi.advanceTimersByTimeAsync(0)
550+
551+
expect(hook.lastResult?.errors).toEqual(['App log streaming session has expired. Please restart your dev session.'])
552+
// Polling should have stopped - no more timers scheduled
553+
expect(vi.getTimerCount()).toEqual(0)
554+
})
555+
515556
test("ignores logs from stores that don't have a matching shop name", async () => {
516557
const mockedPollAppLogs = vi.fn().mockResolvedValue(POLL_APP_LOGS_FOR_LOGS_RESPONSE)
517558
vi.mocked(pollAppLogs).mockImplementation(mockedPollAppLogs)

0 commit comments

Comments
 (0)