@tstdl/base 0.93.127 → 0.93.128
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/client/client.js +45 -9
- package/api/client/tests/api-client.test.d.ts +1 -0
- package/api/client/tests/api-client.test.js +194 -0
- package/api/types.d.ts +34 -2
- package/api/types.js +9 -2
- package/authentication/client/authentication.service.js +30 -11
- package/authentication/client/http-client.middleware.js +10 -3
- package/authentication/server/authentication.service.d.ts +12 -0
- package/authentication/server/authentication.service.js +14 -2
- package/authentication/tests/authentication.client-error-handling.test.js +23 -66
- package/authentication/tests/authentication.client-service-refresh.test.js +14 -14
- package/cancellation/token.d.ts +6 -0
- package/cancellation/token.js +8 -0
- package/http/client/adapters/undici.adapter.js +0 -2
- package/http/client/http-client-request.d.ts +2 -0
- package/http/client/http-client-request.js +4 -0
- package/http/client/http-client-response.d.ts +1 -1
- package/http/client/http-client-response.js +3 -2
- package/http/utils.d.ts +6 -0
- package/http/utils.js +71 -0
- package/injector/injector.js +2 -0
- package/mail/drizzle/0000_numerous_the_watchers.sql +8 -0
- package/mail/drizzle/meta/0000_snapshot.json +1 -32
- package/mail/drizzle/meta/_journal.json +2 -9
- package/object-storage/s3/tests/s3.object-storage.integration.test.js +22 -53
- package/orm/tests/repository-expiration.test.js +3 -3
- package/package.json +1 -1
- package/rate-limit/tests/postgres-rate-limiter.test.js +9 -7
- package/task-queue/tests/complex.test.js +22 -22
- package/task-queue/tests/dependencies.test.js +15 -13
- package/task-queue/tests/queue.test.js +13 -13
- package/task-queue/tests/worker.test.js +12 -12
- package/testing/integration-setup.d.ts +2 -0
- package/testing/integration-setup.js +13 -7
- package/utils/backoff.d.ts +27 -3
- package/utils/backoff.js +31 -9
- package/utils/index.d.ts +1 -0
- package/utils/index.js +1 -0
- package/utils/retry-with-backoff.d.ts +22 -0
- package/utils/retry-with-backoff.js +64 -0
- package/utils/tests/backoff.test.d.ts +1 -0
- package/utils/tests/backoff.test.js +41 -0
- package/utils/tests/retry-with-backoff.test.d.ts +1 -0
- package/utils/tests/retry-with-backoff.test.js +49 -0
- package/mail/drizzle/0000_previous_malcolm_colcord.sql +0 -13
- package/mail/drizzle/0001_flimsy_bloodscream.sql +0 -5
- package/mail/drizzle/meta/0001_snapshot.json +0 -69
package/utils/backoff.js
CHANGED
|
@@ -8,6 +8,7 @@ import { isDefined } from './type-guards.js';
|
|
|
8
8
|
*/
|
|
9
9
|
export const DEFAULT_BACKOFF_OPTIONS = {
|
|
10
10
|
strategy: 'exponential',
|
|
11
|
+
jitterStrategy: 'proportional',
|
|
11
12
|
initialDelay: 1000,
|
|
12
13
|
increase: 2,
|
|
13
14
|
maximumDelay: 30_000, // 30 seconds
|
|
@@ -19,6 +20,16 @@ export const DEFAULT_BACKOFF_OPTIONS = {
|
|
|
19
20
|
export class BackoffHelper {
|
|
20
21
|
options;
|
|
21
22
|
currentDelay;
|
|
23
|
+
attempt;
|
|
24
|
+
/**
|
|
25
|
+
* The current status of the backoff.
|
|
26
|
+
*/
|
|
27
|
+
get status() {
|
|
28
|
+
return {
|
|
29
|
+
attempt: this.attempt,
|
|
30
|
+
currentDelay: this.currentDelay,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
22
33
|
/**
|
|
23
34
|
* Creates a new BackoffHelper.
|
|
24
35
|
* @param options Partial backoff options, which will be merged with sane defaults.
|
|
@@ -39,12 +50,14 @@ export class BackoffHelper {
|
|
|
39
50
|
console.warn('Using an exponential backoff with an increase factor <= 1 is not recommended as the delay will not grow.');
|
|
40
51
|
}
|
|
41
52
|
this.currentDelay = this.options.initialDelay;
|
|
53
|
+
this.attempt = 0;
|
|
42
54
|
}
|
|
43
55
|
/**
|
|
44
56
|
* Resets the current delay to the initial delay.
|
|
45
57
|
*/
|
|
46
58
|
reset() {
|
|
47
59
|
this.currentDelay = this.options.initialDelay;
|
|
60
|
+
this.attempt = 0;
|
|
48
61
|
}
|
|
49
62
|
/**
|
|
50
63
|
* Calculates and returns the next backoff delay based on the configured strategy.
|
|
@@ -52,13 +65,18 @@ export class BackoffHelper {
|
|
|
52
65
|
* @returns The next delay in milliseconds.
|
|
53
66
|
*/
|
|
54
67
|
getNextDelay() {
|
|
55
|
-
const
|
|
68
|
+
const baseDelay = this.currentDelay;
|
|
69
|
+
const newDelay = getNewDelay(this.options.strategy, baseDelay, this.options.increase, this.options.maximumDelay);
|
|
56
70
|
this.currentDelay = newDelay;
|
|
71
|
+
this.attempt++;
|
|
72
|
+
if (this.options.jitterStrategy == 'full') {
|
|
73
|
+
return randomFloat(0, baseDelay);
|
|
74
|
+
}
|
|
57
75
|
if (this.options.jitter > 0) {
|
|
58
|
-
const jitterAmount =
|
|
59
|
-
return Math.max(0,
|
|
76
|
+
const jitterAmount = baseDelay * randomFloat(-this.options.jitter, this.options.jitter); // ±jitter
|
|
77
|
+
return Math.max(0, baseDelay + jitterAmount);
|
|
60
78
|
}
|
|
61
|
-
return
|
|
79
|
+
return baseDelay;
|
|
62
80
|
}
|
|
63
81
|
}
|
|
64
82
|
/**
|
|
@@ -72,9 +90,9 @@ export class BackoffHelper {
|
|
|
72
90
|
*/
|
|
73
91
|
export async function autoBackoffLoop(loopFunction, options = {}) {
|
|
74
92
|
const { errorHandler, ...loopOptions } = options;
|
|
75
|
-
await backoffLoop(async (controller, signal) => {
|
|
93
|
+
await backoffLoop(async (controller, signal, status) => {
|
|
76
94
|
try {
|
|
77
|
-
await loopFunction(controller, signal);
|
|
95
|
+
await loopFunction(controller, signal, status);
|
|
78
96
|
}
|
|
79
97
|
catch (error) {
|
|
80
98
|
errorHandler?.(error);
|
|
@@ -95,22 +113,26 @@ export async function backoffLoop(loopFunction, options = {}) {
|
|
|
95
113
|
const backoffHelper = new BackoffHelper(backoffOptions);
|
|
96
114
|
const loopToken = new CancellationToken();
|
|
97
115
|
let shouldBackoff = false;
|
|
116
|
+
let customBackoffDelay;
|
|
98
117
|
if (isDefined(cancellationSignal)) {
|
|
99
118
|
loopToken.inherit(cancellationSignal, { set: true, unset: false, complete: false, error: false });
|
|
100
119
|
}
|
|
101
120
|
const controller = {
|
|
102
|
-
backoff: () =>
|
|
121
|
+
backoff: (delay) => {
|
|
122
|
+
shouldBackoff = true;
|
|
123
|
+
customBackoffDelay = delay;
|
|
124
|
+
},
|
|
103
125
|
break: () => loopToken.set(),
|
|
104
126
|
};
|
|
105
127
|
while (loopToken.isUnset) {
|
|
106
|
-
await loopFunction(controller, loopToken.signal);
|
|
128
|
+
await loopFunction(controller, loopToken.signal, backoffHelper.status);
|
|
107
129
|
// Exit immediately if the loop function requested a break.
|
|
108
130
|
if (loopToken.isSet) {
|
|
109
131
|
return;
|
|
110
132
|
}
|
|
111
133
|
if (shouldBackoff) {
|
|
112
134
|
shouldBackoff = false;
|
|
113
|
-
const delay = backoffHelper.getNextDelay();
|
|
135
|
+
const delay = customBackoffDelay ?? backoffHelper.getNextDelay();
|
|
114
136
|
await cancelableTimeout(delay, loopToken.signal);
|
|
115
137
|
}
|
|
116
138
|
else {
|
package/utils/index.d.ts
CHANGED
|
@@ -39,6 +39,7 @@ export * from './random.js';
|
|
|
39
39
|
export * from './reactive-value-to-signal.js';
|
|
40
40
|
export * from './reflection.js';
|
|
41
41
|
export * from './repl.js';
|
|
42
|
+
export * from './retry-with-backoff.js';
|
|
42
43
|
export * from './set.js';
|
|
43
44
|
export * from './singleton.js';
|
|
44
45
|
export * from './sort.js';
|
package/utils/index.js
CHANGED
|
@@ -39,6 +39,7 @@ export * from './random.js';
|
|
|
39
39
|
export * from './reactive-value-to-signal.js';
|
|
40
40
|
export * from './reflection.js';
|
|
41
41
|
export * from './repl.js';
|
|
42
|
+
export * from './retry-with-backoff.js';
|
|
42
43
|
export * from './set.js';
|
|
43
44
|
export * from './singleton.js';
|
|
44
45
|
export * from './sort.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CancellationSignal } from '../cancellation/token.js';
|
|
2
|
+
import { type BackoffOptions } from './backoff.js';
|
|
3
|
+
export type RetryWithBackoffOptions = {
|
|
4
|
+
/** Timeout for each individual attempt in milliseconds. */
|
|
5
|
+
timeout?: number;
|
|
6
|
+
/** Retry configuration. If not provided, it will not retry. */
|
|
7
|
+
retry?: {
|
|
8
|
+
/** Whether to retry when an error occurs. */
|
|
9
|
+
shouldRetry: (error: Error, attempt: number) => boolean | Promise<boolean>;
|
|
10
|
+
/** Backoff configuration for retries. */
|
|
11
|
+
backoff?: BackoffOptions;
|
|
12
|
+
};
|
|
13
|
+
/** Optional cancellation signal to abort the execution. */
|
|
14
|
+
signal?: CancellationSignal;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Executes a function with a specified policy including timeout and retries with backoff.
|
|
18
|
+
* @param func The function to execute. It receives a cancellation signal that is set when the attempt times out or is cancelled.
|
|
19
|
+
* @param options The retry options.
|
|
20
|
+
* @returns The result of the function.
|
|
21
|
+
*/
|
|
22
|
+
export declare function retryWithBackoff<T>(func: (signal: CancellationSignal) => T | Promise<T>, options?: RetryWithBackoffOptions): Promise<T>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { HttpError } from '../http/http.error.js';
|
|
2
|
+
import { isDefined, isInstanceOf, isNumber, isString } from '../utils/type-guards.js';
|
|
3
|
+
import { backoffLoop } from './backoff.js';
|
|
4
|
+
import { withTimeout } from './timing.js';
|
|
5
|
+
/**
|
|
6
|
+
* Executes a function with a specified policy including timeout and retries with backoff.
|
|
7
|
+
* @param func The function to execute. It receives a cancellation signal that is set when the attempt times out or is cancelled.
|
|
8
|
+
* @param options The retry options.
|
|
9
|
+
* @returns The result of the function.
|
|
10
|
+
*/
|
|
11
|
+
export async function retryWithBackoff(func, options = {}) {
|
|
12
|
+
const { timeout, retry, signal } = options;
|
|
13
|
+
let result;
|
|
14
|
+
let lastError;
|
|
15
|
+
await backoffLoop(async (controller, loopSignal, status) => {
|
|
16
|
+
try {
|
|
17
|
+
const attemptSignal = isDefined(signal)
|
|
18
|
+
? signal.createChild().inherit(loopSignal)
|
|
19
|
+
: loopSignal;
|
|
20
|
+
if (isDefined(timeout)) {
|
|
21
|
+
result = await withTimeout(timeout, async () => await func(attemptSignal));
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
result = await func(attemptSignal);
|
|
25
|
+
}
|
|
26
|
+
controller.break();
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
lastError = error;
|
|
30
|
+
if (isDefined(retry) && await retry.shouldRetry(lastError, status.attempt)) {
|
|
31
|
+
const retryAfter = tryGetRetryAfterDelay(lastError);
|
|
32
|
+
controller.backoff(retryAfter);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}, {
|
|
39
|
+
...retry?.backoff,
|
|
40
|
+
cancellationSignal: signal,
|
|
41
|
+
});
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
function tryGetRetryAfterDelay(error) {
|
|
45
|
+
if (isInstanceOf(error, HttpError)) {
|
|
46
|
+
const retryAfter = error.responseInstance?.headers.tryGetSingle('Retry-After');
|
|
47
|
+
if (isDefined(retryAfter)) {
|
|
48
|
+
if (isNumber(retryAfter)) {
|
|
49
|
+
return retryAfter * 1000;
|
|
50
|
+
}
|
|
51
|
+
if (isString(retryAfter)) {
|
|
52
|
+
const seconds = Number.parseInt(retryAfter, 10);
|
|
53
|
+
if (!Number.isNaN(seconds)) {
|
|
54
|
+
return seconds * 1000;
|
|
55
|
+
}
|
|
56
|
+
const date = Date.parse(retryAfter);
|
|
57
|
+
if (!Number.isNaN(date)) {
|
|
58
|
+
return Math.max(0, date - Date.now());
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { BackoffHelper } from '../backoff.js';
|
|
3
|
+
describe('BackoffHelper', () => {
|
|
4
|
+
it('should support full jitter', () => {
|
|
5
|
+
const helper = new BackoffHelper({
|
|
6
|
+
initialDelay: 1000,
|
|
7
|
+
jitter: 1,
|
|
8
|
+
jitterStrategy: 'full',
|
|
9
|
+
});
|
|
10
|
+
for (let i = 0; i < 100; i++) {
|
|
11
|
+
const delay = helper.getNextDelay();
|
|
12
|
+
// Full jitter should be between 0 and the base delay (1000 for first call, then 2000, 4000, etc.)
|
|
13
|
+
expect(delay).toBeGreaterThanOrEqual(0);
|
|
14
|
+
expect(delay).toBeLessThanOrEqual(1000 * Math.pow(2, i));
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
it('should support proportional jitter (default)', () => {
|
|
18
|
+
const helper = new BackoffHelper({
|
|
19
|
+
initialDelay: 1000,
|
|
20
|
+
jitter: 0.1,
|
|
21
|
+
jitterStrategy: 'proportional',
|
|
22
|
+
});
|
|
23
|
+
for (let i = 0; i < 1; i++) {
|
|
24
|
+
const delay = helper.getNextDelay();
|
|
25
|
+
// Proportional jitter should be 1000 ± 100 (900 to 1100)
|
|
26
|
+
expect(delay).toBeGreaterThanOrEqual(900);
|
|
27
|
+
expect(delay).toBeLessThanOrEqual(1100);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
it('should provide status emission', () => {
|
|
31
|
+
const helper = new BackoffHelper({ initialDelay: 1000 });
|
|
32
|
+
expect(helper.status.attempt).toBe(0);
|
|
33
|
+
expect(helper.status.currentDelay).toBe(1000);
|
|
34
|
+
helper.getNextDelay();
|
|
35
|
+
expect(helper.status.attempt).toBe(1);
|
|
36
|
+
expect(helper.status.currentDelay).toBe(2000);
|
|
37
|
+
helper.reset();
|
|
38
|
+
expect(helper.status.attempt).toBe(0);
|
|
39
|
+
expect(helper.status.currentDelay).toBe(1000);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { TimeoutError } from '../../errors/timeout.error.js';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { retryWithBackoff } from '../retry-with-backoff.js';
|
|
4
|
+
describe('retryWithBackoff', () => {
|
|
5
|
+
it('should return result on success', async () => {
|
|
6
|
+
const result = await retryWithBackoff(async () => 'success');
|
|
7
|
+
expect(result).toBe('success');
|
|
8
|
+
});
|
|
9
|
+
it('should retry on failure if shouldRetry returns true', async () => {
|
|
10
|
+
let attempts = 0;
|
|
11
|
+
const result = await retryWithBackoff(async () => {
|
|
12
|
+
attempts++;
|
|
13
|
+
if (attempts < 3) {
|
|
14
|
+
throw new Error('fail');
|
|
15
|
+
}
|
|
16
|
+
return 'success';
|
|
17
|
+
}, {
|
|
18
|
+
retry: {
|
|
19
|
+
shouldRetry: (_error, attempt) => attempt < 5,
|
|
20
|
+
backoff: { initialDelay: 0 },
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
expect(result).toBe('success');
|
|
24
|
+
expect(attempts).toBe(3);
|
|
25
|
+
});
|
|
26
|
+
it('should throw after max retries', async () => {
|
|
27
|
+
let attempts = 0;
|
|
28
|
+
const execute = retryWithBackoff(async () => {
|
|
29
|
+
attempts++;
|
|
30
|
+
throw new Error('fail');
|
|
31
|
+
}, {
|
|
32
|
+
retry: {
|
|
33
|
+
shouldRetry: (_error, attempt) => attempt < 2,
|
|
34
|
+
backoff: { initialDelay: 0 },
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
await expect(execute).rejects.toThrow('fail');
|
|
38
|
+
expect(attempts).toBe(3); // 0, 1, 2
|
|
39
|
+
});
|
|
40
|
+
it('should timeout if execution takes too long', async () => {
|
|
41
|
+
const execute = retryWithBackoff(async () => {
|
|
42
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
43
|
+
return 'success';
|
|
44
|
+
}, {
|
|
45
|
+
timeout: 50,
|
|
46
|
+
});
|
|
47
|
+
await expect(execute).rejects.toThrow(TimeoutError);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
CREATE TABLE "mail"."log" (
|
|
2
|
-
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
3
|
-
"timestamp" timestamp with time zone NOT NULL,
|
|
4
|
-
"template" text,
|
|
5
|
-
"data" jsonb NOT NULL,
|
|
6
|
-
"send_result" jsonb,
|
|
7
|
-
"errors" text[],
|
|
8
|
-
"revision" integer NOT NULL,
|
|
9
|
-
"revision_timestamp" timestamp with time zone NOT NULL,
|
|
10
|
-
"create_timestamp" timestamp with time zone NOT NULL,
|
|
11
|
-
"delete_timestamp" timestamp with time zone,
|
|
12
|
-
"attributes" jsonb DEFAULT '{}'::jsonb NOT NULL
|
|
13
|
-
);
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
ALTER TABLE "mail"."log" DROP COLUMN "revision";--> statement-breakpoint
|
|
2
|
-
ALTER TABLE "mail"."log" DROP COLUMN "revision_timestamp";--> statement-breakpoint
|
|
3
|
-
ALTER TABLE "mail"."log" DROP COLUMN "create_timestamp";--> statement-breakpoint
|
|
4
|
-
ALTER TABLE "mail"."log" DROP COLUMN "delete_timestamp";--> statement-breakpoint
|
|
5
|
-
ALTER TABLE "mail"."log" DROP COLUMN "attributes";
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "28ddafef-64f4-437d-9406-82a02c76570e",
|
|
3
|
-
"prevId": "f8cdba37-11b9-477a-9f5a-5ef4b5026011",
|
|
4
|
-
"version": "7",
|
|
5
|
-
"dialect": "postgresql",
|
|
6
|
-
"tables": {
|
|
7
|
-
"mail.log": {
|
|
8
|
-
"name": "log",
|
|
9
|
-
"schema": "mail",
|
|
10
|
-
"columns": {
|
|
11
|
-
"id": {
|
|
12
|
-
"name": "id",
|
|
13
|
-
"type": "uuid",
|
|
14
|
-
"primaryKey": true,
|
|
15
|
-
"notNull": true,
|
|
16
|
-
"default": "gen_random_uuid()"
|
|
17
|
-
},
|
|
18
|
-
"timestamp": {
|
|
19
|
-
"name": "timestamp",
|
|
20
|
-
"type": "timestamp with time zone",
|
|
21
|
-
"primaryKey": false,
|
|
22
|
-
"notNull": true
|
|
23
|
-
},
|
|
24
|
-
"template": {
|
|
25
|
-
"name": "template",
|
|
26
|
-
"type": "text",
|
|
27
|
-
"primaryKey": false,
|
|
28
|
-
"notNull": false
|
|
29
|
-
},
|
|
30
|
-
"data": {
|
|
31
|
-
"name": "data",
|
|
32
|
-
"type": "jsonb",
|
|
33
|
-
"primaryKey": false,
|
|
34
|
-
"notNull": true
|
|
35
|
-
},
|
|
36
|
-
"send_result": {
|
|
37
|
-
"name": "send_result",
|
|
38
|
-
"type": "jsonb",
|
|
39
|
-
"primaryKey": false,
|
|
40
|
-
"notNull": false
|
|
41
|
-
},
|
|
42
|
-
"errors": {
|
|
43
|
-
"name": "errors",
|
|
44
|
-
"type": "text[]",
|
|
45
|
-
"primaryKey": false,
|
|
46
|
-
"notNull": false
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
|
-
"indexes": {},
|
|
50
|
-
"foreignKeys": {},
|
|
51
|
-
"compositePrimaryKeys": {},
|
|
52
|
-
"uniqueConstraints": {},
|
|
53
|
-
"policies": {},
|
|
54
|
-
"checkConstraints": {},
|
|
55
|
-
"isRLSEnabled": false
|
|
56
|
-
}
|
|
57
|
-
},
|
|
58
|
-
"enums": {},
|
|
59
|
-
"schemas": {},
|
|
60
|
-
"sequences": {},
|
|
61
|
-
"roles": {},
|
|
62
|
-
"policies": {},
|
|
63
|
-
"views": {},
|
|
64
|
-
"_meta": {
|
|
65
|
-
"columns": {},
|
|
66
|
-
"schemas": {},
|
|
67
|
-
"tables": {}
|
|
68
|
-
}
|
|
69
|
-
}
|