Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ for await (const payment of client.payments.all()) {
### Available client options

- `raiseOnIdempotencyConflict`: set to `true` to raise exceptions on [idempotency](https://developer.gocardless.com/api-reference/#making-requests-idempotency-keys) conflicts. Defaults to `false`.
- `timeout`: how long to wait for a response, in milliseconds, before aborting the request with a `TimeoutError` (from [`got`](https://github.com/sindresorhus/got/blob/main/documentation/6-timeout.md), which this library uses to make requests). A number applies to the whole request; an object such as `{ connect: 1000, response: 5000 }` sets limits for individual phases of the request. Defaults to 60000 (60 seconds); set it to `0` to disable the timeout.


### CommonJS backwards compatibility
Expand Down
50 changes: 25 additions & 25 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 46 additions & 1 deletion src/api/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as fs from 'node:fs';
import nock from 'nock';
import { Api } from './api';
import { Api, DEFAULT_TIMEOUT_MS } from './api';
import { GoCardlessClient } from '../client';
import { Environments } from '../constants';
import * as GoCardlessErrors from '../errors';
Expand Down Expand Up @@ -79,6 +79,51 @@ describe('.request', () => {
});
});

describe('timeout option', () => {
const requestOptions = (options = {}) =>
new Api(token, Environments.Live, options)['createRequestOptions']('post', {});

test('applies a default timeout when none is configured', () => {
expect(DEFAULT_TIMEOUT_MS).toEqual(60000);
expect(requestOptions().timeout).toEqual({ request: DEFAULT_TIMEOUT_MS });
});

test('a configured timeout replaces the default', () => {
expect(requestOptions({ timeout: 5000 }).timeout).toEqual({ request: 5000 });
expect(requestOptions({ timeout: { connect: 1000 } }).timeout).toEqual({ connect: 1000 });
});

test('a timeout of 0 disables the timeout', () => {
expect(requestOptions({ timeout: 0 }).timeout).toBeUndefined();
});

// POST so that `got` does not retry the timed out request
const params = { path: '/', method: 'post', requestParameters: {}, fetch: null };

test('aborts the request when a numeric timeout is exceeded', async () => {
const api = new Api(token, Environments.Live, { timeout: 50 });
nock('https://api.gocardless.com').post('/').delay(500).reply(200, {});

await expect(api.request(params)).rejects.toMatchObject({ name: 'TimeoutError' });
});

test('aborts the request when a per-phase timeout is exceeded', async () => {
const api = new Api(token, Environments.Live, { timeout: { response: 50 } });
nock('https://api.gocardless.com').post('/').delay(500).reply(200, {});

await expect(api.request(params)).rejects.toMatchObject({ name: 'TimeoutError' });
});

test('does not affect requests that complete in time', async () => {
const api = new Api(token, Environments.Live, { timeout: 5000 });
const check = nock('https://api.gocardless.com').post('/').reply(200, {});

await api.request(params);

expect(check.isDone()).toEqual(true);
});
});

describe('API environments', () => {
test('when in Sandbox environment', async () => {
const check = nock('https://api-sandbox.gocardless.com').get('/').reply(200, {});
Expand Down
14 changes: 13 additions & 1 deletion src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as os from 'os';
import * as process from 'process';
import { v4 as uuidv4 } from 'uuid';
import * as url from 'url';
import got, { Agents, HTTPError, OptionsOfJSONResponseBody, ParseError } from 'got';
import got, { Agents, Delays, HTTPError, OptionsOfJSONResponseBody, ParseError } from 'got';
import * as qs from 'qs';

import { Environments, CLIENT_VERSION, API_VERSION } from '../constants.js';
Expand All @@ -13,8 +13,11 @@ import {
ApiRequestSigningOptionsInternal,
} from '../apiRequestSigning.js';

export const DEFAULT_TIMEOUT_MS = 60000;

export interface APIOptions {
proxy?: Agents;
timeout?: number | Delays;
raiseOnIdempotencyConflict?: boolean;
apiRequestSigningOptions?: ApiRequestSigningOptions;
}
Expand Down Expand Up @@ -67,6 +70,7 @@ export class Api {
private _environment: Environments;
private _baseUrl: string;
private _agent: Agents | undefined;
private _timeout: Delays | undefined;
private raiseOnIdempotencyConflict: boolean;
private apiRequestSigningOptions: ApiRequestSigningOptionsInternal | null = null;

Expand All @@ -88,6 +92,13 @@ export class Api {
this._agent = options.proxy;
}

const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
if (typeof timeout === 'number') {
this._timeout = timeout > 0 ? { request: timeout } : undefined;
} else {
this._timeout = timeout;
}

this.raiseOnIdempotencyConflict = options.raiseOnIdempotencyConflict || false;
this.apiRequestSigningOptions = options.apiRequestSigningOptions as ApiRequestSigningOptionsInternal;

Expand Down Expand Up @@ -222,6 +233,7 @@ export class Api {
const json = this.getRequestBody(method, requestParameters, payloadKey);
return {
agent: this._agent,
timeout: this._timeout,
prefixUrl: this._baseUrl,
// `got` v16 lets an absolute URL replace `prefixUrl`, which would send the token to another host
allowAbsoluteUrls: false,
Expand Down
Loading