Skip to content

Commit ea830a5

Browse files
jonathanMLDevzhao
andauthored
Harden retry/timeout classification on structured HTTP status and bra… (#235)
* Harden retry/timeout classification on structured HTTP status and branded app timeouts * addressed ai reviews * addressed all reviews * make tests more strength --------- Co-authored-by: zhao <jornathanm910923@gmail.com>
1 parent 1bdb56a commit ea830a5

9 files changed

Lines changed: 340 additions & 31 deletions

File tree

src/core/pinecone-client.test.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { resolveConfig } from './config.js';
44
import type { SearchableIndex, PineconeHit } from '../types.js';
55
import * as rerankModule from './pinecone/rerank.js';
66
import { DENSE_LEG_FAILED_REASON, SPARSE_LEG_FAILED_REASON } from '../constants.js';
7+
import { AppTimeoutError } from './server/retry.js';
78

89
/** Stubs for private methods (assigned at runtime; avoid intersecting private `PineconeClient` members). */
910
type PineconeClientMethodStubs = {
@@ -261,10 +262,7 @@ describe('PineconeClient', () => {
261262

262263
it('propagates app timeout when both dense and sparse searches fail', async () => {
263264
const testClient = stubPineconeClient(client);
264-
stubDualLegSearchFailure(
265-
testClient,
266-
new Error('Timeout after 50ms while waiting for search')
267-
);
265+
stubDualLegSearchFailure(testClient, new AppTimeoutError(50, 'search'));
268266

269267
await expect(
270268
client.query({
@@ -273,7 +271,7 @@ describe('PineconeClient', () => {
273271
topK: 5,
274272
useReranking: false,
275273
})
276-
).rejects.toThrow('Timeout after 50ms while waiting for search');
274+
).rejects.toBeInstanceOf(AppTimeoutError);
277275
});
278276
});
279277

@@ -573,7 +571,7 @@ describe('PineconeClient', () => {
573571
});
574572

575573
const p = timeoutClient.keywordSearch({ query: 'q', namespace: 'n' });
576-
const assertion = expect(p).rejects.toThrow(/Timeout after 50ms while waiting for search/);
574+
const assertion = expect(p).rejects.toBeInstanceOf(AppTimeoutError);
577575
await vi.advanceTimersByTimeAsync(50);
578576
await assertion;
579577
});

src/core/pinecone/indexes.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi, afterEach } from 'vitest';
22
import { PineconeIndexSession } from './indexes.js';
33
import type { SearchableIndex } from '../../types.js';
4+
import { makeStructured429Once } from './test-helpers.js';
45

56
/** Subclass so tests inject index handles without calling the real Pinecone SDK. */
67
class PineconeIndexSessionTestDouble extends PineconeIndexSession {
@@ -144,6 +145,24 @@ describe('PineconeIndexSession', () => {
144145
expect(sparse.describeIndexStats).toHaveBeenCalledTimes(2);
145146
});
146147

148+
it('retries describeIndexStats on structured 429 then succeeds', async () => {
149+
const stats = { namespaces: { papers: { recordCount: 7 } } };
150+
const describeIndexStats = makeStructured429Once(stats);
151+
const sparse = { describeIndexStats } as unknown as SearchableIndex;
152+
const session = new PineconeIndexSessionTestDouble({
153+
dense: {} as SearchableIndex,
154+
sparse,
155+
});
156+
157+
const result = await session.listNamespacesFromKeywordIndex();
158+
159+
expect(result.ok).toBe(true);
160+
if (result.ok) {
161+
expect(result.namespaces).toEqual([{ namespace: 'papers', recordCount: 7 }]);
162+
}
163+
expect(describeIndexStats).toHaveBeenCalledTimes(2);
164+
});
165+
147166
it('invokes describeIndexStats on the sparse index receiver through runIo', async () => {
148167
const sparse = makeThisSensitiveIndex({
149168
namespaces: { papers: { recordCount: 42 } },

src/core/pinecone/rerank.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi } from 'vitest';
22
import { rerankResults } from './rerank.js';
33
import type { MergedHit } from '../../types.js';
4+
import { makeStructured429Once } from './test-helpers.js';
45

56
const sampleMerged: MergedHit[] = [
67
{ _id: '1', _score: 0.5, chunk_text: 'hello', metadata: { k: 'v' } },
@@ -75,6 +76,25 @@ describe('rerankResults', () => {
7576
expect(rerank).toHaveBeenCalledTimes(2);
7677
});
7778

79+
it('retries on structured 429 without 429 in message then succeeds', async () => {
80+
const success = {
81+
data: [
82+
{
83+
score: 0.99,
84+
document: { _id: '1', chunk_text: 'hello', metadata: { k: 'v' } },
85+
},
86+
],
87+
};
88+
const rerank = makeStructured429Once(success);
89+
const pc = makePc(rerank);
90+
91+
const out = await rerankResults(pc, 'm', 'q', sampleMerged, 5);
92+
93+
expect(out.degraded).toBe(false);
94+
expect(out.results[0]?.reranked).toBe(true);
95+
expect(rerank).toHaveBeenCalledTimes(2);
96+
});
97+
7898
it('degrades after exhausting retries on persistent 503', async () => {
7999
const rerank = vi.fn().mockRejectedValue(new Error('HTTP 503'));
80100
const pc = makePc(rerank);

src/core/pinecone/search.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
import { AppTimeoutError } from '../server/retry.js';
3+
import { makeStructured429Once } from './test-helpers.js';
24
import {
35
searchIndex,
46
mergeResults,
@@ -77,19 +79,28 @@ describe('searchIndex', () => {
7779
expect(search).toHaveBeenCalledTimes(2);
7880
});
7981

82+
it('retries on structured 429 without 429 in message then succeeds', async () => {
83+
const success = { result: { hits: [{ _id: '1', _score: 1, fields: {} }] } };
84+
const search = makeStructured429Once(success);
85+
const index = { search } as unknown as SearchableIndex;
86+
const hits = await searchIndex(index, 'hi', 5);
87+
expect(hits).toHaveLength(1);
88+
expect(search).toHaveBeenCalledTimes(2);
89+
});
90+
8091
it('does not retry on 401', async () => {
8192
const search = vi.fn().mockRejectedValue(new Error('HTTP 401'));
8293
const index = { search } as unknown as SearchableIndex;
8394
await expect(searchIndex(index, 'hi', 5)).rejects.toThrow(/401/);
8495
expect(search).toHaveBeenCalledTimes(1);
8596
});
8697

87-
it('times out at requestTimeoutMs and preserves the timeout error prefix', async () => {
98+
it('times out at requestTimeoutMs and rejects with AppTimeoutError', async () => {
8899
vi.useFakeTimers();
89100
const search = vi.fn(() => new Promise(() => {}));
90101
const index = { search } as unknown as SearchableIndex;
91102
const p = searchIndex(index, 'hi', 5, undefined, undefined, undefined, 50);
92-
const assertion = expect(p).rejects.toThrow(/^Timeout after 50ms while waiting for search/);
103+
const assertion = expect(p).rejects.toBeInstanceOf(AppTimeoutError);
93104
await vi.advanceTimersByTimeAsync(50);
94105
await assertion;
95106
});

src/core/pinecone/test-helpers.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { vi } from 'vitest';
2+
3+
/** First call throws structured HTTP 429; subsequent calls resolve with `success`. */
4+
export function makeStructured429Once<T>(success: T): ReturnType<typeof vi.fn> {
5+
let n = 0;
6+
return vi.fn().mockImplementation(async () => {
7+
n++;
8+
if (n < 2) {
9+
throw Object.assign(new Error('Rate limited'), { status: 429 });
10+
}
11+
return success;
12+
});
13+
}

src/core/server/retry.test.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
transientShouldRetry,
77
isAppTimeoutError,
88
runWithPolicy,
9+
AppTimeoutError,
10+
getHttpStatus,
911
} from './retry.js';
1012

1113
afterEach(() => {
@@ -19,6 +21,61 @@ describe('defaultShouldRetry', () => {
1921
it('does not retry on 400', () => {
2022
expect(defaultShouldRetry(new Error('HTTP 400'))).toBe(false);
2123
});
24+
25+
it('retries on structured status 429 without 429 in message', () => {
26+
const err = Object.assign(new Error('Too many requests'), { status: 429 });
27+
expect(defaultShouldRetry(err)).toBe(true);
28+
});
29+
30+
it('retries on structured status 408 without status in message', () => {
31+
const err = Object.assign(new Error('Request timeout'), { status: 408 });
32+
expect(defaultShouldRetry(err)).toBe(true);
33+
});
34+
35+
it('retries on PineconeUnmappedHttpError with Status: 429 in message', () => {
36+
const err = Object.assign(new Error('Status: 429. Body: throttled'), {
37+
name: 'PineconeUnmappedHttpError',
38+
});
39+
expect(defaultShouldRetry(err)).toBe(true);
40+
expect(getHttpStatus(err)).toBe(429);
41+
});
42+
43+
it('retries on statusCode 503 without status code in message', () => {
44+
const err = Object.assign(new Error('Service unavailable'), { statusCode: 503 });
45+
expect(defaultShouldRetry(err)).toBe(true);
46+
});
47+
48+
it('retries on PineconeUnavailableError with reworded message', () => {
49+
const err = Object.assign(new Error('Service is down for maintenance'), {
50+
name: 'PineconeUnavailableError',
51+
});
52+
expect(defaultShouldRetry(err)).toBe(true);
53+
expect(getHttpStatus(err)).toBe(503);
54+
});
55+
56+
it('retries on ECONNRESET message without structured status', () => {
57+
expect(defaultShouldRetry(new Error('ECONNRESET'))).toBe(true);
58+
});
59+
60+
it('retries when retryable network signal is only on cause chain', () => {
61+
const wrapped = new Error('Request failed', { cause: new Error('ECONNRESET') });
62+
expect(defaultShouldRetry(wrapped)).toBe(true);
63+
});
64+
65+
it('does not retry wrapper with generic cause and no retry signals', () => {
66+
const wrapped = new Error('Request failed', { cause: new Error('upstream failed') });
67+
expect(defaultShouldRetry(wrapped)).toBe(false);
68+
});
69+
70+
it('does not retry AppTimeoutError even via defaultShouldRetry', () => {
71+
expect(defaultShouldRetry(new AppTimeoutError(50, 'search'))).toBe(false);
72+
});
73+
74+
it('does not retry when structured status is non-retryable despite retryable message', () => {
75+
const err = Object.assign(new Error('HTTP 503 upstream glitch'), { status: 401 });
76+
expect(getHttpStatus(err)).toBe(401);
77+
expect(defaultShouldRetry(err)).toBe(false);
78+
});
2279
});
2380

2481
describe('transientShouldRetry', () => {
@@ -35,12 +92,37 @@ describe('transientShouldRetry', () => {
3592
false
3693
);
3794
});
95+
96+
it('does not retry AppTimeoutError', () => {
97+
expect(transientShouldRetry(new AppTimeoutError(50, 'search'))).toBe(false);
98+
});
3899
});
39100

40101
describe('isAppTimeoutError', () => {
41102
it('matches withTimeout rejection messages', () => {
42103
expect(isAppTimeoutError(new Error('Timeout after 50ms while waiting for search'))).toBe(true);
43104
});
105+
106+
it('matches AppTimeoutError instances', () => {
107+
expect(isAppTimeoutError(new AppTimeoutError(50, 'search'))).toBe(true);
108+
});
109+
110+
it('matches AppTimeoutError in cause chain', () => {
111+
const wrapped = new Error('wrapped', { cause: new AppTimeoutError(50, 'search') });
112+
expect(isAppTimeoutError(wrapped)).toBe(true);
113+
});
114+
115+
it('matches legacy withTimeout message prefix in cause chain', () => {
116+
const wrapped = new Error('wrapper', {
117+
cause: new Error('Timeout after 50ms while waiting for search'),
118+
});
119+
expect(isAppTimeoutError(wrapped)).toBe(true);
120+
});
121+
122+
it('does not match generic wrapper without timeout in cause chain', () => {
123+
const wrapped = new Error('wrapper', { cause: new Error('upstream failed') });
124+
expect(isAppTimeoutError(wrapped)).toBe(false);
125+
});
44126
});
45127

46128
describe('runWithPolicy', () => {
@@ -82,7 +164,7 @@ describe('runWithPolicy', () => {
82164
},
83165
{ timeoutMs: 50, label: 'test', retries: 2, backoffMs: 1 }
84166
);
85-
const assertion = expect(p).rejects.toThrow(/Timeout after 50ms/);
167+
const assertion = expect(p).rejects.toBeInstanceOf(AppTimeoutError);
86168
await vi.advanceTimersByTimeAsync(50);
87169
await assertion;
88170
expect(n).toBe(1);
@@ -99,7 +181,7 @@ describe('withTimeout', () => {
99181
}),
100182
{ timeoutMs: 100, label: 'test' }
101183
);
102-
const assertion = expect(p).rejects.toThrow(/Timeout after 100ms/);
184+
const assertion = expect(p).rejects.toBeInstanceOf(AppTimeoutError);
103185
await vi.advanceTimersByTimeAsync(100);
104186
await assertion;
105187
});

0 commit comments

Comments
 (0)