@linktr.ee/messaging-react 1.22.2 → 1.23.0-rc-1772427007
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -11,6 +11,7 @@ import { ParticipantPicker } from '../ParticipantPicker'
|
|
|
11
11
|
import { EmptyState } from './EmptyState'
|
|
12
12
|
import { ErrorState } from './ErrorState'
|
|
13
13
|
import { LoadingState } from './LoadingState'
|
|
14
|
+
import { createQueryChannelsManager } from './queryChannelsManager'
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Main messaging interface component that combines channel list and channel view
|
|
@@ -94,64 +95,96 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
94
95
|
|
|
95
96
|
// Track if we've already synced channels to prevent repeated API calls
|
|
96
97
|
const syncedRef = useRef<string | null>(null)
|
|
98
|
+
const syncRequestNonceRef = useRef(0)
|
|
99
|
+
const onChannelSelectRef = useRef(onChannelSelect)
|
|
100
|
+
const debugEnabledRef = useRef(debug)
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
onChannelSelectRef.current = onChannelSelect
|
|
104
|
+
}, [onChannelSelect])
|
|
105
|
+
|
|
106
|
+
const queryChannelsManager = React.useMemo(() => {
|
|
107
|
+
if (!client) return null
|
|
108
|
+
|
|
109
|
+
return createQueryChannelsManager({
|
|
110
|
+
client,
|
|
111
|
+
onRetry: ({ requestKey, attempt, delayMs, error }) => {
|
|
112
|
+
if (!debugEnabledRef.current) return
|
|
113
|
+
|
|
114
|
+
console.warn('[MessagingShell] queryChannels rate limited; retrying', {
|
|
115
|
+
requestKey,
|
|
116
|
+
attempt,
|
|
117
|
+
delayMs,
|
|
118
|
+
error,
|
|
119
|
+
})
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
}, [client])
|
|
97
123
|
|
|
98
124
|
// Function to sync channels (extracted for reuse)
|
|
99
|
-
const syncChannels = useCallback(
|
|
100
|
-
|
|
125
|
+
const syncChannels = useCallback(
|
|
126
|
+
async ({ forceFresh = false }: { forceFresh?: boolean } = {}) => {
|
|
127
|
+
if (!client || !isConnected || !queryChannelsManager) return
|
|
101
128
|
|
|
102
|
-
|
|
103
|
-
|
|
129
|
+
const userId = client.userID
|
|
130
|
+
if (!userId) return
|
|
104
131
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
132
|
+
try {
|
|
133
|
+
if (debug) {
|
|
134
|
+
console.log('[MessagingShell] Syncing channels for user:', userId)
|
|
135
|
+
}
|
|
109
136
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const members = channel.state.members
|
|
122
|
-
Object.values(members).forEach((member) => {
|
|
123
|
-
const memberId = member.user?.id
|
|
124
|
-
if (memberId && memberId !== userId) {
|
|
125
|
-
memberIds.add(memberId)
|
|
126
|
-
}
|
|
137
|
+
const requestKey = forceFresh
|
|
138
|
+
? `sync:${userId}:refresh:${syncRequestNonceRef.current++}`
|
|
139
|
+
: `sync:${userId}`
|
|
140
|
+
|
|
141
|
+
const channels = await queryChannelsManager.query({
|
|
142
|
+
requestKey,
|
|
143
|
+
filters: {
|
|
144
|
+
type: 'messaging',
|
|
145
|
+
members: { $in: [userId] },
|
|
146
|
+
},
|
|
147
|
+
options: { limit: 100 },
|
|
127
148
|
})
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
// Only update if the set contents have changed to prevent re-renders
|
|
131
|
-
setExistingParticipantIds((prev) => {
|
|
132
|
-
if (
|
|
133
|
-
prev.size === memberIds.size &&
|
|
134
|
-
[...prev].every((id) => memberIds.has(id))
|
|
135
|
-
) {
|
|
136
|
-
return prev
|
|
137
|
-
}
|
|
138
|
-
return memberIds
|
|
139
|
-
})
|
|
140
|
-
setHasChannels(channels.length > 0)
|
|
141
|
-
setChannelsLoaded(true)
|
|
142
|
-
syncedRef.current = userId // Mark as synced for this user
|
|
143
149
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
150
|
+
const memberIds = new Set<string>()
|
|
151
|
+
channels.forEach((channel: Channel) => {
|
|
152
|
+
const members = channel.state.members
|
|
153
|
+
Object.values(members).forEach((member) => {
|
|
154
|
+
const memberId = member.user?.id
|
|
155
|
+
if (memberId && memberId !== userId) {
|
|
156
|
+
memberIds.add(memberId)
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Only update if the set contents have changed to prevent re-renders
|
|
162
|
+
setExistingParticipantIds((prev) => {
|
|
163
|
+
if (
|
|
164
|
+
prev.size === memberIds.size &&
|
|
165
|
+
[...prev].every((id) => memberIds.has(id))
|
|
166
|
+
) {
|
|
167
|
+
return prev
|
|
168
|
+
}
|
|
169
|
+
return memberIds
|
|
148
170
|
})
|
|
171
|
+
setHasChannels(channels.length > 0)
|
|
172
|
+
setChannelsLoaded(true)
|
|
173
|
+
syncedRef.current = userId // Mark as synced for this user
|
|
174
|
+
|
|
175
|
+
if (debug) {
|
|
176
|
+
console.log('[MessagingShell] Channels synced successfully:', {
|
|
177
|
+
channelCount: channels.length,
|
|
178
|
+
memberCount: memberIds.size,
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
console.error('[MessagingShell] Failed to sync channels:', error)
|
|
183
|
+
// Don't mark as synced on error, allow retry
|
|
149
184
|
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
}, [client, isConnected, debug])
|
|
185
|
+
},
|
|
186
|
+
[client, isConnected, debug, queryChannelsManager]
|
|
187
|
+
)
|
|
155
188
|
|
|
156
189
|
// Sync existing channels to track which participants we can already message
|
|
157
190
|
useEffect(() => {
|
|
@@ -163,12 +196,19 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
163
196
|
// Prevent repeated sync for the same user
|
|
164
197
|
if (syncedRef.current === userId) return
|
|
165
198
|
|
|
166
|
-
syncChannels()
|
|
199
|
+
void syncChannels()
|
|
167
200
|
}, [client, isConnected, syncChannels])
|
|
168
201
|
|
|
169
202
|
// Load initial channel for direct conversation mode
|
|
170
203
|
useEffect(() => {
|
|
171
|
-
if (
|
|
204
|
+
if (
|
|
205
|
+
!initialParticipantFilter ||
|
|
206
|
+
!client ||
|
|
207
|
+
!isConnected ||
|
|
208
|
+
!queryChannelsManager
|
|
209
|
+
) {
|
|
210
|
+
return
|
|
211
|
+
}
|
|
172
212
|
|
|
173
213
|
const loadInitialChannel = async () => {
|
|
174
214
|
const userId = client.userID
|
|
@@ -182,14 +222,14 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
182
222
|
)
|
|
183
223
|
}
|
|
184
224
|
|
|
185
|
-
const channels = await
|
|
186
|
-
{
|
|
225
|
+
const channels = await queryChannelsManager.query({
|
|
226
|
+
requestKey: `direct:${userId}:${initialParticipantFilter}`,
|
|
227
|
+
filters: {
|
|
187
228
|
type: 'messaging',
|
|
188
229
|
members: { $eq: [userId, initialParticipantFilter] },
|
|
189
230
|
},
|
|
190
|
-
{},
|
|
191
|
-
|
|
192
|
-
)
|
|
231
|
+
options: { limit: 1 },
|
|
232
|
+
})
|
|
193
233
|
|
|
194
234
|
if (channels.length > 0) {
|
|
195
235
|
setSelectedChannel(channels[0])
|
|
@@ -197,9 +237,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
197
237
|
setDirectConversationError(null)
|
|
198
238
|
|
|
199
239
|
// Notify parent component of channel selection
|
|
200
|
-
|
|
201
|
-
onChannelSelect(channels[0])
|
|
202
|
-
}
|
|
240
|
+
onChannelSelectRef.current?.(channels[0])
|
|
203
241
|
|
|
204
242
|
if (debug) {
|
|
205
243
|
console.log(
|
|
@@ -231,9 +269,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
231
269
|
setDirectConversationError(null)
|
|
232
270
|
|
|
233
271
|
// Notify parent component of channel selection
|
|
234
|
-
|
|
235
|
-
onChannelSelect(channel)
|
|
236
|
-
}
|
|
272
|
+
onChannelSelectRef.current?.(channel)
|
|
237
273
|
|
|
238
274
|
if (debug) {
|
|
239
275
|
console.log(
|
|
@@ -271,7 +307,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
271
307
|
}
|
|
272
308
|
}
|
|
273
309
|
|
|
274
|
-
loadInitialChannel()
|
|
310
|
+
void loadInitialChannel()
|
|
275
311
|
}, [
|
|
276
312
|
initialParticipantFilter,
|
|
277
313
|
initialParticipantData,
|
|
@@ -279,7 +315,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
279
315
|
isConnected,
|
|
280
316
|
service,
|
|
281
317
|
debug,
|
|
282
|
-
|
|
318
|
+
queryChannelsManager,
|
|
283
319
|
])
|
|
284
320
|
|
|
285
321
|
const handleChannelSelect = useCallback(
|
|
@@ -360,7 +396,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
360
396
|
|
|
361
397
|
// Force re-sync to update the existing participants list
|
|
362
398
|
syncedRef.current = null
|
|
363
|
-
await syncChannels()
|
|
399
|
+
await syncChannels({ forceFresh: true })
|
|
364
400
|
},
|
|
365
401
|
[syncChannels, debug]
|
|
366
402
|
)
|
|
@@ -375,7 +411,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
375
411
|
|
|
376
412
|
// Force re-sync to update the existing participants list
|
|
377
413
|
syncedRef.current = null
|
|
378
|
-
await syncChannels()
|
|
414
|
+
await syncChannels({ forceFresh: true })
|
|
379
415
|
},
|
|
380
416
|
[syncChannels, debug]
|
|
381
417
|
)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createQueryChannelsManager,
|
|
5
|
+
getRetryDelayMsFromRateLimitError,
|
|
6
|
+
isRateLimitError,
|
|
7
|
+
} from './queryChannelsManager'
|
|
8
|
+
|
|
9
|
+
type MockChannel = { id: string }
|
|
10
|
+
|
|
11
|
+
describe('queryChannelsManager', () => {
|
|
12
|
+
it('dedupes in-flight requests for the same request key', async () => {
|
|
13
|
+
const resolverRef: { current: ((value: MockChannel[]) => void) | null } = {
|
|
14
|
+
current: null,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const queryChannels = vi.fn(
|
|
18
|
+
() =>
|
|
19
|
+
new Promise<MockChannel[]>((resolve) => {
|
|
20
|
+
resolverRef.current = resolve
|
|
21
|
+
})
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const manager = createQueryChannelsManager({
|
|
25
|
+
client: { queryChannels } as never,
|
|
26
|
+
wait: async () => {},
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const firstPromise = manager.query({
|
|
30
|
+
requestKey: 'direct:viewer:participant',
|
|
31
|
+
filters: {
|
|
32
|
+
type: 'messaging',
|
|
33
|
+
},
|
|
34
|
+
options: { limit: 1 },
|
|
35
|
+
})
|
|
36
|
+
const secondPromise = manager.query({
|
|
37
|
+
requestKey: 'direct:viewer:participant',
|
|
38
|
+
filters: {
|
|
39
|
+
type: 'messaging',
|
|
40
|
+
},
|
|
41
|
+
options: { limit: 1 },
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(queryChannels).toHaveBeenCalledTimes(1)
|
|
45
|
+
|
|
46
|
+
const resolveQuery = resolverRef.current
|
|
47
|
+
expect(resolveQuery).toBeTypeOf('function')
|
|
48
|
+
if (!resolveQuery) {
|
|
49
|
+
throw new Error('Expected in-flight query resolver to be set')
|
|
50
|
+
}
|
|
51
|
+
resolveQuery([{ id: 'channel-1' }])
|
|
52
|
+
|
|
53
|
+
await expect(firstPromise).resolves.toEqual([{ id: 'channel-1' }])
|
|
54
|
+
await expect(secondPromise).resolves.toEqual([{ id: 'channel-1' }])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('does not dedupe requests that use different request keys', async () => {
|
|
58
|
+
const queryChannels = vi.fn().mockResolvedValue([{ id: 'channel-1' }])
|
|
59
|
+
|
|
60
|
+
const manager = createQueryChannelsManager({
|
|
61
|
+
client: { queryChannels } as never,
|
|
62
|
+
wait: async () => {},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await manager.query({
|
|
66
|
+
requestKey: 'sync:user',
|
|
67
|
+
filters: {
|
|
68
|
+
type: 'messaging',
|
|
69
|
+
},
|
|
70
|
+
options: { limit: 1 },
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
await manager.query({
|
|
74
|
+
requestKey: 'sync:user:refresh:1',
|
|
75
|
+
filters: {
|
|
76
|
+
type: 'messaging',
|
|
77
|
+
},
|
|
78
|
+
options: { limit: 1 },
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(queryChannels).toHaveBeenCalledTimes(2)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('retries with retry-after header when queryChannels returns a 429 response', async () => {
|
|
85
|
+
const wait = vi.fn(async () => {})
|
|
86
|
+
const queryChannels = vi
|
|
87
|
+
.fn()
|
|
88
|
+
.mockRejectedValueOnce({
|
|
89
|
+
response: {
|
|
90
|
+
status: 429,
|
|
91
|
+
headers: {
|
|
92
|
+
'retry-after': '2',
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
.mockResolvedValueOnce([{ id: 'channel-1' }])
|
|
97
|
+
|
|
98
|
+
const manager = createQueryChannelsManager({
|
|
99
|
+
client: { queryChannels } as never,
|
|
100
|
+
wait,
|
|
101
|
+
maxRetries: 2,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const channels = await manager.query({
|
|
105
|
+
requestKey: 'direct:viewer:participant',
|
|
106
|
+
filters: {
|
|
107
|
+
type: 'messaging',
|
|
108
|
+
},
|
|
109
|
+
options: { limit: 1 },
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
expect(wait).toHaveBeenCalledWith(2000)
|
|
113
|
+
expect(queryChannels).toHaveBeenCalledTimes(2)
|
|
114
|
+
expect(channels).toEqual([{ id: 'channel-1' }])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('falls back to x-ratelimit-reset header when retry-after is absent', () => {
|
|
118
|
+
const delay = getRetryDelayMsFromRateLimitError(
|
|
119
|
+
{
|
|
120
|
+
response: {
|
|
121
|
+
status: 429,
|
|
122
|
+
headers: {
|
|
123
|
+
'x-ratelimit-reset': '12',
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
0,
|
|
128
|
+
10_000
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
expect(delay).toBe(2_000)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('retries for stream error code 9 even if status is unavailable', async () => {
|
|
135
|
+
const wait = vi.fn(async () => {})
|
|
136
|
+
const queryChannels = vi
|
|
137
|
+
.fn()
|
|
138
|
+
.mockRejectedValueOnce({
|
|
139
|
+
code: 9,
|
|
140
|
+
message:
|
|
141
|
+
'StreamChat error code 9: QueryChannels failed with error: "Too many requests"',
|
|
142
|
+
})
|
|
143
|
+
.mockResolvedValueOnce([{ id: 'channel-1' }])
|
|
144
|
+
|
|
145
|
+
const manager = createQueryChannelsManager({
|
|
146
|
+
client: { queryChannels } as never,
|
|
147
|
+
wait,
|
|
148
|
+
maxRetries: 2,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const channels = await manager.query({
|
|
152
|
+
requestKey: 'direct:viewer:participant',
|
|
153
|
+
filters: {
|
|
154
|
+
type: 'messaging',
|
|
155
|
+
},
|
|
156
|
+
options: { limit: 1 },
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
expect(wait).toHaveBeenCalledTimes(1)
|
|
160
|
+
expect(queryChannels).toHaveBeenCalledTimes(2)
|
|
161
|
+
expect(channels).toEqual([{ id: 'channel-1' }])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('does not retry non-rate-limit errors', async () => {
|
|
165
|
+
const wait = vi.fn(async () => {})
|
|
166
|
+
const queryChannels = vi
|
|
167
|
+
.fn()
|
|
168
|
+
.mockRejectedValueOnce(new Error('network down'))
|
|
169
|
+
|
|
170
|
+
const manager = createQueryChannelsManager({
|
|
171
|
+
client: { queryChannels } as never,
|
|
172
|
+
wait,
|
|
173
|
+
maxRetries: 2,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
await expect(
|
|
177
|
+
manager.query({
|
|
178
|
+
requestKey: 'direct:viewer:participant',
|
|
179
|
+
filters: {
|
|
180
|
+
type: 'messaging',
|
|
181
|
+
},
|
|
182
|
+
options: { limit: 1 },
|
|
183
|
+
})
|
|
184
|
+
).rejects.toThrow('network down')
|
|
185
|
+
|
|
186
|
+
expect(wait).not.toHaveBeenCalled()
|
|
187
|
+
expect(queryChannels).toHaveBeenCalledTimes(1)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('throws after max retries are exhausted', async () => {
|
|
191
|
+
const wait = vi.fn(async () => {})
|
|
192
|
+
const rateLimitError = {
|
|
193
|
+
response: {
|
|
194
|
+
status: 429,
|
|
195
|
+
headers: {},
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const queryChannels = vi.fn().mockRejectedValue(rateLimitError)
|
|
200
|
+
|
|
201
|
+
const manager = createQueryChannelsManager({
|
|
202
|
+
client: { queryChannels } as never,
|
|
203
|
+
wait,
|
|
204
|
+
maxRetries: 1,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
await expect(
|
|
208
|
+
manager.query({
|
|
209
|
+
requestKey: 'direct:viewer:participant',
|
|
210
|
+
filters: {
|
|
211
|
+
type: 'messaging',
|
|
212
|
+
},
|
|
213
|
+
options: { limit: 1 },
|
|
214
|
+
})
|
|
215
|
+
).rejects.toBe(rateLimitError)
|
|
216
|
+
|
|
217
|
+
expect(wait).toHaveBeenCalledTimes(1)
|
|
218
|
+
expect(queryChannels).toHaveBeenCalledTimes(2)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('isRateLimitError', () => {
|
|
223
|
+
it('returns true for response status 429', () => {
|
|
224
|
+
expect(
|
|
225
|
+
isRateLimitError({
|
|
226
|
+
response: {
|
|
227
|
+
status: 429,
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
).toBe(true)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('returns true for stream code 9', () => {
|
|
234
|
+
expect(
|
|
235
|
+
isRateLimitError({
|
|
236
|
+
code: 9,
|
|
237
|
+
})
|
|
238
|
+
).toBe(true)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('returns false for non-rate-limit errors', () => {
|
|
242
|
+
expect(isRateLimitError(new Error('oops'))).toBe(false)
|
|
243
|
+
})
|
|
244
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { StreamChat } from 'stream-chat'
|
|
2
|
+
|
|
3
|
+
type QueryChannelsFilters = Parameters<StreamChat['queryChannels']>[0]
|
|
4
|
+
type QueryChannelsSort = Parameters<StreamChat['queryChannels']>[1]
|
|
5
|
+
type QueryChannelsOptions = Parameters<StreamChat['queryChannels']>[2]
|
|
6
|
+
type QueryChannelsResult = Awaited<ReturnType<StreamChat['queryChannels']>>
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MAX_RETRIES = 2
|
|
9
|
+
const DEFAULT_BASE_DELAY_MS = 300
|
|
10
|
+
const MAX_DELAY_MS = 10_000
|
|
11
|
+
|
|
12
|
+
const sleep = async (ms: number): Promise<void> =>
|
|
13
|
+
await new Promise((resolve) => setTimeout(resolve, ms))
|
|
14
|
+
|
|
15
|
+
type HeaderValue = string | number | string[] | undefined
|
|
16
|
+
|
|
17
|
+
const getHeaderValue = (
|
|
18
|
+
headers: Record<string, unknown> | undefined,
|
|
19
|
+
headerName: string
|
|
20
|
+
): HeaderValue => {
|
|
21
|
+
if (!headers) return undefined
|
|
22
|
+
|
|
23
|
+
const match = Object.entries(headers).find(
|
|
24
|
+
([key]) => key.toLowerCase() === headerName.toLowerCase()
|
|
25
|
+
)
|
|
26
|
+
return match ? (match[1] as HeaderValue) : undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const toNumber = (value: unknown): number | null => {
|
|
30
|
+
const parsed = Number(value)
|
|
31
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const getErrorResponse = (
|
|
35
|
+
error: unknown
|
|
36
|
+
): { status?: number; headers?: Record<string, unknown> } | null => {
|
|
37
|
+
if (!error || typeof error !== 'object') return null
|
|
38
|
+
|
|
39
|
+
const maybeResponse = (error as { response?: unknown }).response
|
|
40
|
+
if (!maybeResponse || typeof maybeResponse !== 'object') return null
|
|
41
|
+
|
|
42
|
+
return maybeResponse as { status?: number; headers?: Record<string, unknown> }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const isRateLimitError = (error: unknown): boolean => {
|
|
46
|
+
const response = getErrorResponse(error)
|
|
47
|
+
if (response?.status === 429) return true
|
|
48
|
+
|
|
49
|
+
if (error && typeof error === 'object') {
|
|
50
|
+
const code = toNumber((error as { code?: unknown }).code)
|
|
51
|
+
if (code === 9) return true
|
|
52
|
+
|
|
53
|
+
const status = toNumber((error as { status?: unknown }).status)
|
|
54
|
+
if (status === 429) return true
|
|
55
|
+
|
|
56
|
+
const message = (error as { message?: unknown }).message
|
|
57
|
+
if (typeof message === 'string') {
|
|
58
|
+
const normalizedMessage = message.toLowerCase()
|
|
59
|
+
if (
|
|
60
|
+
normalizedMessage.includes('too many requests') ||
|
|
61
|
+
normalizedMessage.includes('rate limit')
|
|
62
|
+
) {
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const getRetryDelayMsFromRateLimitError = (
|
|
72
|
+
error: unknown,
|
|
73
|
+
attempt: number,
|
|
74
|
+
nowMs = Date.now()
|
|
75
|
+
): number => {
|
|
76
|
+
const headers = getErrorResponse(error)?.headers
|
|
77
|
+
|
|
78
|
+
const retryAfterHeader = getHeaderValue(headers, 'retry-after')
|
|
79
|
+
const retryAfterSeconds = toNumber(
|
|
80
|
+
Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader
|
|
81
|
+
)
|
|
82
|
+
if (retryAfterSeconds !== null && retryAfterSeconds >= 0) {
|
|
83
|
+
return Math.min(Math.round(retryAfterSeconds * 1000), MAX_DELAY_MS)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const resetHeader = getHeaderValue(headers, 'x-ratelimit-reset')
|
|
87
|
+
const resetEpochSeconds = toNumber(
|
|
88
|
+
Array.isArray(resetHeader) ? resetHeader[0] : resetHeader
|
|
89
|
+
)
|
|
90
|
+
if (resetEpochSeconds !== null && resetEpochSeconds > 0) {
|
|
91
|
+
const delayMs = Math.round(resetEpochSeconds * 1000 - nowMs)
|
|
92
|
+
return Math.min(Math.max(delayMs, 0), MAX_DELAY_MS)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const exponentialBackoffMs = DEFAULT_BASE_DELAY_MS * 2 ** attempt
|
|
96
|
+
return Math.min(exponentialBackoffMs, MAX_DELAY_MS)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface QueryRequest {
|
|
100
|
+
requestKey: string
|
|
101
|
+
filters: QueryChannelsFilters
|
|
102
|
+
sort?: QueryChannelsSort
|
|
103
|
+
options?: QueryChannelsOptions
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface QueryChannelsManagerConfig {
|
|
107
|
+
client: Pick<StreamChat, 'queryChannels'>
|
|
108
|
+
maxRetries?: number
|
|
109
|
+
wait?: (ms: number) => Promise<void>
|
|
110
|
+
now?: () => number
|
|
111
|
+
onRetry?: (details: {
|
|
112
|
+
requestKey: string
|
|
113
|
+
attempt: number
|
|
114
|
+
delayMs: number
|
|
115
|
+
error: unknown
|
|
116
|
+
}) => void
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const createQueryChannelsManager = ({
|
|
120
|
+
client,
|
|
121
|
+
maxRetries = DEFAULT_MAX_RETRIES,
|
|
122
|
+
wait = sleep,
|
|
123
|
+
now = Date.now,
|
|
124
|
+
onRetry,
|
|
125
|
+
}: QueryChannelsManagerConfig) => {
|
|
126
|
+
const inFlightByRequestKey = new Map<string, Promise<QueryChannelsResult>>()
|
|
127
|
+
|
|
128
|
+
const query = async ({
|
|
129
|
+
requestKey,
|
|
130
|
+
filters,
|
|
131
|
+
sort = {} as QueryChannelsSort,
|
|
132
|
+
options = {} as QueryChannelsOptions,
|
|
133
|
+
}: QueryRequest): Promise<QueryChannelsResult> => {
|
|
134
|
+
const inFlightRequest = inFlightByRequestKey.get(requestKey)
|
|
135
|
+
if (inFlightRequest) {
|
|
136
|
+
return await inFlightRequest
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const requestPromise = (async () => {
|
|
140
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
141
|
+
try {
|
|
142
|
+
return await client.queryChannels(filters, sort, options)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (!isRateLimitError(error) || attempt >= maxRetries) {
|
|
145
|
+
throw error
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const delayMs = getRetryDelayMsFromRateLimitError(
|
|
149
|
+
error,
|
|
150
|
+
attempt,
|
|
151
|
+
now()
|
|
152
|
+
)
|
|
153
|
+
onRetry?.({
|
|
154
|
+
requestKey,
|
|
155
|
+
attempt: attempt + 1,
|
|
156
|
+
delayMs,
|
|
157
|
+
error,
|
|
158
|
+
})
|
|
159
|
+
await wait(delayMs)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
})()
|
|
163
|
+
|
|
164
|
+
inFlightByRequestKey.set(requestKey, requestPromise)
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
return await requestPromise
|
|
168
|
+
} finally {
|
|
169
|
+
inFlightByRequestKey.delete(requestKey)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { query }
|
|
174
|
+
}
|