@@ -11,8 +11,23 @@ import type {
1111 ISnapshotTree ,
1212} from "@fluidframework/driver-definitions/internal" ;
1313
14+ import { GenericNetworkError , NonRetryableError } from "../network.js" ;
1415import { PrefetchDocumentStorageService } from "../prefetchDocumentStorageService.js" ;
1516
17+ /**
18+ * Creates a retryable error for testing
19+ */
20+ function createRetryableError ( message : string ) : GenericNetworkError {
21+ return new GenericNetworkError ( message , true , { driverVersion : undefined } ) ;
22+ }
23+
24+ /**
25+ * Creates a non-retryable error for testing
26+ */
27+ function createNonRetryableError ( message : string ) : NonRetryableError < "genericNetworkError" > {
28+ return new NonRetryableError ( message , "genericNetworkError" , { driverVersion : undefined } ) ;
29+ }
30+
1631/**
1732 * Helper to wait for a condition with timeout
1833 */
@@ -36,9 +51,9 @@ async function waitForCondition(
3651class MockStorageService implements Partial < IDocumentStorageService > {
3752 public readBlobCalls : string [ ] = [ ] ;
3853 public shouldFail = false ;
39- public failureError = new Error ( "Mock read failure" ) ;
54+ public failureError : Error = new Error ( "Mock read failure" ) ;
4055 public shouldGetSnapshotTreeFail = false ;
41- public getSnapshotTreeError = new Error ( "Mock getSnapshotTree failure" ) ;
56+ public getSnapshotTreeError : Error = new Error ( "Mock getSnapshotTree failure" ) ;
4257
4358 public async readBlob ( blobId : string ) : Promise < ArrayBufferLike > {
4459 this . readBlobCalls . push ( blobId ) ;
@@ -96,9 +111,7 @@ describe("PrefetchDocumentStorageService", () => {
96111 } ) ;
97112
98113 it ( "should clear cache on retryable errors allowing retry" , async ( ) => {
99- const retryableError = new Error ( "Retryable error" ) ;
100- ( retryableError as any ) . canRetry = true ;
101- mockStorage . failureError = retryableError ;
114+ mockStorage . failureError = createRetryableError ( "Retryable error" ) ;
102115 mockStorage . shouldFail = true ;
103116
104117 // First call fails
@@ -118,6 +131,36 @@ describe("PrefetchDocumentStorageService", () => {
118131 ) ;
119132 } ) ;
120133
134+ it ( "should NOT clear cache on non-retryable errors" , async ( ) => {
135+ const nonRetryableError = createNonRetryableError ( "Non-retryable error" ) ;
136+ mockStorage . failureError = nonRetryableError ;
137+ mockStorage . shouldFail = true ;
138+
139+ // First call fails with non-retryable error
140+ await assert . rejects (
141+ async ( ) => prefetchService . readBlob ( "blob1" ) ,
142+ ( error : Error ) => error === nonRetryableError ,
143+ "First call should receive the non-retryable error" ,
144+ ) ;
145+
146+ // Reset mock to return different data (to prove we're using cached rejection)
147+ mockStorage . shouldFail = false ;
148+ mockStorage . readBlobCalls = [ ] ;
149+
150+ // Second call should still fail with same cached non-retryable error
151+ // (cache should NOT be cleared for non-retryable errors)
152+ await assert . rejects (
153+ async ( ) => prefetchService . readBlob ( "blob1" ) ,
154+ ( error : Error ) => error === nonRetryableError ,
155+ "Should receive the same cached non-retryable error" ,
156+ ) ;
157+ assert . strictEqual (
158+ mockStorage . readBlobCalls . length ,
159+ 0 ,
160+ "Should not perform new underlying read - cached rejection should be returned" ,
161+ ) ;
162+ } ) ;
163+
121164 it ( "should successfully prefetch blobs" , async ( ) => {
122165 // Trigger prefetch
123166 await prefetchService . getSnapshotTree ( ) ;
@@ -140,52 +183,85 @@ describe("PrefetchDocumentStorageService", () => {
140183 } ) ;
141184
142185 it ( "should not cause unhandled rejections on fire-and-forget prefetch failures" , async ( ) => {
143- // Set up to fail all blob reads
144- const prefetchError = new Error ( "Prefetch network failure" ) ;
145- ( prefetchError as any ) . canRetry = true ;
146- mockStorage . failureError = prefetchError ;
147- mockStorage . shouldFail = true ;
186+ // Track unhandled rejections to verify none occur
187+ const unhandledRejections : unknown [ ] = [ ] ;
188+ const rejectionHandler = ( reason : unknown ) : void => {
189+ unhandledRejections . push ( reason ) ;
190+ } ;
191+ process . on ( "unhandledRejection" , rejectionHandler ) ;
148192
149- // Trigger prefetch via getSnapshotTree (fire-and-forget pattern)
150- // The prefetch will fail, but should NOT cause unhandled rejection
151- await prefetchService . getSnapshotTree ( ) ;
193+ try {
194+ // Set up to fail all blob reads with a non-retryable error
195+ // Using non-retryable so the error is cached and we can verify error identity
196+ const prefetchError = createNonRetryableError ( "Prefetch network failure" ) ;
197+ mockStorage . failureError = prefetchError ;
198+ mockStorage . shouldFail = true ;
152199
153- // Wait for prefetch attempts to occur
154- await waitForCondition ( ( ) => mockStorage . readBlobCalls . length > 0 ) ;
200+ // Trigger prefetch via getSnapshotTree (fire-and-forget pattern)
201+ // The prefetch will fail, but should NOT cause unhandled rejection
202+ await prefetchService . getSnapshotTree ( ) ;
155203
156- // Allow microtask queue to flush ( for catch handlers to execute)
157- await Promise . resolve ( ) ;
204+ // Wait for prefetch attempts to occur
205+ await waitForCondition ( ( ) => mockStorage . readBlobCalls . length > 0 ) ;
158206
159- // If we reach here without unhandled rejection, the test passes
160- // Now verify that explicit readBlob calls still receive the error properly
161- mockStorage . readBlobCalls = [ ] ;
162- await assert . rejects (
163- async ( ) => prefetchService . readBlob ( "blob1" ) ,
164- ( error : Error ) => error . message === "Prefetch network failure" ,
165- "Explicit readBlob should still receive the error" ,
166- ) ;
207+ // Allow microtask queue to flush (for catch handlers to execute)
208+ await Promise . resolve ( ) ;
209+ await new Promise ( ( resolve ) => setTimeout ( resolve , 10 ) ) ;
210+
211+ // Verify no unhandled rejections occurred
212+ assert . strictEqual (
213+ unhandledRejections . length ,
214+ 0 ,
215+ `Expected no unhandled rejections, but got: ${ JSON . stringify ( unhandledRejections ) } ` ,
216+ ) ;
217+
218+ // Also verify that explicit readBlob calls still receive the same cached error
219+ // (non-retryable errors remain cached, so we can verify error identity)
220+ await assert . rejects (
221+ async ( ) => prefetchService . readBlob ( "blob1" ) ,
222+ ( error : Error ) => error === prefetchError ,
223+ "Explicit readBlob should receive the same cached error instance" ,
224+ ) ;
225+ } finally {
226+ process . off ( "unhandledRejection" , rejectionHandler ) ;
227+ }
167228 } ) ;
168229
169230 it ( "should not cause unhandled rejections when getSnapshotTree fails" , async ( ) => {
170- // Set up getSnapshotTree to fail (e.g., network timeout)
171- const networkError = new Error ( "Socket timeout" ) ;
172- mockStorage . shouldGetSnapshotTreeFail = true ;
173- mockStorage . getSnapshotTreeError = networkError ;
174-
175- // getSnapshotTree internally does: void p.then(...).catch(...)
176- // Without the .catch(), if p rejects, the derived promise from .then() also
177- // rejects and causes an unhandled rejection. This test verifies the fix.
178- await assert . rejects (
179- async ( ) => prefetchService . getSnapshotTree ( ) ,
180- ( error : Error ) => error . message === "Socket timeout" ,
181- "Caller should receive the error" ,
182- ) ;
231+ // Track unhandled rejections to verify none occur
232+ const unhandledRejections : unknown [ ] = [ ] ;
233+ const rejectionHandler = ( reason : unknown ) : void => {
234+ unhandledRejections . push ( reason ) ;
235+ } ;
236+ process . on ( "unhandledRejection" , rejectionHandler ) ;
237+
238+ try {
239+ // Set up getSnapshotTree to fail (e.g., network timeout)
240+ const networkError = new Error ( "Socket timeout" ) ;
241+ mockStorage . shouldGetSnapshotTreeFail = true ;
242+ mockStorage . getSnapshotTreeError = networkError ;
183243
184- // Allow microtask queue to flush - if there's an unhandled rejection,
185- // it would surface here or cause the test to fail
186- await Promise . resolve ( ) ;
187- await new Promise ( ( resolve ) => setTimeout ( resolve , 10 ) ) ;
244+ // getSnapshotTree internally does: void p.then(...).catch(...)
245+ // Without the .catch(), if p rejects, the derived promise from .then() also
246+ // rejects and causes an unhandled rejection. This test verifies the fix.
247+ await assert . rejects (
248+ async ( ) => prefetchService . getSnapshotTree ( ) ,
249+ ( error : Error ) => error . message === "Socket timeout" ,
250+ "Caller should receive the error" ,
251+ ) ;
188252
189- // If we reach here without unhandled rejection, the test passes
253+ // Allow microtask queue to flush
254+ await Promise . resolve ( ) ;
255+ await new Promise ( ( resolve ) => setTimeout ( resolve , 10 ) ) ;
256+
257+ // Verify no unhandled rejections occurred
258+ assert . strictEqual (
259+ unhandledRejections . length ,
260+ 0 ,
261+ `Expected no unhandled rejections, but got: ${ JSON . stringify ( unhandledRejections ) } ` ,
262+ ) ;
263+ } finally {
264+ process . off ( "unhandledRejection" , rejectionHandler ) ;
265+ }
190266 } ) ;
191267} ) ;
0 commit comments