diff --git a/.eslintrc.base.js b/.eslintrc.base.js index 9684533..9eb958b 100644 --- a/.eslintrc.base.js +++ b/.eslintrc.base.js @@ -38,6 +38,7 @@ module.exports = (projectRoot, extraRules = {}) => ({ "typescript-enum", "typescript-sort-keys", "unused-imports", + "no-only-tests", ], settings: { react: { @@ -109,6 +110,10 @@ module.exports = (projectRoot, extraRules = {}) => ({ "@typescript-eslint/no-useless-constructor": ["error"], "@typescript-eslint/prefer-optional-chain": ["error"], "@typescript-eslint/consistent-type-imports": ["error"], + "@typescript-eslint/require-array-sort-compare": [ + "error", + { ignoreStringArrays: true }, + ], eqeqeq: ["error"], "object-shorthand": ["error", "always"], "@typescript-eslint/unbound-method": ["error"], @@ -430,6 +435,9 @@ module.exports = (projectRoot, extraRules = {}) => ({ ], quotes: ["error", "double", { avoidEscape: true }], + + "no-only-tests/no-only-tests": "error", + ...extraRules, }, }); diff --git a/.npmrc b/.npmrc deleted file mode 100644 index c52ad5f..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -# Published to https://www.npmjs.com diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..f5688b5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "options": { "parser": "typescript" } + } + ] +} diff --git a/docs/classes/PacerComposite.md b/docs/classes/PacerComposite.md deleted file mode 100644 index 4cd23e0..0000000 --- a/docs/classes/PacerComposite.md +++ /dev/null @@ -1,66 +0,0 @@ -[@clickup/rest-client](../README.md) / [Exports](../modules.md) / PacerComposite - -# Class: PacerComposite - -A Pacer which runs all sub-pacers and chooses the largest delay. - -## Implements - -- [`Pacer`](../interfaces/Pacer.md) - -## Constructors - -### constructor - -• **new PacerComposite**(`_pacers`): [`PacerComposite`](PacerComposite.md) - -#### Parameters - -| Name | Type | -| :------ | :------ | -| `_pacers` | [`Pacer`](../interfaces/Pacer.md)[] | - -#### Returns - -[`PacerComposite`](PacerComposite.md) - -#### Defined in - -[src/pacers/PacerComposite.ts:10](https://github.com/clickup/rest-client/blob/master/src/pacers/PacerComposite.ts#L10) - -## Properties - -### name - -• `Readonly` **name**: ``""`` - -Human readable name of the pacer, used when composing multiple pacers. - -#### Implementation of - -[Pacer](../interfaces/Pacer.md).[name](../interfaces/Pacer.md#name) - -#### Defined in - -[src/pacers/PacerComposite.ts:8](https://github.com/clickup/rest-client/blob/master/src/pacers/PacerComposite.ts#L8) - -## Methods - -### touch - -▸ **touch**(): `Promise`\<\{ `delayMs`: `number` ; `reason`: `string` }\> - -Signals that we're about to send a request. Returns the delay we need to -wait for before actually sending. - -#### Returns - -`Promise`\<\{ `delayMs`: `number` ; `reason`: `string` }\> - -#### Implementation of - -[Pacer](../interfaces/Pacer.md).[touch](../interfaces/Pacer.md#touch) - -#### Defined in - -[src/pacers/PacerComposite.ts:12](https://github.com/clickup/rest-client/blob/master/src/pacers/PacerComposite.ts#L12) diff --git a/docs/classes/PacerQPS.md b/docs/classes/PacerQPS.md deleted file mode 100644 index 0436f3f..0000000 --- a/docs/classes/PacerQPS.md +++ /dev/null @@ -1,79 +0,0 @@ -[@clickup/rest-client](../README.md) / [Exports](../modules.md) / PacerQPS - -# Class: PacerQPS - -Implements a very simple heuristic: -- increase the delay if we're above the QPS within the rolling window; -- decrease the delay if we're below the desired QPS. - -Each worker keeps (and grows/shrinks) its delay individually; this way, we -don't need to elect, who's the "source of truth" for the delay. - -Backend is a concrete (and minimal) implementation of the storage logic for -the pacing algorithm. - -## Implements - -- [`Pacer`](../interfaces/Pacer.md) - -## Constructors - -### constructor - -• **new PacerQPS**(`_options`, `_backend`): [`PacerQPS`](PacerQPS.md) - -#### Parameters - -| Name | Type | -| :------ | :------ | -| `_options` | [`PacerQPSOptions`](../interfaces/PacerQPSOptions.md) | -| `_backend` | [`PacerQPSBackend`](../interfaces/PacerQPSBackend.md) | - -#### Returns - -[`PacerQPS`](PacerQPS.md) - -#### Defined in - -[src/pacers/PacerQPS.ts:74](https://github.com/clickup/rest-client/blob/master/src/pacers/PacerQPS.ts#L74) - -## Accessors - -### name - -• `get` **name**(): `string` - -Human readable name of the pacer, used when composing multiple pacers. - -#### Returns - -`string` - -#### Implementation of - -[Pacer](../interfaces/Pacer.md).[name](../interfaces/Pacer.md#name) - -#### Defined in - -[src/pacers/PacerQPS.ts:79](https://github.com/clickup/rest-client/blob/master/src/pacers/PacerQPS.ts#L79) - -## Methods - -### touch - -▸ **touch**(): `Promise`\<[`PacerDelay`](../interfaces/PacerDelay.md)\> - -Signals that we're about to send a request. Returns the delay we need to -wait for before actually sending. - -#### Returns - -`Promise`\<[`PacerDelay`](../interfaces/PacerDelay.md)\> - -#### Implementation of - -[Pacer](../interfaces/Pacer.md).[touch](../interfaces/Pacer.md#touch) - -#### Defined in - -[src/pacers/PacerQPS.ts:83](https://github.com/clickup/rest-client/blob/master/src/pacers/PacerQPS.ts#L83) diff --git a/docs/interfaces/Pacer.md b/docs/interfaces/Pacer.md index 3948803..a8d83f2 100644 --- a/docs/interfaces/Pacer.md +++ b/docs/interfaces/Pacer.md @@ -5,36 +5,31 @@ Pacer is a class which allows to pace requests on some resource identified by the instance of this class. -## Implemented by - -- [`PacerComposite`](../classes/PacerComposite.md) -- [`PacerQPS`](../classes/PacerQPS.md) - ## Properties -### name +### key -• `Readonly` **name**: `string` +• `Readonly` **key**: `string` Human readable name of the pacer, used when composing multiple pacers. #### Defined in -[src/pacers/Pacer.ts:15](https://github.com/clickup/rest-client/blob/master/src/pacers/Pacer.ts#L15) +[src/middlewares/paceRequests.ts:12](https://github.com/clickup/rest-client/blob/master/src/middlewares/paceRequests.ts#L12) ## Methods -### touch +### pace -▸ **touch**(): `Promise`\<[`PacerDelay`](PacerDelay.md)\> +▸ **pace**(): `Promise`\<[`PacerOutcome`](PacerOutcome.md)\> Signals that we're about to send a request. Returns the delay we need to wait for before actually sending. #### Returns -`Promise`\<[`PacerDelay`](PacerDelay.md)\> +`Promise`\<[`PacerOutcome`](PacerOutcome.md)\> #### Defined in -[src/pacers/Pacer.ts:21](https://github.com/clickup/rest-client/blob/master/src/pacers/Pacer.ts#L21) +[src/middlewares/paceRequests.ts:18](https://github.com/clickup/rest-client/blob/master/src/middlewares/paceRequests.ts#L18) diff --git a/docs/interfaces/PacerDelay.md b/docs/interfaces/PacerDelay.md deleted file mode 100644 index 7d3d388..0000000 --- a/docs/interfaces/PacerDelay.md +++ /dev/null @@ -1,25 +0,0 @@ -[@clickup/rest-client](../README.md) / [Exports](../modules.md) / PacerDelay - -# Interface: PacerDelay - -A result of some Pacer work. - -## Properties - -### delayMs - -• **delayMs**: `number` - -#### Defined in - -[src/pacers/Pacer.ts:5](https://github.com/clickup/rest-client/blob/master/src/pacers/Pacer.ts#L5) - -___ - -### reason - -• **reason**: `string` - -#### Defined in - -[src/pacers/Pacer.ts:6](https://github.com/clickup/rest-client/blob/master/src/pacers/Pacer.ts#L6) diff --git a/docs/interfaces/PacerOutcome.md b/docs/interfaces/PacerOutcome.md new file mode 100644 index 0000000..76f0b96 --- /dev/null +++ b/docs/interfaces/PacerOutcome.md @@ -0,0 +1,25 @@ +[@clickup/rest-client](../README.md) / [Exports](../modules.md) / PacerOutcome + +# Interface: PacerOutcome + +A result of some Pacer work. + +## Properties + +### delayMs + +• **delayMs**: `number` + +#### Defined in + +[src/middlewares/paceRequests.ts:25](https://github.com/clickup/rest-client/blob/master/src/middlewares/paceRequests.ts#L25) + +___ + +### reason + +• **reason**: `string` + +#### Defined in + +[src/middlewares/paceRequests.ts:26](https://github.com/clickup/rest-client/blob/master/src/middlewares/paceRequests.ts#L26) diff --git a/docs/interfaces/PacerQPSBackend.md b/docs/interfaces/PacerQPSBackend.md deleted file mode 100644 index 868ec46..0000000 --- a/docs/interfaces/PacerQPSBackend.md +++ /dev/null @@ -1,44 +0,0 @@ -[@clickup/rest-client](../README.md) / [Exports](../modules.md) / PacerQPSBackend - -# Interface: PacerQPSBackend - -## Properties - -### key - -• `Readonly` **key**: `string` - -Resource key which this backend is operating on. - -#### Defined in - -[src/pacers/PacerQPS.ts:33](https://github.com/clickup/rest-client/blob/master/src/pacers/PacerQPS.ts#L33) - -## Methods - -### push - -▸ **push**(`props`): `Promise`\<\{ `count`: `number` ; `sum`: `number` ; `avg`: `number` ; `median`: `number` }\> - -Maintains the array of numbers somewhere in memory (time-value pairs), -inserts a new time-value pair to the end of this list, and removes all the -entries which are earlier than `minTime`. Returns the size of the resulting -array and some central tendency statistics about its values. - -#### Parameters - -| Name | Type | -| :------ | :------ | -| `props` | `Object` | -| `props.time` | `number` | -| `props.minTime` | `number` | -| `props.value` | `number` | -| `props.minCountForCentralTendency` | `number` | - -#### Returns - -`Promise`\<\{ `count`: `number` ; `sum`: `number` ; `avg`: `number` ; `median`: `number` }\> - -#### Defined in - -[src/pacers/PacerQPS.ts:41](https://github.com/clickup/rest-client/blob/master/src/pacers/PacerQPS.ts#L41) diff --git a/docs/interfaces/PacerQPSOptions.md b/docs/interfaces/PacerQPSOptions.md deleted file mode 100644 index fff1e01..0000000 --- a/docs/interfaces/PacerQPSOptions.md +++ /dev/null @@ -1,40 +0,0 @@ -[@clickup/rest-client](../README.md) / [Exports](../modules.md) / PacerQPSOptions - -# Interface: PacerQPSOptions - -## Properties - -### qps - -• **qps**: `number` - -The maximum QPS allowed within the rolling window. - -#### Defined in - -[src/pacers/PacerQPS.ts:51](https://github.com/clickup/rest-client/blob/master/src/pacers/PacerQPS.ts#L51) - -___ - -### windowSec - -• `Optional` **windowSec**: `number` - -The length of the rolling windows in milliseconds. - -#### Defined in - -[src/pacers/PacerQPS.ts:53](https://github.com/clickup/rest-client/blob/master/src/pacers/PacerQPS.ts#L53) - -___ - -### decreaseThreshold - -• `Optional` **decreaseThreshold**: `number` - -Decrease the delay if the number of requests in the window has dropped -below `decreaseThreshold` portion of the limit. - -#### Defined in - -[src/pacers/PacerQPS.ts:56](https://github.com/clickup/rest-client/blob/master/src/pacers/PacerQPS.ts#L56) diff --git a/docs/interfaces/RestOptions.md b/docs/interfaces/RestOptions.md index 04b2ace..1b8e507 100644 --- a/docs/interfaces/RestOptions.md +++ b/docs/interfaces/RestOptions.md @@ -126,6 +126,18 @@ addresses are allowed. ___ +### responseEncoding + +• **responseEncoding**: `undefined` \| `BufferEncoding` + +Overrides the default encoding heuristics for responses. + +#### Defined in + +[src/RestOptions.ts:100](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L100) + +___ + ### isDebug • **isDebug**: `boolean` @@ -134,7 +146,7 @@ If true, logs request-response pairs to console. #### Defined in -[src/RestOptions.ts:100](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L100) +[src/RestOptions.ts:102](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L102) ___ @@ -153,7 +165,7 @@ Sets Keep-Alive parameters (persistent connections). #### Defined in -[src/RestOptions.ts:104](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L104) +[src/RestOptions.ts:106](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L106) ___ @@ -165,7 +177,7 @@ When resolving DNS, use IPv4, IPv6 or both (see dns.lookup() docs). #### Defined in -[src/RestOptions.ts:112](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L112) +[src/RestOptions.ts:114](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L114) ___ @@ -177,7 +189,7 @@ Max timeout to wait for a response. #### Defined in -[src/RestOptions.ts:114](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L114) +[src/RestOptions.ts:116](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L116) ___ @@ -204,7 +216,7 @@ delay events logging. #### Defined in -[src/RestOptions.ts:117](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L117) +[src/RestOptions.ts:119](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L119) ___ @@ -216,7 +228,7 @@ Middlewares to wrap requests. May alter both request and response. #### Defined in -[src/RestOptions.ts:119](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L119) +[src/RestOptions.ts:121](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L121) ___ @@ -253,7 +265,7 @@ remote API is that weird. Return values: #### Defined in -[src/RestOptions.ts:132](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L132) +[src/RestOptions.ts:134](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L134) ___ @@ -287,7 +299,7 @@ contradictory information; then isRateLimitError wins. #### Defined in -[src/RestOptions.ts:142](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L142) +[src/RestOptions.ts:144](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L144) ___ @@ -314,7 +326,7 @@ not, the response ought to be either success or some other error. #### Defined in -[src/RestOptions.ts:147](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L147) +[src/RestOptions.ts:149](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L149) ___ @@ -347,4 +359,4 @@ retry will happen in not less than this number of milliseconds. #### Defined in -[src/RestOptions.ts:155](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L155) +[src/RestOptions.ts:157](https://github.com/clickup/rest-client/blob/master/src/RestOptions.ts#L157) diff --git a/docs/modules.md b/docs/modules.md index 1a68df0..d03d3c4 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -15,8 +15,6 @@ - [RestRetriableError](classes/RestRetriableError.md) - [RestTimeoutError](classes/RestTimeoutError.md) - [RestTokenInvalidError](classes/RestTokenInvalidError.md) -- [PacerComposite](classes/PacerComposite.md) -- [PacerQPS](classes/PacerQPS.md) ## Interfaces @@ -24,10 +22,8 @@ - [RestLogEvent](interfaces/RestLogEvent.md) - [Middleware](interfaces/Middleware.md) - [RestOptions](interfaces/RestOptions.md) -- [PacerDelay](interfaces/PacerDelay.md) - [Pacer](interfaces/Pacer.md) -- [PacerQPSBackend](interfaces/PacerQPSBackend.md) -- [PacerQPSOptions](interfaces/PacerQPSOptions.md) +- [PacerOutcome](interfaces/PacerOutcome.md) ## Functions @@ -77,7 +73,7 @@ Pacer implementations. | Name | Type | | :------ | :------ | | `pacer` | ``null`` \| [`Pacer`](interfaces/Pacer.md) \| (`req`: [`RestRequest`](classes/RestRequest.md)\<`any`\>) => `Promise`\<``null`` \| [`Pacer`](interfaces/Pacer.md)\> | -| `delayMetric?` | (`delay`: `number`) => `void` | +| `delayMetric?` | (`delay`: `number`, `reason`: `string`) => `void` | #### Returns @@ -85,4 +81,4 @@ Pacer implementations. #### Defined in -[src/middlewares/paceRequests.ts:11](https://github.com/clickup/rest-client/blob/master/src/middlewares/paceRequests.ts#L11) +[src/middlewares/paceRequests.ts:33](https://github.com/clickup/rest-client/blob/master/src/middlewares/paceRequests.ts#L33) diff --git a/jest.config.base.js b/jest.config.base.js index c0ea465..cee966c 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -6,7 +6,7 @@ module.exports = { restoreMocks: true, ...(process.env.IN_JEST_PROJECT ? {} - : { forceExit: true, testTimeout: 30000, forceExit: true }), + : { forceExit: true, testTimeout: 30000 }), transform: { "\\.ts$": "ts-jest", }, diff --git a/package.json b/package.json index 19494b7..d830041 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@clickup/rest-client", "description": "A syntax sugar tool around Node fetch() API, tailored to work with TypeScript and response validators", - "version": "2.10.296", + "version": "2.11.0", "license": "MIT", "keywords": [ "rest-client", @@ -51,6 +51,7 @@ "eslint-import-resolver-typescript": "^3.5.5", "eslint-plugin-import": "^2.27.5", "eslint-plugin-lodash": "^7.4.0", + "eslint-plugin-no-only-tests": "^3.1.0", "eslint-plugin-node": "^11.1.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react": "^7.32.2", diff --git a/src/RestClient.ts b/src/RestClient.ts index 951113d..87ce577 100644 --- a/src/RestClient.ts +++ b/src/RestClient.ts @@ -480,8 +480,8 @@ function simpleShape(path: string, args?: any) { args && typeof args === "object" ? Object.keys(args) // Filter out args that are already mentioned in the path, e.g. - // /pages/:pageID/blocks. - .filter((arg) => !argsInPath.includes(arg)) + // /pages/:pageID/blocks. Also filter out undefined args. + .filter((arg) => !argsInPath.includes(arg) && args[arg] !== undefined) .sort() .join(",") : ""; diff --git a/src/RestOptions.ts b/src/RestOptions.ts index 727b172..45cb470 100644 --- a/src/RestOptions.ts +++ b/src/RestOptions.ts @@ -96,6 +96,8 @@ export default interface RestOptions { /** 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. */ @@ -172,6 +174,7 @@ export const DEFAULT_OPTIONS: RestOptions = { throwIfResIsBigger: undefined, privateDataInResponse: false, allowInternalIPs: false, + responseEncoding: undefined, isDebug: false, agents: new Agents(), keepAlive: { timeoutMs: 10000 }, diff --git a/src/RestRequest.ts b/src/RestRequest.ts index 98c9b1b..f8ef6aa 100644 --- a/src/RestRequest.ts +++ b/src/RestRequest.ts @@ -347,6 +347,7 @@ export default class RestRequest { ); } }, + responseEncoding: this.options.responseEncoding, }); } diff --git a/src/index.ts b/src/index.ts index bc3c6c3..9f4f74c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,10 +7,7 @@ import RestRetriableError from "./errors/RestRetriableError"; import RestTimeoutError from "./errors/RestTimeoutError"; import RestTokenInvalidError from "./errors/RestTokenInvalidError"; import depaginate from "./helpers/depaginate"; -import paceRequests from "./middlewares/paceRequests"; -import Pacer from "./pacers/Pacer"; -import PacerComposite from "./pacers/PacerComposite"; -import PacerQPS, { PacerQPSBackend } from "./pacers/PacerQPS"; +import paceRequests, { Pacer, PacerOutcome } from "./middlewares/paceRequests"; import RestClient, { TokenGetter } from "./RestClient"; import RestOptions, { RestLogEvent, @@ -29,10 +26,8 @@ export { Headers, Middleware, Pacer, - PacerComposite, paceRequests, - PacerQPS, - PacerQPSBackend, + PacerOutcome, RestClient, RestContentSizeOverLimitError, RestError, diff --git a/src/internal/RestFetchReader.ts b/src/internal/RestFetchReader.ts index beb3bfe..c2a8055 100644 --- a/src/internal/RestFetchReader.ts +++ b/src/internal/RestFetchReader.ts @@ -10,6 +10,7 @@ export interface RestFetchReaderOptions { heartbeat?: () => Promise; onTimeout?: (reader: RestFetchReader, e: any) => void; onAfterRead?: (reader: RestFetchReader) => void; + responseEncoding?: NodeJS.BufferEncoding; } /** @@ -160,7 +161,9 @@ export default class RestFetchReader { // how Node streams and setEncoding() handle decoding when the returned // chunks cross the boundaries of multi-byte characters (TL;DR: it works // fine, that's why we work with string and not Buffer here). - res.body.setEncoding(inferResBodyEncoding(res)); + res.body.setEncoding( + this._options.responseEncoding ?? inferResBodyEncoding(res), + ); await this._options.heartbeat?.(); for await (const chunk of res.body) { diff --git a/src/internal/ellipsis.ts b/src/internal/ellipsis.ts new file mode 100644 index 0000000..39a2989 --- /dev/null +++ b/src/internal/ellipsis.ts @@ -0,0 +1,16 @@ +const ELLIPSIS = "…"; + +/** + * The fastest possible version of truncation. Lodash'es truncate() messes up + * with unicode a lot, so for e.g. logging purposes, it's super-slow. + */ +export default function ellipsis(string: any, length: number) { + string = ("" + string).trimEnd(); + length = Math.max(length, ELLIPSIS.length); + + if (string.length <= length) { + return string; + } + + return string.substring(0, length - ELLIPSIS.length) + ELLIPSIS; +} diff --git a/src/internal/inspectPossibleJSON.ts b/src/internal/inspectPossibleJSON.ts index 04150ae..93f29ab 100644 --- a/src/internal/inspectPossibleJSON.ts +++ b/src/internal/inspectPossibleJSON.ts @@ -1,6 +1,6 @@ import { inspect } from "util"; import sortBy from "lodash/sortBy"; -import truncate from "lodash/truncate"; +import ellipsis from "./ellipsis"; export default function inspectPossibleJSON( headers: { get(name: string): string | null }, @@ -65,7 +65,3 @@ function reorderObjectProps( Object.fromEntries(sortBy(entries, ([k, v]) => ranker(k, v))), ); } - -function ellipsis(text: any, length: number) { - return truncate("" + text, { length }).trimEnd(); -} diff --git a/src/internal/throwIfErrorResponse.ts b/src/internal/throwIfErrorResponse.ts index 14cfa54..89dca40 100644 --- a/src/internal/throwIfErrorResponse.ts +++ b/src/internal/throwIfErrorResponse.ts @@ -36,7 +36,7 @@ export default function throwIfErrorResponse( const retryAfterHeader = res.headers.get("Retry-After") || "0"; throw new RestRateLimitError( `Rate limited by HTTP status ${STATUS_TOO_MANY_REQUESTS}`, - parseInt(retryAfterHeader) || 0, + 1000 * parseInt(retryAfterHeader) || 0, res, ); } diff --git a/src/middlewares/paceRequests.ts b/src/middlewares/paceRequests.ts index f8e7a49..d2a12cd 100644 --- a/src/middlewares/paceRequests.ts +++ b/src/middlewares/paceRequests.ts @@ -1,16 +1,38 @@ -import type Pacer from "../pacers/Pacer"; import type { Middleware } from "../RestOptions"; import type RestRequest from "../RestRequest"; const MIN_LOG_DELAY_MS = 10; +/** + * Pacer is a class which allows to pace requests on some resource identified by + * the instance of this class. + */ +export interface Pacer { + /** Human readable name of the pacer, used when composing multiple pacers. */ + readonly key: string; + + /** + * Signals that we're about to send a request. Returns the delay we need to + * wait for before actually sending. + */ + pace(): Promise; +} + +/** + * A result of some Pacer work. + */ +export interface PacerOutcome { + delayMs: number; + reason: string; +} + /** * Rest Client middleware that adds some delay between requests using one of * Pacer implementations. */ export default function paceRequests( pacer: Pacer | ((req: RestRequest) => Promise) | null, - delayMetric?: (delay: number) => void, + delayMetric?: (delay: number, reason: string) => void, ): Middleware { return async (req, next) => { if (typeof pacer === "function") { @@ -18,10 +40,10 @@ export default function paceRequests( } if (pacer) { - const { delayMs, reason } = await pacer.touch(); + const { delayMs, reason } = await pacer.pace(); if (delayMs > 0) { - delayMetric?.(delayMs); + delayMetric?.(delayMs, reason); await req.options.heartbeater.delay(delayMs); } diff --git a/src/pacers/Pacer.ts b/src/pacers/Pacer.ts deleted file mode 100644 index 226287e..0000000 --- a/src/pacers/Pacer.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * A result of some Pacer work. - */ -export interface PacerDelay { - delayMs: number; - reason: string; -} - -/** - * Pacer is a class which allows to pace requests on some resource identified by - * the instance of this class. - */ -export default interface Pacer { - /** Human readable name of the pacer, used when composing multiple pacers. */ - readonly name: string; - - /** - * Signals that we're about to send a request. Returns the delay we need to - * wait for before actually sending. - */ - touch(): Promise; -} diff --git a/src/pacers/PacerComposite.ts b/src/pacers/PacerComposite.ts deleted file mode 100644 index 8ec87a1..0000000 --- a/src/pacers/PacerComposite.ts +++ /dev/null @@ -1,29 +0,0 @@ -import maxBy from "lodash/maxBy"; -import type Pacer from "./Pacer"; - -/** - * A Pacer which runs all sub-pacers and chooses the largest delay. - */ -export default class PacerComposite implements Pacer { - readonly name = ""; - - constructor(private _pacers: Pacer[]) {} - - async touch() { - const delays = await Promise["all"]( - this._pacers.map(async (pacer) => ({ - pacer, - delay: await pacer.touch(), - })), - ); - const pair = maxBy(delays, ({ delay }) => delay.delayMs); - return pair - ? { - ...pair.delay, - reason: pair.pacer.name - ? `${pair.pacer.constructor.name} ${pair.pacer.name}\n${pair.delay.reason}` - : pair.delay.reason, - } - : { delayMs: 0, reason: "no pacers" }; - } -} diff --git a/src/pacers/PacerQPS.ts b/src/pacers/PacerQPS.ts deleted file mode 100644 index 002b67b..0000000 --- a/src/pacers/PacerQPS.ts +++ /dev/null @@ -1,147 +0,0 @@ -import random from "lodash/random"; -import type { PacerDelay } from "./Pacer"; -import type Pacer from "./Pacer"; - -/** Start decreasing the delay (and thus speeding up requests) only when we have - * less requests in the moving window than allowed by the desired QPS multiplied - * by this factor. I.e. we don't speed up immediately when we're slow; we wait - * until we're SLIGHTLY below the limits. */ -const DEFAULT_DECREASE_THRESHOLD = 0.75; - -/** Default moving window length. */ -const DEFAULT_WINDOW_SEC = 30; - -/** Below how many samples do we stop relying on samples to recalculate the - * current fleet's average delay and instead keep using the previously - * calculated (and saved) value for the delay. E.g. it doesn't make much sense - * to rely on an average of 3-4 samples to calculate the average delay, it makes - * sense to wait for more samples to come. */ -const MIN_COUNT_FOR_CENTRAL_TENDENCY = 10; - -/** The value here is multiplied by the fleet average to get the delay - * increment/decrement step. It basically determines, in how many increments - * would a "cold started" worker reach the current fleet's average delay. Or, in - * how many steps would it reach delay=0 situation from the current fleet's - * average if needed. */ -const DELAY_AVG_TO_STEP_FACTOR = 0.02; - -/** Delay increments are jittered by +/- this proportion. */ -const DELAY_STEP_JITTER = 0.1; - -export interface PacerQPSBackend { - /** Resource key which this backend is operating on. */ - readonly key: string; - - /** - * Maintains the array of numbers somewhere in memory (time-value pairs), - * inserts a new time-value pair to the end of this list, and removes all the - * entries which are earlier than `minTime`. Returns the size of the resulting - * array and some central tendency statistics about its values. - */ - push(props: { - time: number; - minTime: number; - value: number; - minCountForCentralTendency: number; - }): Promise<{ count: number; sum: number; avg: number; median: number }>; -} - -export interface PacerQPSOptions { - /** The maximum QPS allowed within the rolling window. */ - qps: number; - /** The length of the rolling windows in milliseconds. */ - windowSec?: number; - /** Decrease the delay if the number of requests in the window has dropped - * below `decreaseThreshold` portion of the limit. */ - decreaseThreshold?: number; -} - -/** - * Implements a very simple heuristic: - * - increase the delay if we're above the QPS within the rolling window; - * - decrease the delay if we're below the desired QPS. - * - * Each worker keeps (and grows/shrinks) its delay individually; this way, we - * don't need to elect, who's the "source of truth" for the delay. - * - * Backend is a concrete (and minimal) implementation of the storage logic for - * the pacing algorithm. - */ -export default class PacerQPS implements Pacer { - private _isFirstTouch = true; - private _delay = 0; - - constructor( - private _options: PacerQPSOptions, - private _backend: PacerQPSBackend, - ) {} - - get name() { - return this._backend.key; - } - - async touch(): Promise { - const windowSec = this._options.windowSec ?? DEFAULT_WINDOW_SEC; - const limit = Math.round(windowSec * this._options.qps); - const decreaseThreshold = - this._options.decreaseThreshold ?? DEFAULT_DECREASE_THRESHOLD; - - const time = Date.now(); - const delayPushed = this._delay; - const { count, sum, avg, median } = await this._backend.push({ - time, - minTime: time - windowSec * 1000, - value: this._delay, - minCountForCentralTendency: MIN_COUNT_FOR_CENTRAL_TENDENCY, - }); - const sumDivCount = count ? sum / count : 0; - - // "Cold start": start with the fleet average delay. - if (this._isFirstTouch && this._delay === 0) { - this._delay = Math.round(avg); - this._isFirstTouch = false; - } - - // If we imagine there is only 1 worker in the fleet, what would be its - // delay increment/decrement step. We use this number in a fallback - // situation, when we don't know much about the entire fleet average delay - // yet, or when this delay is too small to count on. - const singleWorkerDelayStepMs = Math.round( - ((windowSec * 1000) / limit) * DELAY_AVG_TO_STEP_FACTOR, - ); - - // Considering that there are multiple workers running, and that the current - // average fleet's delay is representative, what would be a delay increment - // to reach from delay=0 to that fleet's average delay. - const multiWorkerDelayStepMs = Math.round( - avg * - DELAY_AVG_TO_STEP_FACTOR * - random(1 - DELAY_STEP_JITTER, 1 + DELAY_STEP_JITTER, true), - ); - - // If average fleet delay is not representative yet, we fallback to a - // single-worker delay increment. - const delayStepMs = multiWorkerDelayStepMs || singleWorkerDelayStepMs || 1; - - if (count > limit) { - // Increase the delay if the limit is reached. There is no "max delay": - // imagine we have 10 QPS limit and 10000 users; it's obvious that in this - // case, the delay between requests per a single user will be gigantic. - this._delay += delayStepMs; - } else if (count < limit * decreaseThreshold) { - // Decrease the delay if we're significantly under the limit. - this._delay = Math.max(0, this._delay - delayStepMs); - } - - return { - delayMs: this._delay, - reason: [ - `count=${count} per ${windowSec}s (limit=${limit})`, - `delay=${this._delay} step=${delayStepMs} delayPushed=${delayPushed}`, - `median=${Math.round(median)}`, - `sum/count=${Math.round(sumDivCount)}`, - `avg=${Math.round(avg)}`, - ].join("\n"), - }; - } -}