@@ -1783,10 +1783,43 @@ describe('SSEClientTransport', () => {
17831783 await expect ( transport . finishAuth ( 'auth-code' ) ) . rejects . toThrow ( 'finishAuth requires an OAuthClientProvider' ) ;
17841784 } ) ;
17851785
1786- it ( 'SSE connect 401 retry does not poison future 401s — onUnauthorized called on each attempt' , async ( ) => {
1786+ it ( 'SSE connect: a second 401 right after a refresh rejects, onUnauthorized called once' , async ( ) => {
1787+ await resourceServer . close ( ) ;
1788+
1789+ let getAttempt = 0 ;
1790+ resourceServer = createServer ( ( req , res ) => {
1791+ if ( req . method === 'GET' ) {
1792+ getAttempt ++ ;
1793+ res . writeHead ( 401 ) . end ( ) ; // always 401
1794+ }
1795+ } ) ;
1796+ resourceBaseUrl = await listenOnRandomPort ( resourceServer ) ;
1797+
1798+ // Backstop so an unbounded retry ends the test with a clear failure instead of a hang.
1799+ let calls = 0 ;
1800+ const authProvider : AuthProvider = {
1801+ token : vi . fn ( async ( ) => 'still-bad' ) ,
1802+ onUnauthorized : vi . fn ( async ( ) => {
1803+ if ( ++ calls >= 10 ) throw new Error ( 'backstop: onUnauthorized called 10 times' ) ;
1804+ } )
1805+ } ;
1806+ transport = new SSEClientTransport ( resourceBaseUrl , { authProvider } ) ;
1807+ const onerror = vi . fn ( ) ;
1808+ transport . onerror = onerror ;
1809+
1810+ const error = await transport . start ( ) . catch ( e => e ) ;
1811+ expect ( error ) . toBeInstanceOf ( SdkHttpError ) ;
1812+ expect ( ( error as SdkHttpError ) . code ) . toBe ( SdkErrorCode . ClientHttpAuthentication ) ;
1813+ expect ( ( error as SdkHttpError ) . status ) . toBe ( 401 ) ;
1814+ expect ( authProvider . onUnauthorized ) . toHaveBeenCalledTimes ( 1 ) ;
1815+ expect ( getAttempt ) . toBe ( 2 ) ;
1816+ expect ( onerror ) . toHaveBeenCalledTimes ( 1 ) ;
1817+ } ) ;
1818+
1819+ it ( 'SSE connect 401 retry does not poison future 401s — a 401 on a later reconnect refreshes again' , async ( ) => {
17871820 // Regression: _startOrAuth(true) baked isAuthRetry=true into the retry EventSource's
17881821 // onerror closure, so a subsequent 401 (token expiry on reconnect) would throw
1789- // instead of refreshing. Fix: retry always calls _startOrAuth() fresh .
1822+ // instead of refreshing. The retry guard resets once the stream opens .
17901823 await resourceServer . close ( ) ;
17911824
17921825 let getAttempt = 0 ;
@@ -1796,7 +1829,8 @@ describe('SSEClientTransport', () => {
17961829 return ;
17971830 }
17981831 getAttempt ++ ;
1799- if ( getAttempt < 3 ) {
1832+ // 1: 401, 2: opens then drops, 3: 401 on the automatic reconnect, 4: opens and stays.
1833+ if ( getAttempt === 1 || getAttempt === 3 ) {
18001834 res . writeHead ( 401 ) . end ( ) ;
18011835 return ;
18021836 }
@@ -1805,8 +1839,12 @@ describe('SSEClientTransport', () => {
18051839 'Cache-Control' : 'no-cache, no-transform' ,
18061840 Connection : 'keep-alive'
18071841 } ) ;
1842+ res . write ( 'retry: 10\n' ) ;
18081843 res . write ( 'event: endpoint\n' ) ;
18091844 res . write ( `data: ${ resourceBaseUrl . href } post\n\n` ) ;
1845+ if ( getAttempt === 2 ) {
1846+ res . end ( ) ;
1847+ }
18101848 } ) ;
18111849 resourceBaseUrl = await listenOnRandomPort ( resourceServer ) ;
18121850
@@ -1816,17 +1854,17 @@ describe('SSEClientTransport', () => {
18161854 } ;
18171855 transport = new SSEClientTransport ( resourceBaseUrl , { authProvider } ) ;
18181856
1819- await transport . start ( ) ; // should resolve on attempt 3
1857+ await transport . start ( ) ; // resolves on attempt 2
1858+ expect ( authProvider . onUnauthorized ) . toHaveBeenCalledTimes ( 1 ) ;
18201859
1860+ await vi . waitFor ( ( ) => expect ( getAttempt ) . toBe ( 4 ) , { timeout : 3000 } ) ;
18211861 expect ( authProvider . onUnauthorized ) . toHaveBeenCalledTimes ( 2 ) ;
1822- expect ( getAttempt ) . toBe ( 3 ) ;
18231862 } ) ;
18241863
18251864 it ( 'retry failure during SSE connect fires onerror exactly once' , async ( ) => {
18261865 // Regression: when the retry EventSource rejected, its onerror fired inside, then
18271866 // the outer .then() rejection handler fired onerror AGAIN for the same error.
18281867 // Fix: inner retry chains to .then(resolve, reject) — no outer onerror call.
1829- // onUnauthorized's own failure is handled separately and fires onerror once.
18301868 await resourceServer . close ( ) ;
18311869
18321870 resourceServer = createServer ( ( req , res ) => {
@@ -1836,20 +1874,40 @@ describe('SSEClientTransport', () => {
18361874 } ) ;
18371875 resourceBaseUrl = await listenOnRandomPort ( resourceServer ) ;
18381876
1839- const onUnauthorized : AuthProvider [ 'onUnauthorized' ] = vi
1840- . fn ( )
1841- . mockResolvedValueOnce ( undefined ) // first call succeeds → triggers retry
1842- . mockRejectedValueOnce ( new Error ( 'refresh failed' ) ) ; // second call (in retry) throws
18431877 const authProvider : AuthProvider = {
18441878 token : vi . fn ( async ( ) => 'token' ) ,
1845- onUnauthorized
1879+ onUnauthorized : vi . fn ( async ( ) => { } )
1880+ } ;
1881+ transport = new SSEClientTransport ( resourceBaseUrl , { authProvider } ) ;
1882+ const onerror = vi . fn ( ) ;
1883+ transport . onerror = onerror ;
1884+
1885+ await expect ( transport . start ( ) ) . rejects . toThrow ( 'Server returned 401 after re-authentication' ) ;
1886+ expect ( authProvider . onUnauthorized ) . toHaveBeenCalledTimes ( 1 ) ;
1887+ expect ( onerror ) . toHaveBeenCalledTimes ( 1 ) ;
1888+ expect ( onerror . mock . calls [ 0 ] ! [ 0 ] . message ) . toBe ( 'Server returned 401 after re-authentication' ) ;
1889+ } ) ;
1890+
1891+ it ( 'a failing onUnauthorized during SSE connect fires onerror exactly once' , async ( ) => {
1892+ await resourceServer . close ( ) ;
1893+
1894+ resourceServer = createServer ( ( req , res ) => {
1895+ if ( req . method === 'GET' ) {
1896+ res . writeHead ( 401 ) . end ( ) ; // always 401
1897+ }
1898+ } ) ;
1899+ resourceBaseUrl = await listenOnRandomPort ( resourceServer ) ;
1900+
1901+ const authProvider : AuthProvider = {
1902+ token : vi . fn ( async ( ) => 'token' ) ,
1903+ onUnauthorized : vi . fn ( ) . mockRejectedValueOnce ( new Error ( 'refresh failed' ) )
18461904 } ;
18471905 transport = new SSEClientTransport ( resourceBaseUrl , { authProvider } ) ;
18481906 const onerror = vi . fn ( ) ;
18491907 transport . onerror = onerror ;
18501908
18511909 await expect ( transport . start ( ) ) . rejects . toThrow ( 'refresh failed' ) ;
1852- expect ( authProvider . onUnauthorized ) . toHaveBeenCalledTimes ( 2 ) ;
1910+ expect ( authProvider . onUnauthorized ) . toHaveBeenCalledTimes ( 1 ) ;
18531911 expect ( onerror ) . toHaveBeenCalledTimes ( 1 ) ;
18541912 expect ( onerror . mock . calls [ 0 ] ! [ 0 ] . message ) . toBe ( 'refresh failed' ) ;
18551913 } ) ;
0 commit comments