Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const pc = new Pinecone();

If you prefer to pass configuration in code, the constructor accepts a config object containing the `apiKey` value.
This is the object in which you would pass properties like `maxRetries` (defaults to `3`) for retryable operations
(e.g. `upsert`).
(`upsert`, `update`, and `configureIndex`).

```typescript
import { Pinecone } from '@pinecone-database/pinecone';
Expand Down
11 changes: 9 additions & 2 deletions src/control/configureIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
import { PineconeArgumentError } from '../errors';
import type { IndexName } from './types';
import { ValidateProperties } from '../utils/validateProperties';
import { RetryOnServerFailure } from '../utils';

// Properties for validation to ensure no unknown/invalid properties are passed, no req'd properties are missing
type ConfigureIndexRequestType = keyof ConfigureIndexRequest;
Expand Down Expand Up @@ -49,11 +50,17 @@ export const configureIndex = (api: ManageIndexesApi) => {

return async (
indexName: IndexName,
options: ConfigureIndexRequest
options: ConfigureIndexRequest,
maxRetries?: number
): Promise<IndexModel> => {
validator(indexName, options);

return await api.configureIndex({
const retryWrapper = new RetryOnServerFailure(
api.configureIndex.bind(api),
maxRetries
);

return await retryWrapper.execute({
indexName,
configureIndexRequest: options,
});
Expand Down
2 changes: 1 addition & 1 deletion src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ export class Index<T extends RecordMetadata = RecordMetadata> {
* @returns A promise that resolves when the update is completed.
*/
async update(options: UpdateOptions<T>) {
return await this._updateCommand.run(options);
return await this._updateCommand.run(options, this.config.maxRetries);
}

/**
Expand Down
10 changes: 8 additions & 2 deletions src/data/vectors/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
} from './types';
import { PineconeArgumentError } from '../../errors';
import { ValidateProperties } from '../../utils/validateProperties';
import { RetryOnServerFailure } from '../../utils';

/**
* This type is very similar to { @link PineconeRecord }, but differs because the
Expand Down Expand Up @@ -62,7 +63,7 @@ export class UpdateCommand<T extends RecordMetadata = RecordMetadata> {
}
};

async run(options: UpdateOptions<T>): Promise<void> {
async run(options: UpdateOptions<T>, maxRetries?: number): Promise<void> {
this.validator(options);

const requestOptions = {
Expand All @@ -73,7 +74,12 @@ export class UpdateCommand<T extends RecordMetadata = RecordMetadata> {
};

const api = await this.apiProvider.provide();
await api.updateVector({
const retryWrapper = new RetryOnServerFailure(
api.updateVector.bind(api),
maxRetries
);

await retryWrapper.execute({
updateRequest: { ...requestOptions, namespace: this.namespace },
});
return;
Expand Down
2 changes: 2 additions & 0 deletions src/errors/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ export const mapHttpStatusError = (failedRequestInfo: FailedRequestInfo) => {
return new PineconeInternalServerError(failedRequestInfo);
case 501:
return new PineconeNotImplementedError(failedRequestInfo);
case 503:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops forgot to map this the 1st time around

return new PineconeUnavailableError(failedRequestInfo);
default:
throw new PineconeUnmappedHttpError(failedRequestInfo);
}
Expand Down
37 changes: 6 additions & 31 deletions src/integration/control/configureIndex.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
import {
BasePineconeError,
PineconeBadRequestError,
PineconeInternalServerError,
} from '../../errors';
import { BasePineconeError, PineconeBadRequestError } from '../../errors';
import { Pinecone } from '../../index';
import {
randomIndexName,
retryDeletes,
sleep,
waitUntilReady,
} from '../test-helpers';
import { randomIndexName, retryDeletes, waitUntilReady } from '../test-helpers';

let podIndexName: string, serverlessIndexName: string, pinecone: Pinecone;

Expand Down Expand Up @@ -75,26 +66,10 @@ describe('configure index', () => {
const description = await pinecone.describeIndex(podIndexName);
expect(description.spec.pod?.podType).toEqual('p1.x1');

// Scale up podType to x2
let state = true;
let retryCount = 0;
const maxRetries = 10;
while (state && retryCount < maxRetries) {
try {
await pinecone.configureIndex(podIndexName, {
spec: { pod: { podType: 'p1.x2' } },
});
state = false;
} catch (e) {
if (e instanceof PineconeInternalServerError) {
retryCount++;
await sleep(2000);
} else {
console.log('Unexpected error:', e);
throw e;
}
}
}
Comment on lines -78 to -97
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't need this now that we've got retries!

await pinecone.configureIndex(podIndexName, {
spec: { pod: { podType: 'p1.x2' } },
});

await waitUntilReady(podIndexName);
const description2 = await pinecone.describeIndex(podIndexName);
expect(description2.spec.pod?.podType).toEqual('p1.x2');
Expand Down
114 changes: 75 additions & 39 deletions src/integration/data/vectors/upsertAndUpdate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,46 +46,46 @@ afterAll(async () => {
});

// todo: add sparse values update
describe('upsert and update to serverless index', () => {
test('verify upsert and update', async () => {
const recordToUpsert = generateRecords({
dimension: 2,
quantity: 1,
withSparseValues: false,
withMetadata: true,
});

// Upsert record
await serverlessIndex.upsert(recordToUpsert);

// Build new values
const newValues = [0.5, 0.4];
const newMetadata = { flavor: 'chocolate' };

const updateSpy = jest
.spyOn(serverlessIndex, 'update')
.mockResolvedValue(undefined);

// Update values w/new values
await serverlessIndex.update({
id: '0',
values: newValues,
metadata: newMetadata,
});

expect(updateSpy).toHaveBeenCalledWith({
id: '0',
values: newValues,
metadata: newMetadata,
});

// Clean up spy after the test
updateSpy.mockRestore();
});
});
// describe('upsert and update to serverless index', () => {
// test('verify upsert and update', async () => {
// const recordToUpsert = generateRecords({
// dimension: 2,
// quantity: 1,
// withSparseValues: false,
// withMetadata: true,
// });
//
// // Upsert record
// await serverlessIndex.upsert(recordToUpsert);
//
// // Build new values
// const newValues = [0.5, 0.4];
// const newMetadata = { flavor: 'chocolate' };
//
// const updateSpy = jest
// .spyOn(serverlessIndex, 'update')
// .mockResolvedValue(undefined);
//
// // Update values w/new values
// await serverlessIndex.update({
// id: '0',
// values: newValues,
// metadata: newMetadata,
// });
//
// expect(updateSpy).toHaveBeenCalledWith({
// id: '0',
// values: newValues,
// metadata: newMetadata,
// });
//
// // Clean up spy after the test
// updateSpy.mockRestore();
// });
// });

// Retry logic tests
describe('Testing retry logic on Upsert operation, as run on a mock, in-memory http server', () => {
describe('Testing retry logic via a mock, in-memory http server', () => {
const recordsToUpsert = generateRecords({
dimension: 2,
quantity: 1,
Expand All @@ -96,13 +96,14 @@ describe('Testing retry logic on Upsert operation, as run on a mock, in-memory h
let server: http.Server; // Note: server cannot be something like an express server due to conflicts w/edge runtime
let mockServerlessIndex: Index;
let callCount: number;
let op: string;

// Helper function to start the server with a specific response pattern
const startMockServer = (shouldSucceedOnSecondCall: boolean) => {
// Create http server
server = http.createServer((req, res) => {
const { pathname } = parse(req.url || '', true);
if (req.method === 'POST' && pathname === '/vectors/upsert') {
if (req.method === 'POST' && pathname === `/vectors/${op}`) {
callCount++;
if (shouldSucceedOnSecondCall && callCount === 1) {
res.writeHead(503, { 'Content-Type': 'application/json' });
Expand Down Expand Up @@ -137,6 +138,7 @@ describe('Testing retry logic on Upsert operation, as run on a mock, in-memory h
});

test('Upsert operation should retry 1x if server responds 1x with error and 1x with success', async () => {
op = 'upsert';
pinecone = new Pinecone({
apiKey: process.env['PINECONE_API_KEY'] || '',
maxRetries: 2,
Expand All @@ -155,13 +157,47 @@ describe('Testing retry logic on Upsert operation, as run on a mock, in-memory h
// Call Upsert operation
await mockServerlessIndex.upsert(recordsToUpsert);

// 2 total tries: 1 initial call, 1 retry
expect(retrySpy).toHaveBeenCalledTimes(1); // passes
expect(delaySpy).toHaveBeenCalledTimes(1); // fails
expect(callCount).toBe(2);
});

test('Update operation should retry 1x if server responds 1x with error and 1x with success', async () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Figured duplicating this type of test across upsert and update would be enough to justify me not duplicating it again for configureIndex, but lmk if you disagree!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(We should rly centralize this type of thing to avoid duplicating this logic, but I think this is okay for now)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems useful to validate that the calls themselves trigger retries as we'd expect, is that what you're talking about centralizing?

Copy link
Contributor Author

@aulorbe aulorbe Dec 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm I'm not 100% sure we're on the same page -- when you say "the calls themselves," are you talking about the different async funcs that we could pass into the RetryWrapper?

Assuming you answer yes to the above, yes that's what I'd like to centralize.... something like we have a single parameterized test that confirms that < whatever async func > is retried n times

op = 'update';

pinecone = new Pinecone({
apiKey: process.env['PINECONE_API_KEY'] || '',
maxRetries: 2,
});

mockServerlessIndex = pinecone
.Index(serverlessIndexName, 'http://localhost:4000')
.namespace(globalNamespaceOne);

const retrySpy = jest.spyOn(RetryOnServerFailure.prototype, 'execute');
const delaySpy = jest.spyOn(RetryOnServerFailure.prototype, 'delay');

// Start server with a successful response on the second call
startMockServer(true);

const recordIdToUpdate = recordsToUpsert[0].id;
const newMetadata = { flavor: 'chocolate' };

// Call Update operation
await mockServerlessIndex.update({
id: recordIdToUpdate,
metadata: newMetadata,
});

// 2 total tries: 1 initial call, 1 retry
expect(retrySpy).toHaveBeenCalledTimes(1);
expect(delaySpy).toHaveBeenCalledTimes(1);
expect(callCount).toBe(2);
});

test('Max retries exceeded w/o resolve', async () => {
op = 'upsert';
pinecone = new Pinecone({
apiKey: process.env['PINECONE_API_KEY'] || '',
maxRetries: 3,
Expand Down
2 changes: 1 addition & 1 deletion src/pinecone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,7 @@ export class Pinecone {
* @returns A promise that resolves to {@link IndexModel} when the request to configure the index is completed.
*/
configureIndex(indexName: IndexName, options: ConfigureIndexRequest) {
return this._configureIndex(indexName, options);
return this._configureIndex(indexName, options, this.config.maxRetries);
}

/**
Expand Down
38 changes: 38 additions & 0 deletions src/utils/__tests__/retries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,41 @@ describe('isRetryError', () => {
expect(result).toBe(true);
});
});

describe('shouldStopRetrying', () => {
test('Should return true for non-retryable status code', () => {
const retryWrapper = new RetryOnServerFailure(() => Promise.resolve({}));
const result = retryWrapper['shouldStopRetrying']({ status: 400 });
expect(result).toBe(true);
});

test('Should return true for non-retryable error name', () => {
const retryWrapper = new RetryOnServerFailure(() => Promise.resolve({}));
const result = retryWrapper['shouldStopRetrying']({
name: 'SomeOtherError',
});
expect(result).toBe(true);
});

test('Should return false for retryable error name PineconeUnavailableError', () => {
const retryWrapper = new RetryOnServerFailure(() => Promise.resolve({}));
const result = retryWrapper['shouldStopRetrying']({
name: 'PineconeUnavailableError',
});
expect(result).toBe(false);
});

test('Should return false for retryable error name PineconeInternalServerError', () => {
const retryWrapper = new RetryOnServerFailure(() => Promise.resolve({}));
const result = retryWrapper['shouldStopRetrying']({
name: 'PineconeInternalServerError',
});
expect(result).toBe(false);
});

test('Should return false for retryable status code', () => {
const retryWrapper = new RetryOnServerFailure(() => Promise.resolve({}));
const result = retryWrapper['shouldStopRetrying']({ status: 500 });
expect(result).toBe(false);
});
});
Loading
Loading