Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -791,7 +791,9 @@ export class OpenAI {

const security = options.__security ?? { bearerAuth: true };
const controller = new AbortController();
const response = await this.fetchWithAuth(url, req, timeout, controller, security).catch(castToError);
const response = await this.fetchWithAuth(url, req, timeout, controller, security, options.stream).catch(
castToError,
);
const headersTime = Date.now();

if (response instanceof globalThis.Error) {
Expand Down Expand Up @@ -974,6 +976,7 @@ export class OpenAI {
bearerAuth: true,
adminAPIKeyAuth: true,
},
keepAbortListener = false,
): Promise<Response> {
if (this._workloadIdentityAuth && schemes.bearerAuth) {
const headers = init.headers as Headers;
Expand All @@ -984,7 +987,7 @@ export class OpenAI {
}
}

const response = await this.fetchWithTimeout(url, init, timeout, controller);
const response = await this.fetchWithTimeout(url, init, timeout, controller, keepAbortListener);

return response;
}
Expand All @@ -994,6 +997,7 @@ export class OpenAI {
init: RequestInit | undefined,
ms: number,
controller: AbortController,
keepAbortListener = false,
): Promise<Response> {
const { signal, method, ...options } = init || {};
const abort = this._makeAbort(controller);
Expand Down Expand Up @@ -1022,6 +1026,7 @@ export class OpenAI {
return await this.fetch.call(undefined, url, fetchOptions);
} finally {
clearTimeout(timeout);
if (signal && !keepAbortListener) signal.removeEventListener('abort', abort);
}
}

Expand Down
53 changes: 53 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,59 @@ describe('default encoder', () => {
});

describe('retries', () => {
test('removes abort listener after successful fetch', async () => {
const client = new OpenAI({
apiKey: 'My API Key',
adminAPIKey: 'My Admin API Key',
fetch: async () =>
new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }),
});

const externalController = new AbortController();
const addEventListener = jest.spyOn(externalController.signal, 'addEventListener');
const removeEventListener = jest.spyOn(externalController.signal, 'removeEventListener');

await client.fetchWithTimeout(
'http://localhost/foo',
{ signal: externalController.signal },
1000,
new AbortController(),
);

expect(addEventListener).toHaveBeenCalledWith('abort', expect.any(Function), { once: true });
const abortListener = addEventListener.mock.calls[0]?.[1];
expect(abortListener).toEqual(expect.any(Function));
expect(removeEventListener).toHaveBeenCalledWith('abort', abortListener);
});

test('keeps abort listener after fetch resolves for streaming responses', async () => {
const client = new OpenAI({
apiKey: 'My API Key',
adminAPIKey: 'My Admin API Key',
fetch: async () => new Response('data: {"a":1}\n\n'),
});

const externalController = new AbortController();
const requestController = new AbortController();
const addEventListener = jest.spyOn(externalController.signal, 'addEventListener');
const removeEventListener = jest.spyOn(externalController.signal, 'removeEventListener');

await client.fetchWithTimeout(
'http://localhost/foo',
{ signal: externalController.signal },
1000,
requestController,
true,
);

expect(addEventListener).toHaveBeenCalledWith('abort', expect.any(Function), { once: true });
expect(removeEventListener).not.toHaveBeenCalled();

externalController.abort();

expect(requestController.signal.aborted).toBe(true);
});

test('retry on timeout', async () => {
let count = 0;
const testFetch = async (
Expand Down