@@ -7,18 +7,20 @@ import {clientId} from './identity.js'
77import { IdentityToken } from './schema.js'
88import { exchangeDeviceCodeForAccessToken } from './exchange.js'
99import { identityFqdn } from '../../../public/node/context/fqdn.js'
10+ import { recordRetry } from '../../../public/node/analytics.js'
1011import { shopifyFetch } from '../../../public/node/http.js'
1112import { isTTY } from '../../../public/node/ui.js'
1213import { err , ok } from '../../../public/node/result.js'
13- import { AbortError } from '../../../public/node/error.js'
14- import { isCI , openURL } from '../../../public/node/system.js'
14+ import { AbortError , BugError } from '../../../public/node/error.js'
15+ import { isCI , openURL , sleep } from '../../../public/node/system.js'
1516import * as output from '../../../public/node/output.js'
1617
1718import { beforeEach , describe , expect , test , vi } from 'vitest'
1819import { Response } from 'node-fetch'
1920
2021vi . mock ( '../../../public/node/context/fqdn.js' )
2122vi . mock ( './identity' )
23+ vi . mock ( '../../../public/node/analytics.js' )
2224vi . mock ( '../../../public/node/http.js' )
2325vi . mock ( '../../../public/node/ui.js' )
2426vi . mock ( './exchange.js' )
@@ -160,7 +162,7 @@ describe('requestDeviceAuthorization', () => {
160162 expect ( outputInfo ) . not . toHaveBeenCalledWith ( '👉 Press any key to open the login page on your browser' )
161163 } )
162164
163- test ( 'when the response is not valid JSON, throw an error with context' , async ( ) => {
165+ test ( 'when the response is not valid JSON, throw an error with context without retrying ' , async ( ) => {
164166 // Given
165167 const response = new Response ( 'not valid JSON' )
166168 Object . defineProperty ( response , 'status' , { value : 200 } )
@@ -169,10 +171,121 @@ describe('requestDeviceAuthorization', () => {
169171 vi . mocked ( identityFqdn ) . mockResolvedValue ( 'fqdn.com' )
170172 vi . mocked ( clientId ) . mockReturnValue ( 'clientId' )
171173
172- // When/Then
173- await expect ( requestDeviceAuthorization ( [ 'scope1' , 'scope2' ] ) ) . rejects . toThrowError (
174+ // When
175+ const request = requestDeviceAuthorization ( [ 'scope1' , 'scope2' ] )
176+
177+ // Then
178+ await expect ( request ) . rejects . toBeInstanceOf ( BugError )
179+ await expect ( request ) . rejects . toThrowError (
174180 'Received invalid response from authorization service (HTTP 200). Response could not be parsed as valid JSON. If this issue persists, please contact support at https://help.shopify.com' ,
175181 )
182+ expect ( shopifyFetch ) . toHaveBeenCalledTimes ( 1 )
183+ } )
184+
185+ test ( 'retries a gateway response and returns once the service recovers' , async ( ) => {
186+ // Given
187+ vi . mocked ( shopifyFetch )
188+ . mockResolvedValueOnce ( new Response ( 'Service unavailable' , { status : 503 } ) )
189+ . mockResolvedValueOnce ( new Response ( JSON . stringify ( data ) , { status : 200 } ) )
190+ vi . mocked ( identityFqdn ) . mockResolvedValue ( 'fqdn.com' )
191+ vi . mocked ( clientId ) . mockReturnValue ( 'clientId' )
192+
193+ // When
194+ const got = await requestDeviceAuthorization ( [ 'scope1' , 'scope2' ] )
195+
196+ // Then
197+ expect ( got ) . toEqual ( dataExpected )
198+ expect ( shopifyFetch ) . toHaveBeenCalledTimes ( 2 )
199+ expect ( sleep ) . toHaveBeenCalledOnce ( )
200+ expect ( sleep ) . toHaveBeenCalledWith ( 0.2 )
201+ expect ( recordRetry ) . toHaveBeenCalledWith (
202+ 'https://fqdn.com/oauth/device_authorization' ,
203+ 'device-authorization-gateway-error' ,
204+ )
205+ } )
206+
207+ test . each ( [
208+ { retryAfter : '3' , expectedDelay : 3 } ,
209+ { retryAfter : '0' , expectedDelay : 0 } ,
210+ { retryAfter : 'Fri, 18 Sep 2026 12:00:10 GMT' , expectedDelay : 10 } ,
211+ { retryAfter : 'Fri, 18 Sep 2026 11:59:59 GMT' , expectedDelay : 0 } ,
212+ { retryAfter : 'invalid' , expectedDelay : 0.2 } ,
213+ { retryAfter : '-1' , expectedDelay : 0.2 } ,
214+ { retryAfter : '' , expectedDelay : 0.2 } ,
215+ { retryAfter : ' ' , expectedDelay : 0.2 } ,
216+ ] ) ( 'uses $expectedDelay seconds for Retry-After "$retryAfter"' , async ( { retryAfter, expectedDelay} ) => {
217+ const now = vi . spyOn ( Date , 'now' ) . mockReturnValue ( new Date ( '2026-09-18T12:00:00Z' ) . getTime ( ) )
218+ vi . mocked ( shopifyFetch )
219+ . mockResolvedValueOnce ( new Response ( 'Service unavailable' , { status : 503 , headers : { 'Retry-After' : retryAfter } } ) )
220+ . mockResolvedValueOnce ( new Response ( JSON . stringify ( data ) , { status : 200 } ) )
221+ vi . mocked ( identityFqdn ) . mockResolvedValue ( 'fqdn.com' )
222+ vi . mocked ( clientId ) . mockReturnValue ( 'clientId' )
223+
224+ try {
225+ const result = await requestDeviceAuthorization ( [ 'scope1' , 'scope2' ] )
226+
227+ expect ( result ) . toEqual ( dataExpected )
228+ expect ( shopifyFetch ) . toHaveBeenCalledTimes ( 2 )
229+ expect ( sleep ) . toHaveBeenCalledExactlyOnceWith ( expectedDelay )
230+ } finally {
231+ now . mockRestore ( )
232+ }
233+ } )
234+
235+ test ( 'uses the Retry-After from each response and stops after two retries' , async ( ) => {
236+ vi . mocked ( shopifyFetch )
237+ . mockResolvedValueOnce ( new Response ( 'Service unavailable' , { status : 503 , headers : { 'Retry-After' : '2' } } ) )
238+ . mockResolvedValueOnce ( new Response ( 'Service unavailable' , { status : 503 , headers : { 'Retry-After' : '4' } } ) )
239+ . mockResolvedValueOnce ( new Response ( 'Service unavailable' , { status : 503 , headers : { 'Retry-After' : '6' } } ) )
240+ vi . mocked ( identityFqdn ) . mockResolvedValue ( 'fqdn.com' )
241+ vi . mocked ( clientId ) . mockReturnValue ( 'clientId' )
242+
243+ await expect ( requestDeviceAuthorization ( [ 'scope1' , 'scope2' ] ) ) . rejects . toBeInstanceOf ( AbortError )
244+
245+ expect ( shopifyFetch ) . toHaveBeenCalledTimes ( 3 )
246+ expect ( sleep ) . toHaveBeenCalledTimes ( 2 )
247+ expect ( sleep ) . toHaveBeenNthCalledWith ( 1 , 2 )
248+ expect ( sleep ) . toHaveBeenNthCalledWith ( 2 , 4 )
249+ } )
250+
251+ test . each ( [ 502 , 503 , 504 ] ) (
252+ 'when HTTP %i gateway responses persist, throw an expected error after two retries' ,
253+ async ( status ) => {
254+ // Given
255+ vi . mocked ( shopifyFetch ) . mockImplementation ( async ( ) => new Response ( 'Service unavailable' , { status} ) )
256+ vi . mocked ( identityFqdn ) . mockResolvedValue ( 'fqdn.com' )
257+ vi . mocked ( clientId ) . mockReturnValue ( 'clientId' )
258+
259+ // When
260+ const request = requestDeviceAuthorization ( [ 'scope1' , 'scope2' ] )
261+
262+ // Then
263+ await expect ( request ) . rejects . toBeInstanceOf ( AbortError )
264+ await expect ( request ) . rejects . toThrowError (
265+ `Received invalid response from authorization service (HTTP ${ status } ). The service may be experiencing issues. Response could not be parsed as valid JSON. If this issue persists, please contact support at https://help.shopify.com` ,
266+ )
267+ expect ( shopifyFetch ) . toHaveBeenCalledTimes ( 3 )
268+ expect ( sleep ) . toHaveBeenNthCalledWith ( 1 , 0.2 )
269+ expect ( sleep ) . toHaveBeenNthCalledWith ( 2 , 0.4 )
270+ expect ( recordRetry ) . toHaveBeenCalledTimes ( 2 )
271+ } ,
272+ )
273+
274+ test ( 'preserves the existing error for a persistent JSON gateway response' , async ( ) => {
275+ // Given
276+ vi . mocked ( shopifyFetch ) . mockImplementation (
277+ async ( ) => new Response ( JSON . stringify ( { error : 'service_unavailable' } ) , { status : 503 } ) ,
278+ )
279+ vi . mocked ( identityFqdn ) . mockResolvedValue ( 'fqdn.com' )
280+ vi . mocked ( clientId ) . mockReturnValue ( 'clientId' )
281+
282+ // When
283+ const request = requestDeviceAuthorization ( [ 'scope1' , 'scope2' ] )
284+
285+ // Then
286+ await expect ( request ) . rejects . toBeInstanceOf ( BugError )
287+ await expect ( request ) . rejects . toThrowError ( 'Failed to start authorization process' )
288+ expect ( shopifyFetch ) . toHaveBeenCalledTimes ( 3 )
176289 } )
177290
178291 test ( 'when the response is empty, throw an error with empty body message' , async ( ) => {
@@ -206,7 +319,7 @@ describe('requestDeviceAuthorization', () => {
206319 )
207320 } )
208321
209- test ( 'when the server returns a 500 error with non-JSON response, throw an error with server issue message ' , async ( ) => {
322+ test ( 'when the server returns a 500 error with non-JSON response, throw an error without retrying ' , async ( ) => {
210323 // Given
211324 const response = new Response ( 'Internal Server Error' )
212325 Object . defineProperty ( response , 'status' , { value : 500 } )
@@ -219,6 +332,7 @@ describe('requestDeviceAuthorization', () => {
219332 await expect ( requestDeviceAuthorization ( [ 'scope1' , 'scope2' ] ) ) . rejects . toThrowError (
220333 'Received invalid response from authorization service (HTTP 500). The service may be experiencing issues. Response could not be parsed as valid JSON. If this issue persists, please contact support at https://help.shopify.com' ,
221334 )
335+ expect ( shopifyFetch ) . toHaveBeenCalledTimes ( 1 )
222336 } )
223337
224338 test ( 'when response.text() fails, throw an error about network/streaming issue' , async ( ) => {
0 commit comments