Skip to content

Commit c9b18da

Browse files
committed
feat: recognize fatal errors in search sources
1 parent afe749d commit c9b18da

File tree

4 files changed

+99
-2
lines changed

4 files changed

+99
-2
lines changed

src/search/BaseSearchSource.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ abstract class BaseSearchSourceBase<T> implements ISearchSource<T> {
6868
this.state = new StateStore<SearchSourceState<T>>(this.initialState);
6969
}
7070

71+
protected abstract isFatalError(error: Error): boolean;
72+
7173
get lastQueryError() {
7274
return this.state.getLatestValue().lastQueryError;
7375
}
@@ -217,6 +219,9 @@ export abstract class BaseSearchSource<T>
217219

218220
protected abstract filterQueryResults(items: T[]): T[] | Promise<T[]>;
219221

222+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
223+
protected isFatalError = (e: Error) => false;
224+
220225
setDebounceOptions = ({ debounceMs }: DebounceOptions) => {
221226
this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs);
222227
};
@@ -237,6 +242,9 @@ export abstract class BaseSearchSource<T>
237242
stateUpdate.items = await this.filterQueryResults(items);
238243
} catch (e) {
239244
stateUpdate.lastQueryError = e as Error;
245+
if (this.isFatalError(e as Error)) {
246+
stateUpdate.hasNext = false;
247+
}
240248
} finally {
241249
this.state.next(this.getStateAfterQuery(stateUpdate, hasNewSearchQuery));
242250
}
@@ -265,6 +273,10 @@ export abstract class BaseSearchSourceSync<T>
265273

266274
protected abstract filterQueryResults(items: T[]): T[];
267275

276+
/** Signals that with the current search query string it is not possible to perform further requests. */
277+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
278+
protected isFatalError = (e: Error) => false;
279+
268280
setDebounceOptions = ({ debounceMs }: DebounceOptions) => {
269281
this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs);
270282
};
@@ -285,6 +297,9 @@ export abstract class BaseSearchSourceSync<T>
285297
stateUpdate.items = this.filterQueryResults(items);
286298
} catch (e) {
287299
stateUpdate.lastQueryError = e as Error;
300+
if (this.isFatalError(e as Error)) {
301+
stateUpdate.hasNext = false;
302+
}
288303
} finally {
289304
this.state.next(this.getStateAfterQuery(stateUpdate, hasNewSearchQuery));
290305
}

src/search/ChannelSearchSource.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import type { FilterBuilderOptions } from '../pagination';
33
import { FilterBuilder } from '../pagination';
44
import type { Channel } from '../channel';
55
import type { StreamChat } from '../client';
6-
import type { ChannelFilters, ChannelOptions, ChannelSort } from '../types';
6+
import type {
7+
APIErrorResponse,
8+
ChannelFilters,
9+
ChannelOptions,
10+
ChannelSort,
11+
} from '../types';
712
import type { SearchSourceOptions } from './types';
813

914
type CustomContext = Record<string, unknown>;
@@ -70,4 +75,20 @@ export class ChannelSearchSource<
7075
protected filterQueryResults(items: Channel[]) {
7176
return items;
7277
}
78+
79+
protected isFatalError = (error: Error) => {
80+
// unwrapping error message that has been wrapped
81+
// 1. server-side once - /.*failed with error: "(.*)"/
82+
// 2. client-side second time - StreamChat error code \d+:
83+
const originalErrorString = error.message.match(
84+
/StreamChat error code \d+: .*failed with error: "(.*)"/,
85+
)?.[1];
86+
if (!originalErrorString) return false;
87+
try {
88+
const originalError = JSON.parse(originalErrorString) as APIErrorResponse;
89+
return /field is empty or contains invalid characters/.test(originalError.message);
90+
} catch (e) {
91+
return false;
92+
}
93+
};
7394
}

test/unit/search/ChannelSearchSource.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,34 @@ describe('ChannelSearchSource', () => {
184184
context: { searchQuery: 'no-user' },
185185
});
186186
});
187+
188+
describe('method isFatalError', () => {
189+
it('considers fatal an error matching syntax /StreamChat error code \\d+: .*failed with error: "(.*)"/', () => {
190+
const message =
191+
'StreamChat error code 4: QueryChannels failed with error: "{"code":4,"message":"$autocomplete field is empty or contains invalid characters. Please provide a valid string to autocomplete","StatusCode":400,"duration":"","more_info":"","details":[]}"';
192+
// @ts-expect-error accessing protected property
193+
expect(searchSource.isFatalError(new Error(message))).toBe(true);
194+
});
195+
196+
it('considers non-fatal an error not matching syntax /StreamChat error code \\d+: .*failed with error: "(.*)"/', () => {
197+
const message =
198+
'StreamChat error code: QueryChannels failed with error: "{"code":4,"message":"$autocomplete field is empty or contains invalid characters. Please provide a valid string to autocomplete","StatusCode":400,"duration":"","more_info":"","details":[]}"';
199+
// @ts-expect-error accessing protected property
200+
expect(searchSource.isFatalError(new Error(message))).toBe(false);
201+
});
202+
203+
it('considers non-fatal an error with malformed JSON in the error message', () => {
204+
const message =
205+
'QueryChannels failed with error: "{code":4,"message":"$autocomplete field is empty or contains invalid characters. Please provide a valid string to autocomplete","StatusCode":400,"duration":"","more_info":"","details":[]}"';
206+
// @ts-expect-error accessing protected property
207+
expect(searchSource.isFatalError(new Error(message))).toBe(false);
208+
});
209+
210+
it('considers non-fatal an error not containing "field is empty or contains invalid characters"', () => {
211+
const message =
212+
'StreamChat error code 4: QueryChannels failed with error: "{"code":4,"message":"$autocomplete field is empty or xx invalid characters. Please provide a valid string to autocomplete","StatusCode":400,"duration":"","more_info":"","details":[]}"';
213+
// @ts-expect-error accessing protected property
214+
expect(searchSource.isFatalError(new Error(message))).toBe(false);
215+
});
216+
});
187217
});

test/unit/search/SearchController.test.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import { generateUser } from '../test-utils/generateUser';
1111
import { generateChannel } from '../test-utils/generateChannel';
1212

13-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
13+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
1414

1515
describe('SearchController', () => {
1616
let searchController;
@@ -407,6 +407,37 @@ describe('BaseSearchSource and implementations', () => {
407407
expect(searchSource.items).to.deep.equal(['item1']);
408408
expect(searchSource.hasNext).to.be.false;
409409
});
410+
411+
it('stores error from a failed request, keeps ability to paginate', async () => {
412+
searchSource.pageSize = 1;
413+
searchSource.state.partialNext({
414+
items: [],
415+
isActive: true,
416+
searchQuery: 'test',
417+
});
418+
419+
vi.spyOn(searchSource, 'query').mockRejectedValue(new Error('anything'));
420+
421+
await searchSource.executeQuery();
422+
expect(searchSource.items).toStrictEqual([]);
423+
expect(searchSource.hasNext).toBe(true);
424+
});
425+
426+
it('terminates pagination on fatal error', async () => {
427+
searchSource.pageSize = 1;
428+
searchSource.state.partialNext({
429+
items: [],
430+
isActive: true,
431+
searchQuery: 'test',
432+
});
433+
434+
vi.spyOn(searchSource, 'query').mockRejectedValue(new Error('anything'));
435+
vi.spyOn(searchSource, 'isFatalError').mockReturnValue(true);
436+
437+
await searchSource.executeQuery();
438+
expect(searchSource.items).toStrictEqual([]);
439+
expect(searchSource.hasNext).toBe(false);
440+
});
410441
});
411442

412443
describe('search debounce', () => {

0 commit comments

Comments
 (0)