@@ -5,6 +5,20 @@ import { createS2Container, type StartedS2Container } from "@internal/testcontai
55import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server" ;
66import { S2FaultProxy } from "./helpers/s2FaultProxy" ;
77
8+ const creationErrors = [
9+ { status : 409 , code : undefined } ,
10+ { status : 404 , code : "stream_not_found" } ,
11+ ] ;
12+
13+ const terminalErrors = [
14+ { status : 401 , code : "authn" } ,
15+ { status : 403 , code : "permission_denied" } ,
16+ { status : 404 , code : "basin_not_found" } ,
17+ { status : 404 , code : "access_token_not_found" } ,
18+ { status : 404 , code : undefined } ,
19+ { status : 404 , code : "unknown" } ,
20+ ] ;
21+
822describe ( "S2 subscription handshake" , ( ) => {
923 let s2 : StartedS2Container ;
1024 let streams : S2RealtimeStreams ;
@@ -37,50 +51,60 @@ describe("S2 subscription handshake", () => {
3751 ) ;
3852 }
3953
40- it ( "retries creation conflicts without losing the requested resume position" , async ( ) => {
41- const session = randomUUID ( ) ;
42- await streams . appendPartToSessionStream ( "first" , "p0" , session , "out" ) ;
43- await streams . appendPartToSessionStream ( "second" , "p1" , session , "out" ) ;
44- proxy . rejectNextRead ( session , 409 , 2 ) ;
45-
46- const response = await subscribe ( session , undefined , "0" ) ;
47- const body = await response . text ( ) ;
48- expect ( response . status ) . toBe ( 200 ) ;
49- expect ( response . headers . get ( "X-Stream-Version" ) ) . toBe ( "v2" ) ;
50- expect ( body ) . toContain ( "second" ) ;
51- expect ( body ) . not . toContain ( "first" ) ;
52- expect ( proxy . injected . filter ( ( path ) => path . includes ( session ) ) ) . toHaveLength ( 2 ) ;
53- } ) ;
54+ it . each ( creationErrors ) (
55+ "retries HTTP $status ($code) without losing the requested resume position" ,
56+ async ( { status, code } ) => {
57+ const session = randomUUID ( ) ;
58+ await streams . appendPartToSessionStream ( "first" , "p0" , session , "out" ) ;
59+ await streams . appendPartToSessionStream ( "second" , "p1" , session , "out" ) ;
60+ proxy . rejectNextRead ( session , status , 2 , code ) ;
5461
55- it ( "fails after four attempts if the conflict persists" , async ( ) => {
56- const session = randomUUID ( ) ;
57- proxy . rejectNextRead ( session , 409 , 10 ) ;
58- await expect ( subscribe ( session ) ) . rejects . toThrow ( "S2 stream failed: 409" ) ;
59- expect ( proxy . injected . filter ( ( path ) => path . includes ( session ) ) ) . toHaveLength ( 4 ) ;
60- } ) ;
62+ const response = await subscribe ( session , undefined , "0" ) ;
63+ const body = await response . text ( ) ;
64+ expect ( response . status ) . toBe ( 200 ) ;
65+ expect ( response . headers . get ( "X-Stream-Version" ) ) . toBe ( "v2" ) ;
66+ expect ( body ) . toContain ( "second" ) ;
67+ expect ( body ) . not . toContain ( "first" ) ;
68+ expect ( proxy . injected . filter ( ( path ) => path . includes ( session ) ) ) . toHaveLength ( 2 ) ;
69+ }
70+ ) ;
6171
62- it ( "retries non-blocking read conflicts without losing the requested resume position" , async ( ) => {
63- const session = randomUUID ( ) ;
64- await streams . appendPartToSessionStream ( "first" , "p0" , session , "out" ) ;
65- await streams . appendPartToSessionStream ( "second" , "p1" , session , "out" ) ;
66- proxy . rejectNextRead ( session , 409 , 2 ) ;
72+ it . each ( creationErrors ) (
73+ "fails after four attempts if HTTP $status ($code) persists" ,
74+ async ( { status, code } ) => {
75+ const session = randomUUID ( ) ;
76+ proxy . rejectNextRead ( session , status , 10 , code ) ;
77+ await expect ( subscribe ( session ) ) . rejects . toThrow ( `S2 stream failed: ${ status } ` ) ;
78+ expect ( proxy . injected . filter ( ( path ) => path . includes ( session ) ) ) . toHaveLength ( 4 ) ;
79+ }
80+ ) ;
6781
68- const records = await streams . readSessionStreamRecords ( session , "out" , 0 ) ;
82+ it . each ( creationErrors ) (
83+ "retries non-blocking HTTP $status ($code) without losing the requested resume position" ,
84+ async ( { status, code } ) => {
85+ const session = randomUUID ( ) ;
86+ const channel = "updates" ;
87+ await streams . appendPartToSessionStream ( "first" , "p0" , session , "out" , channel ) ;
88+ await streams . appendPartToSessionStream ( "second" , "p1" , session , "out" , channel ) ;
89+ proxy . rejectNextRead ( session , status , 2 , code ) ;
6990
70- expect ( records . map ( ( { data, id, seqNum } ) => ( { data, id, seqNum } ) ) ) . toEqual ( [
71- { data : "second" , id : "p1" , seqNum : 1 } ,
72- ] ) ;
73- expect ( proxy . injected . filter ( ( path ) => path . includes ( session ) ) ) . toHaveLength ( 2 ) ;
74- } ) ;
91+ const records = await streams . readSessionStreamRecords ( session , "out" , 0 , channel ) ;
92+
93+ expect ( records . map ( ( { data, id, seqNum } ) => ( { data, id, seqNum } ) ) ) . toEqual ( [
94+ { data : "second" , id : "p1" , seqNum : 1 } ,
95+ ] ) ;
96+ expect ( proxy . injected . filter ( ( path ) => path . includes ( session ) ) ) . toHaveLength ( 2 ) ;
97+ }
98+ ) ;
7599
76100 it . each ( [
77- { status : 409 , attempts : 3 } ,
78- { status : 403 , attempts : 1 } ,
101+ { status : 409 , attempts : 3 , code : undefined } ,
102+ ... terminalErrors . map ( ( error ) => ( { ... error , attempts : 1 } ) ) ,
79103 ] ) (
80- "bounds non-blocking reads to $attempts attempts for HTTP $status" ,
81- async ( { status, attempts } ) => {
104+ "bounds non-blocking reads to $attempts attempts for HTTP $status ($code) " ,
105+ async ( { status, attempts, code } ) => {
82106 const run = randomUUID ( ) ;
83- proxy . rejectNextRead ( run , status , 10 ) ;
107+ proxy . rejectNextRead ( run , status , 10 , code ) ;
84108
85109 await expect ( streams . readRecords ( run , "input" ) ) . rejects . toThrow (
86110 `S2 readRecords failed: ${ status } `
@@ -89,6 +113,14 @@ describe("S2 subscription handshake", () => {
89113 }
90114 ) ;
91115
116+ it ( "returns no records only after bounded retries for a missing stream" , async ( ) => {
117+ const session = randomUUID ( ) ;
118+ proxy . rejectNextRead ( session , 404 , 10 , "stream_not_found" ) ;
119+
120+ expect ( await streams . readSessionStreamRecords ( session , "in" ) ) . toEqual ( [ ] ) ;
121+ expect ( proxy . injected . filter ( ( path ) => path . includes ( session ) ) ) . toHaveLength ( 3 ) ;
122+ } ) ;
123+
92124 it ( "opens concurrent subscriptions to previously unwritten streams" , async ( ) => {
93125 for ( let round = 0 ; round < 8 ; round ++ ) {
94126 const session = randomUUID ( ) ;
@@ -103,24 +135,27 @@ describe("S2 subscription handshake", () => {
103135 }
104136 } ) ;
105137
106- it ( "does not retry other HTTP errors " , async ( ) => {
138+ it . each ( terminalErrors ) ( "does not retry HTTP $status ($code) " , async ( { status , code } ) => {
107139 const session = randomUUID ( ) ;
108- proxy . rejectNextRead ( session , 403 , 10 ) ;
109- await expect ( subscribe ( session ) ) . rejects . toThrow ( " S2 stream failed: 403" ) ;
140+ proxy . rejectNextRead ( session , status , 10 , code ) ;
141+ await expect ( subscribe ( session ) ) . rejects . toThrow ( ` S2 stream failed: ${ status } ` ) ;
110142 expect ( proxy . injected . filter ( ( path ) => path . includes ( session ) ) ) . toHaveLength ( 1 ) ;
111143 } ) ;
112144
113- it ( "stops retrying when the subscriber disconnects during backoff" , async ( ) => {
114- const session = randomUUID ( ) ;
115- const abort = new AbortController ( ) ;
116- proxy . rejectNextRead ( session , 409 , 10 ) ;
117- const result = subscribe ( session , abort . signal ) . catch ( ( error : unknown ) => error ) ;
118- await expect . poll ( ( ) => proxy . injected . some ( ( path ) => path . includes ( session ) ) ) . toBe ( true ) ;
119- abort . abort ( ) ;
120- expect ( await result ) . toMatchObject ( { name : "AbortError" } ) ;
121- const attempts = proxy . injected . filter ( ( path ) => path . includes ( session ) ) . length ;
122- // Prove no new request is sent after cancellation, across the first retry delay.
123- await setTimeout ( 150 ) ;
124- expect ( proxy . injected . filter ( ( path ) => path . includes ( session ) ) ) . toHaveLength ( attempts ) ;
125- } ) ;
145+ it . each ( creationErrors ) (
146+ "stops retrying HTTP $status ($code) when the subscriber disconnects during backoff" ,
147+ async ( { status, code } ) => {
148+ const session = randomUUID ( ) ;
149+ const abort = new AbortController ( ) ;
150+ proxy . rejectNextRead ( session , status , 10 , code ) ;
151+ const result = subscribe ( session , abort . signal ) . catch ( ( error : unknown ) => error ) ;
152+ await expect . poll ( ( ) => proxy . injected . some ( ( path ) => path . includes ( session ) ) ) . toBe ( true ) ;
153+ abort . abort ( ) ;
154+ expect ( await result ) . toMatchObject ( { name : "AbortError" } ) ;
155+ const attempts = proxy . injected . filter ( ( path ) => path . includes ( session ) ) . length ;
156+ // Prove no new request is sent after cancellation, across the first retry delay.
157+ await setTimeout ( 150 ) ;
158+ expect ( proxy . injected . filter ( ( path ) => path . includes ( session ) ) ) . toHaveLength ( attempts ) ;
159+ }
160+ ) ;
126161} ) ;
0 commit comments