@clickup/rest-client
Version:
A syntax sugar tool around Node fetch() API, tailored to work with TypeScript and response validators
190 lines (182 loc) • 7.44 kB
text/typescript
import { Agent as HttpAgent } from "http";
import { Agent as HttpsAgent } from "https";
import delay from "delay";
import { Memoize } from "fast-typescript-memoize";
import type RestRequest from "./RestRequest";
import type RestResponse from "./RestResponse";
/**
* An event which is passed to an external logger (see RestOptions).
*/
export interface RestLogEvent {
attempt: number;
req: RestRequest;
res: RestResponse | "backoff_delay" | null;
exception: any | null;
timestamp: number;
elapsed: number;
isFinalAttempt: boolean;
privateDataInResponse: boolean;
comment: string;
}
/**
* Middlewares allow to modify RestRequest and RestResponse objects during the
* request processing.
*/
export interface Middleware {
(
req: RestRequest,
next: (req: RestRequest) => Promise<RestResponse>,
): Promise<RestResponse>;
}
/**
* @ignore
* Parameters for Agents.
*/
interface AgentOptions {
keepAlive: boolean;
timeout?: number;
maxSockets?: number;
rejectUnauthorized?: boolean;
family?: 4 | 6 | 0;
}
/**
* @ignore
* An internal class which keeps the list of HttpAgent instances used in a
* particular RestClient instance.
*/
export class Agents {
((...args: any[]) => JSON.stringify(args))
http(options: AgentOptions) {
return new HttpAgent(options);
}
((...args: any[]) => JSON.stringify(args))
https(options: AgentOptions) {
return new HttpsAgent(options);
}
}
/**
* Options passed to RestClient. More options can be added by cloning an
* instance of RestClient, withOption() method.
*/
export default interface RestOptions {
/** Max number of retries. Default is 0, because some requests are from the
* web app, and we don't want to retry them. */
retries: number;
/** How much time to wait by default on the 1st retry attempt. */
retryDelayFirstMs: number;
/** How much to increase the retry delay on each retry. */
retryDelayFactor: number;
/** Use this fraction (random) of the current retry delay to jitter both ways
* (e.g. 0.1 means 90%...110% of the delay to be actually applied). */
retryDelayJitter: number;
/** Maximum delay between each retry. */
retryDelayMaxMs: number;
/** A logic which runs on different IO stages (delay and heartbeats). */
heartbeater: {
/* This function is called after each request with 0 passed, so it can serve
* the purpose of a heartbeat. */
heartbeat(): Promise<void>;
/** A function which, when runs, resolves in the provided number of ms. Can
* be used for several purposes, like overriding in unit tests or to pass a
* custom delay implementation which can throw on some external event (like
* when the process wants to gracefully stop). */
delay(ms: number): Promise<void>;
};
/** Allows to limit huge requests and throw instead. */
throwIfResIsBigger: number | undefined;
/** Passed to the logger which may decide, should it log details of the
* response or not. */
privateDataInResponse: boolean;
/** If true, non-public IP addresses are allowed too; otherwise, only unicast
* addresses are allowed. */
allowInternalIPs: boolean;
/** Overrides the default encoding heuristics for responses. */
responseEncoding: NodeJS.BufferEncoding | undefined;
/** If true, logs request-response pairs to console. */
isDebug: boolean;
/** @ignore Holds HttpsAgent/HttpAgent instances; used internally only. */
agents: Agents;
/** Sets Keep-Alive parameters (persistent connections). */
keepAlive: {
/** How much time to keep an idle connection alive in the pool. If 0, closes
* the connection immediately after the response. */
timeoutMs: number;
/** How many sockets at maximum will be kept open. */
maxSockets?: number;
};
/** When resolving DNS, use IPv4, IPv6 or both (see dns.lookup() docs). */
family: 4 | 6 | 0;
/** Max timeout to wait for a response. */
timeoutMs: number;
/** Logger to be used for each responses (including retried) plus for backoff
* delay events logging. */
logger: (event: RestLogEvent) => void;
/** Middlewares to wrap requests. May alter both request and response. */
middlewares: Middleware[];
/** If set, makes decision whether the response is successful or not. The
* response will either be returned to the client, or an error will be thrown.
* This allows to treat some non-successful HTTP statuses as success if the
* remote API is that weird. Return values:
* * "SUCCESS" - the request will be considered successful, no further checks
* will be performed;
* * "BEST_EFFORT" - inconclusive, the request may be either successful or
* unsuccessful, additional tests (e.g. will check HTTP status code) will be
* performed;
* * "THROW" - the request resulted in error. Additional tests will be
* performed to determine is the error is retriable, is OAuth token good,
* and etc. */
isSuccessResponse: (res: RestResponse) => "SUCCESS" | "THROW" | "BEST_EFFORT";
/** Decides whether the response is a rate-limit error or not. Returning
* non-zero value is treated as retry delay (if retries are set up). In case
* the returned value is "SOMETHING_ELSE", the response ought to be either
* success or some other error. Returning "BEST_EFFORT" turns on built-in
* heuristic (e.g. relying on HTTP status code and Retry-After header). In
* case we've made a decision that it's a rate limited error, the request is
* always retried; this covers a very common case when we have both
* isRateLimitError and isRetriableError handlers set up, and they return
* contradictory information; then isRateLimitError wins. */
isRateLimitError: (
res: RestResponse,
) => "SOMETHING_ELSE" | "RATE_LIMIT" | "BEST_EFFORT" | number;
/** Decides whether the response is a token-invalid error or not. In case it's
* not, the response ought to be either success or some other error. */
isTokenInvalidError: (res: RestResponse) => boolean;
/** Called only if we haven't decided earlier that it's a rate limit error.
* Decides whether the response is a retriable error or not. In case the
* returned value is "NEVER_RETRY", the response ought to be either success or
* some other error, but it's guaranteed that the request won't be retried.
* Returning "BEST_EFFORT" turns on built-in heuristics (e.g. never retry "not
* found" errors). Returning a number is treated as "RETRY", and the next
* retry will happen in not less than this number of milliseconds. */
isRetriableError: (
res: RestResponse,
_error: any,
) => "NEVER_RETRY" | "RETRY" | "BEST_EFFORT" | number;
}
/** @ignore */
export const DEFAULT_OPTIONS: RestOptions = {
retries: 0,
retryDelayFirstMs: 1000,
retryDelayFactor: 2,
retryDelayJitter: 0.1,
retryDelayMaxMs: Number.MAX_SAFE_INTEGER,
heartbeater: {
heartbeat: async () => {},
delay,
},
throwIfResIsBigger: undefined,
privateDataInResponse: false,
allowInternalIPs: false,
responseEncoding: undefined,
isDebug: false,
agents: new Agents(),
keepAlive: { timeoutMs: 10000 },
family: 4, // we don't want to hit ~5s DNS timeout on a missing IPv6 by default
timeoutMs: 4 * 60 * 1000,
logger: () => {},
middlewares: [],
isSuccessResponse: () => "BEST_EFFORT",
isRateLimitError: () => "BEST_EFFORT",
isTokenInvalidError: () => false,
isRetriableError: () => "BEST_EFFORT",
};