@zauru-sdk/common 6.9.0 → 6.10.0
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/dist/common.d.ts +4 -1
- package/dist/esm/common.js +28 -4
- package/dist/esm/httpClientPolicy.js +153 -0
- package/dist/esm/index.js +1 -0
- package/dist/httpClientPolicy.d.ts +64 -0
- package/dist/index.d.ts +1 -0
- package/package.json +4 -3
- package/src/common.ts +35 -9
- package/src/httpClientPolicy.test.ts +136 -0
- package/src/httpClientPolicy.ts +216 -0
- package/src/index.ts +1 -0
- package/tsconfig.json +2 -1
package/dist/common.d.ts
CHANGED
|
@@ -182,9 +182,12 @@ export declare const sortByProperty: (array: any[], property: string) => any[];
|
|
|
182
182
|
/**
|
|
183
183
|
* Handle web app table actions and return a response with a consistent format.
|
|
184
184
|
* @param action A function that returns a Promise of type T.
|
|
185
|
+
* @param timeout Seconds for the whole callback. Omit to use the active
|
|
186
|
+
* HTTP client policy (25s by default). `false` disables the race so each
|
|
187
|
+
* HTTP call uses its own axios timeout.
|
|
185
188
|
* @returns A Promise of AxiosUtilsResponse<T>.
|
|
186
189
|
*/
|
|
187
|
-
export declare function handlePossibleAxiosErrors<T>(action: () => Promise<T>, timeout?: number): Promise<AxiosUtilsResponse<T>>;
|
|
190
|
+
export declare function handlePossibleAxiosErrors<T>(action: () => Promise<T>, timeout?: number | false): Promise<AxiosUtilsResponse<T>>;
|
|
188
191
|
/**
|
|
189
192
|
* createPostgresUrl
|
|
190
193
|
* @param host
|
package/dist/esm/common.js
CHANGED
|
@@ -25,6 +25,7 @@ const moment_1 = __importDefault(require("moment"));
|
|
|
25
25
|
require("moment-timezone");
|
|
26
26
|
const types_1 = require("@zauru-sdk/types");
|
|
27
27
|
const rejectionPercentageAdjustment_js_1 = require("./rejectionPercentageAdjustment.js");
|
|
28
|
+
const httpClientPolicy_js_1 = require("./httpClientPolicy.js");
|
|
28
29
|
exports.DESTINOS_MUESTRA_OPTIONS = [
|
|
29
30
|
{ label: "Microbiología", value: "microbiologa" },
|
|
30
31
|
{ label: "Residuos de plaguicidas", value: "residuos_de_plaguicidas" },
|
|
@@ -711,13 +712,36 @@ exports.sortByProperty = sortByProperty;
|
|
|
711
712
|
/**
|
|
712
713
|
* Handle web app table actions and return a response with a consistent format.
|
|
713
714
|
* @param action A function that returns a Promise of type T.
|
|
715
|
+
* @param timeout Seconds for the whole callback. Omit to use the active
|
|
716
|
+
* HTTP client policy (25s by default). `false` disables the race so each
|
|
717
|
+
* HTTP call uses its own axios timeout.
|
|
714
718
|
* @returns A Promise of AxiosUtilsResponse<T>.
|
|
715
719
|
*/
|
|
716
|
-
async function handlePossibleAxiosErrors(action, timeout
|
|
717
|
-
const
|
|
720
|
+
async function handlePossibleAxiosErrors(action, timeout) {
|
|
721
|
+
const policyTimeout = (0, httpClientPolicy_js_1.getHttpClientPolicy)().actionTimeoutSeconds;
|
|
722
|
+
const resolved = timeout === undefined ? policyTimeout : timeout;
|
|
723
|
+
const seconds = resolved === false || resolved <= 0
|
|
724
|
+
? null
|
|
725
|
+
: resolved ?? httpClientPolicy_js_1.DEFAULT_ACTION_TIMEOUT_SECONDS;
|
|
718
726
|
try {
|
|
719
|
-
|
|
720
|
-
|
|
727
|
+
if (seconds == null) {
|
|
728
|
+
const result = await action();
|
|
729
|
+
return { error: false, data: result };
|
|
730
|
+
}
|
|
731
|
+
let timer;
|
|
732
|
+
try {
|
|
733
|
+
const result = await Promise.race([
|
|
734
|
+
action(),
|
|
735
|
+
new Promise((_, reject) => {
|
|
736
|
+
timer = setTimeout(() => reject(new Error(`Timeout skipped after ${seconds} seconds`)), seconds * 1000);
|
|
737
|
+
}),
|
|
738
|
+
]);
|
|
739
|
+
return { error: false, data: result };
|
|
740
|
+
}
|
|
741
|
+
finally {
|
|
742
|
+
if (timer)
|
|
743
|
+
clearTimeout(timer);
|
|
744
|
+
}
|
|
721
745
|
}
|
|
722
746
|
catch (error) {
|
|
723
747
|
return {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Timeouts and retries for outbound HTTP (Zauru REST + Hasura GraphQL).
|
|
4
|
+
*
|
|
5
|
+
* Defaults keep the 25s action budget of Netlify sync functions. Background
|
|
6
|
+
* lambdas opt into {@link BACKGROUND_HTTP_CLIENT_POLICY}: per-request max of
|
|
7
|
+
* 30s (Heroku router H12 and Hasura `HASURA_GRAPHQL_SERVER_TIMEOUT` default)
|
|
8
|
+
* and one retry. The 25s `handlePossibleAxiosErrors` race is disabled there
|
|
9
|
+
* so a slow query is not aborted before the platform itself gives up.
|
|
10
|
+
*
|
|
11
|
+
* Use {@link runWithHttpClientPolicy} / {@link withHttpClientPolicy} around a
|
|
12
|
+
* set of calls, or pass `timeout` on a single axios config to override one
|
|
13
|
+
* request. GraphQL POSTs retry; Zauru writes do not retry on timeout (the
|
|
14
|
+
* server may have committed).
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.BACKGROUND_HTTP_CLIENT_POLICY = exports.DEFAULT_HTTP_CLIENT_POLICY = exports.HASURA_SERVER_TIMEOUT_SECONDS = exports.HEROKU_ROUTER_TIMEOUT_SECONDS = exports.DEFAULT_HTTP_TIMEOUT_SECONDS = exports.DEFAULT_ACTION_TIMEOUT_SECONDS = void 0;
|
|
18
|
+
exports.mergeHttpClientPolicies = mergeHttpClientPolicies;
|
|
19
|
+
exports.bindHttpClientPolicyRuntime = bindHttpClientPolicyRuntime;
|
|
20
|
+
exports.getHttpClientPolicy = getHttpClientPolicy;
|
|
21
|
+
exports.runWithHttpClientPolicy = runWithHttpClientPolicy;
|
|
22
|
+
exports.withHttpClientPolicy = withHttpClientPolicy;
|
|
23
|
+
exports.isRetryableHttpError = isRetryableHttpError;
|
|
24
|
+
exports.httpTimeoutMsForClient = httpTimeoutMsForClient;
|
|
25
|
+
exports.DEFAULT_ACTION_TIMEOUT_SECONDS = 25;
|
|
26
|
+
exports.DEFAULT_HTTP_TIMEOUT_SECONDS = 200;
|
|
27
|
+
exports.HEROKU_ROUTER_TIMEOUT_SECONDS = 30;
|
|
28
|
+
exports.HASURA_SERVER_TIMEOUT_SECONDS = 30;
|
|
29
|
+
exports.DEFAULT_HTTP_CLIENT_POLICY = {
|
|
30
|
+
actionTimeoutSeconds: exports.DEFAULT_ACTION_TIMEOUT_SECONDS,
|
|
31
|
+
zauruTimeoutSeconds: exports.DEFAULT_HTTP_TIMEOUT_SECONDS,
|
|
32
|
+
graphqlTimeoutSeconds: exports.DEFAULT_HTTP_TIMEOUT_SECONDS,
|
|
33
|
+
retries: 0,
|
|
34
|
+
};
|
|
35
|
+
exports.BACKGROUND_HTTP_CLIENT_POLICY = {
|
|
36
|
+
actionTimeoutSeconds: false,
|
|
37
|
+
zauruTimeoutSeconds: exports.HEROKU_ROUTER_TIMEOUT_SECONDS,
|
|
38
|
+
graphqlTimeoutSeconds: exports.HASURA_SERVER_TIMEOUT_SECONDS,
|
|
39
|
+
retries: 1,
|
|
40
|
+
};
|
|
41
|
+
const RETRYABLE_STATUS = new Set([408, 429, 502, 503, 504]);
|
|
42
|
+
const RETRYABLE_NETWORK_CODES = new Set([
|
|
43
|
+
"ECONNRESET",
|
|
44
|
+
"ECONNABORTED",
|
|
45
|
+
"ETIMEDOUT",
|
|
46
|
+
"ENOTFOUND",
|
|
47
|
+
"EAI_AGAIN",
|
|
48
|
+
"ERR_NETWORK",
|
|
49
|
+
"ERR_CANCELED",
|
|
50
|
+
]);
|
|
51
|
+
function mergeHttpClientPolicies(base, override = {}) {
|
|
52
|
+
return {
|
|
53
|
+
...exports.DEFAULT_HTTP_CLIENT_POLICY,
|
|
54
|
+
...base,
|
|
55
|
+
...override,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
let fallbackStore;
|
|
59
|
+
function isThenable(value) {
|
|
60
|
+
return (typeof value === "object" &&
|
|
61
|
+
value !== null &&
|
|
62
|
+
typeof value.then === "function");
|
|
63
|
+
}
|
|
64
|
+
function runWithFallbackStore(policy, fn) {
|
|
65
|
+
const previous = fallbackStore;
|
|
66
|
+
fallbackStore = policy;
|
|
67
|
+
try {
|
|
68
|
+
const result = fn();
|
|
69
|
+
if (isThenable(result)) {
|
|
70
|
+
return result.finally(() => {
|
|
71
|
+
fallbackStore = previous;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
fallbackStore = previous;
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
fallbackStore = previous;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const fallbackRuntime = {
|
|
83
|
+
get: () => fallbackStore,
|
|
84
|
+
run: runWithFallbackStore,
|
|
85
|
+
};
|
|
86
|
+
let runtime = fallbackRuntime;
|
|
87
|
+
/**
|
|
88
|
+
* Services bind AsyncLocalStorage here so concurrent serverless invocations
|
|
89
|
+
* do not leak policy. Common stays browser-safe (no `node:async_hooks`).
|
|
90
|
+
*/
|
|
91
|
+
function bindHttpClientPolicyRuntime(next) {
|
|
92
|
+
runtime = next;
|
|
93
|
+
}
|
|
94
|
+
function getHttpClientPolicy() {
|
|
95
|
+
return mergeHttpClientPolicies(undefined, runtime.get() ?? {});
|
|
96
|
+
}
|
|
97
|
+
function runWithHttpClientPolicy(policy, fn) {
|
|
98
|
+
const merged = mergeHttpClientPolicies(runtime.get(), policy);
|
|
99
|
+
return runtime.run(merged, fn);
|
|
100
|
+
}
|
|
101
|
+
function withHttpClientPolicy(policy, fn) {
|
|
102
|
+
return (...args) => runWithHttpClientPolicy(policy, () => fn(...args));
|
|
103
|
+
}
|
|
104
|
+
function isTimeoutError(error) {
|
|
105
|
+
const code = error.code ?? "";
|
|
106
|
+
if (code === "ECONNABORTED" || code === "ETIMEDOUT") {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
return /timeout/i.test(error.message ?? "");
|
|
110
|
+
}
|
|
111
|
+
function isTransientFailure(error) {
|
|
112
|
+
const status = error.response?.status;
|
|
113
|
+
if (status != null) {
|
|
114
|
+
return RETRYABLE_STATUS.has(status);
|
|
115
|
+
}
|
|
116
|
+
const code = error.code ?? "";
|
|
117
|
+
return isTimeoutError(error) || RETRYABLE_NETWORK_CODES.has(code);
|
|
118
|
+
}
|
|
119
|
+
function isRetryableZauruError(error) {
|
|
120
|
+
const method = (error.config?.method ?? "get").toLowerCase();
|
|
121
|
+
const idempotent = method === "get" || method === "head" || method === "options";
|
|
122
|
+
if (idempotent) {
|
|
123
|
+
return isTransientFailure(error);
|
|
124
|
+
}
|
|
125
|
+
if (error.response?.status != null || isTimeoutError(error)) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return RETRYABLE_NETWORK_CODES.has(error.code ?? "");
|
|
129
|
+
}
|
|
130
|
+
function isRetryableHttpError(error, client) {
|
|
131
|
+
switch (client) {
|
|
132
|
+
case "graphql":
|
|
133
|
+
return isTransientFailure(error);
|
|
134
|
+
case "zauru":
|
|
135
|
+
return isRetryableZauruError(error);
|
|
136
|
+
default: {
|
|
137
|
+
const _never = client;
|
|
138
|
+
return _never;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function httpTimeoutMsForClient(client, policy = getHttpClientPolicy()) {
|
|
143
|
+
switch (client) {
|
|
144
|
+
case "graphql":
|
|
145
|
+
return policy.graphqlTimeoutSeconds * 1000;
|
|
146
|
+
case "zauru":
|
|
147
|
+
return policy.zauruTimeoutSeconds * 1000;
|
|
148
|
+
default: {
|
|
149
|
+
const _never = client;
|
|
150
|
+
return _never;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
package/dist/esm/index.js
CHANGED
|
@@ -16,5 +16,6 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
__exportStar(require("./common.js"), exports);
|
|
18
18
|
__exportStar(require("./gtZones.js"), exports);
|
|
19
|
+
__exportStar(require("./httpClientPolicy.js"), exports);
|
|
19
20
|
__exportStar(require("./oauthErrors.js"), exports);
|
|
20
21
|
__exportStar(require("./rejectionPercentageAdjustment.js"), exports);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeouts and retries for outbound HTTP (Zauru REST + Hasura GraphQL).
|
|
3
|
+
*
|
|
4
|
+
* Defaults keep the 25s action budget of Netlify sync functions. Background
|
|
5
|
+
* lambdas opt into {@link BACKGROUND_HTTP_CLIENT_POLICY}: per-request max of
|
|
6
|
+
* 30s (Heroku router H12 and Hasura `HASURA_GRAPHQL_SERVER_TIMEOUT` default)
|
|
7
|
+
* and one retry. The 25s `handlePossibleAxiosErrors` race is disabled there
|
|
8
|
+
* so a slow query is not aborted before the platform itself gives up.
|
|
9
|
+
*
|
|
10
|
+
* Use {@link runWithHttpClientPolicy} / {@link withHttpClientPolicy} around a
|
|
11
|
+
* set of calls, or pass `timeout` on a single axios config to override one
|
|
12
|
+
* request. GraphQL POSTs retry; Zauru writes do not retry on timeout (the
|
|
13
|
+
* server may have committed).
|
|
14
|
+
*/
|
|
15
|
+
export type HttpClientKind = "graphql" | "zauru";
|
|
16
|
+
export type HttpClientPolicy = {
|
|
17
|
+
/**
|
|
18
|
+
* Budget for the whole `handlePossibleAxiosErrors` callback.
|
|
19
|
+
* `false` disables that race so each HTTP call uses its own timeout.
|
|
20
|
+
*/
|
|
21
|
+
actionTimeoutSeconds?: number | false;
|
|
22
|
+
zauruTimeoutSeconds?: number;
|
|
23
|
+
graphqlTimeoutSeconds?: number;
|
|
24
|
+
/** Extra attempts after the first failure. `1` = retry once. */
|
|
25
|
+
retries?: number;
|
|
26
|
+
};
|
|
27
|
+
export type ResolvedHttpClientPolicy = {
|
|
28
|
+
actionTimeoutSeconds: number | false;
|
|
29
|
+
zauruTimeoutSeconds: number;
|
|
30
|
+
graphqlTimeoutSeconds: number;
|
|
31
|
+
retries: number;
|
|
32
|
+
};
|
|
33
|
+
export type HttpErrorLike = {
|
|
34
|
+
code?: string;
|
|
35
|
+
message?: string;
|
|
36
|
+
response?: {
|
|
37
|
+
status?: number;
|
|
38
|
+
};
|
|
39
|
+
config?: {
|
|
40
|
+
method?: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
export declare const DEFAULT_ACTION_TIMEOUT_SECONDS = 25;
|
|
44
|
+
export declare const DEFAULT_HTTP_TIMEOUT_SECONDS = 200;
|
|
45
|
+
export declare const HEROKU_ROUTER_TIMEOUT_SECONDS = 30;
|
|
46
|
+
export declare const HASURA_SERVER_TIMEOUT_SECONDS = 30;
|
|
47
|
+
export declare const DEFAULT_HTTP_CLIENT_POLICY: ResolvedHttpClientPolicy;
|
|
48
|
+
export declare const BACKGROUND_HTTP_CLIENT_POLICY: HttpClientPolicy;
|
|
49
|
+
export declare function mergeHttpClientPolicies(base: HttpClientPolicy | undefined, override?: HttpClientPolicy): ResolvedHttpClientPolicy;
|
|
50
|
+
type PolicyRuntime = {
|
|
51
|
+
get: () => HttpClientPolicy | undefined;
|
|
52
|
+
run: <T>(policy: ResolvedHttpClientPolicy, fn: () => T) => T;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Services bind AsyncLocalStorage here so concurrent serverless invocations
|
|
56
|
+
* do not leak policy. Common stays browser-safe (no `node:async_hooks`).
|
|
57
|
+
*/
|
|
58
|
+
export declare function bindHttpClientPolicyRuntime(next: PolicyRuntime): void;
|
|
59
|
+
export declare function getHttpClientPolicy(): ResolvedHttpClientPolicy;
|
|
60
|
+
export declare function runWithHttpClientPolicy<T>(policy: HttpClientPolicy, fn: () => T): T;
|
|
61
|
+
export declare function withHttpClientPolicy<Args extends unknown[], Result>(policy: HttpClientPolicy, fn: (...args: Args) => Result): (...args: Args) => Result;
|
|
62
|
+
export declare function isRetryableHttpError(error: HttpErrorLike, client: HttpClientKind): boolean;
|
|
63
|
+
export declare function httpTimeoutMsForClient(client: HttpClientKind, policy?: ResolvedHttpClientPolicy): number;
|
|
64
|
+
export {};
|
package/dist/index.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zauru-sdk/common",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.10.0",
|
|
4
4
|
"description": "Funciones de uso común en el desarrollo de webapps en Zauru",
|
|
5
5
|
"main": "./dist/esm/index.js",
|
|
6
6
|
"module": "./dist/esm/index.js",
|
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
"scripts": {
|
|
13
13
|
"build": "npm run build:esm",
|
|
14
14
|
"build:esm": "tsc -p tsconfig.esm.json",
|
|
15
|
-
"test": "
|
|
15
|
+
"test": "tsx --test src/httpClientPolicy.test.ts"
|
|
16
16
|
},
|
|
17
17
|
"author": "Oscar Cuéllar",
|
|
18
18
|
"license": "MIT",
|
|
19
19
|
"devDependencies": {
|
|
20
|
+
"tsx": "^4.23.15",
|
|
20
21
|
"typescript": "^5.1.6"
|
|
21
22
|
},
|
|
22
23
|
"dependencies": {
|
|
@@ -25,5 +26,5 @@
|
|
|
25
26
|
"moment": "^2.30.1",
|
|
26
27
|
"moment-timezone": "^0.5.45"
|
|
27
28
|
},
|
|
28
|
-
"gitHead": "
|
|
29
|
+
"gitHead": "8df8d97cb33e23e09198c617a95cffdff96cd05b"
|
|
29
30
|
}
|
package/src/common.ts
CHANGED
|
@@ -21,6 +21,10 @@ import {
|
|
|
21
21
|
normalizeRejectionPercentageLayers,
|
|
22
22
|
resolveRejectionPercentageOrigin,
|
|
23
23
|
} from "./rejectionPercentageAdjustment.js";
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_ACTION_TIMEOUT_SECONDS,
|
|
26
|
+
getHttpClientPolicy,
|
|
27
|
+
} from "./httpClientPolicy.js";
|
|
24
28
|
|
|
25
29
|
export const DESTINOS_MUESTRA_OPTIONS: SelectFieldOption[] = [
|
|
26
30
|
{ label: "Microbiología", value: "microbiologa" },
|
|
@@ -848,22 +852,44 @@ export const sortByProperty = (array: any[], property: string) => {
|
|
|
848
852
|
/**
|
|
849
853
|
* Handle web app table actions and return a response with a consistent format.
|
|
850
854
|
* @param action A function that returns a Promise of type T.
|
|
855
|
+
* @param timeout Seconds for the whole callback. Omit to use the active
|
|
856
|
+
* HTTP client policy (25s by default). `false` disables the race so each
|
|
857
|
+
* HTTP call uses its own axios timeout.
|
|
851
858
|
* @returns A Promise of AxiosUtilsResponse<T>.
|
|
852
859
|
*/
|
|
853
860
|
export async function handlePossibleAxiosErrors<T>(
|
|
854
861
|
action: () => Promise<T>,
|
|
855
|
-
timeout
|
|
862
|
+
timeout?: number | false,
|
|
856
863
|
): Promise<AxiosUtilsResponse<T>> {
|
|
857
|
-
const
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
864
|
+
const policyTimeout = getHttpClientPolicy().actionTimeoutSeconds;
|
|
865
|
+
const resolved = timeout === undefined ? policyTimeout : timeout;
|
|
866
|
+
const seconds =
|
|
867
|
+
resolved === false || resolved <= 0
|
|
868
|
+
? null
|
|
869
|
+
: resolved ?? DEFAULT_ACTION_TIMEOUT_SECONDS;
|
|
863
870
|
|
|
864
871
|
try {
|
|
865
|
-
|
|
866
|
-
|
|
872
|
+
if (seconds == null) {
|
|
873
|
+
const result = await action();
|
|
874
|
+
return { error: false, data: result } as AxiosUtilsResponse<T>;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
878
|
+
try {
|
|
879
|
+
const result = await Promise.race([
|
|
880
|
+
action(),
|
|
881
|
+
new Promise<never>((_, reject) => {
|
|
882
|
+
timer = setTimeout(
|
|
883
|
+
() =>
|
|
884
|
+
reject(new Error(`Timeout skipped after ${seconds} seconds`)),
|
|
885
|
+
seconds * 1000,
|
|
886
|
+
);
|
|
887
|
+
}),
|
|
888
|
+
]);
|
|
889
|
+
return { error: false, data: result } as AxiosUtilsResponse<T>;
|
|
890
|
+
} finally {
|
|
891
|
+
if (timer) clearTimeout(timer);
|
|
892
|
+
}
|
|
867
893
|
} catch (error) {
|
|
868
894
|
return {
|
|
869
895
|
error: true,
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { handlePossibleAxiosErrors } from "./common.js";
|
|
4
|
+
import {
|
|
5
|
+
BACKGROUND_HTTP_CLIENT_POLICY,
|
|
6
|
+
DEFAULT_HTTP_CLIENT_POLICY,
|
|
7
|
+
getHttpClientPolicy,
|
|
8
|
+
isRetryableHttpError,
|
|
9
|
+
mergeHttpClientPolicies,
|
|
10
|
+
runWithHttpClientPolicy,
|
|
11
|
+
} from "./httpClientPolicy.js";
|
|
12
|
+
|
|
13
|
+
describe("mergeHttpClientPolicies", () => {
|
|
14
|
+
it("When merging an empty override, it should keep the defaults", () => {
|
|
15
|
+
assert.deepEqual(
|
|
16
|
+
mergeHttpClientPolicies(undefined, {}),
|
|
17
|
+
DEFAULT_HTTP_CLIENT_POLICY,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("When merging a background policy, it should disable the action race and set 30s + 1 retry", () => {
|
|
22
|
+
const merged = mergeHttpClientPolicies(
|
|
23
|
+
undefined,
|
|
24
|
+
BACKGROUND_HTTP_CLIENT_POLICY,
|
|
25
|
+
);
|
|
26
|
+
assert.equal(merged.actionTimeoutSeconds, false);
|
|
27
|
+
assert.equal(merged.zauruTimeoutSeconds, 30);
|
|
28
|
+
assert.equal(merged.graphqlTimeoutSeconds, 30);
|
|
29
|
+
assert.equal(merged.retries, 1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("When nesting policies, it should let the inner override win", () => {
|
|
33
|
+
const merged = mergeHttpClientPolicies(
|
|
34
|
+
BACKGROUND_HTTP_CLIENT_POLICY,
|
|
35
|
+
{ graphqlTimeoutSeconds: 15, retries: 2 },
|
|
36
|
+
);
|
|
37
|
+
assert.equal(merged.actionTimeoutSeconds, false);
|
|
38
|
+
assert.equal(merged.graphqlTimeoutSeconds, 15);
|
|
39
|
+
assert.equal(merged.zauruTimeoutSeconds, 30);
|
|
40
|
+
assert.equal(merged.retries, 2);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("runWithHttpClientPolicy", () => {
|
|
45
|
+
it("When running a policy, it should apply inside the callback and restore after", () => {
|
|
46
|
+
assert.equal(getHttpClientPolicy().retries, 0);
|
|
47
|
+
const inside = runWithHttpClientPolicy({ retries: 1 }, () =>
|
|
48
|
+
getHttpClientPolicy().retries,
|
|
49
|
+
);
|
|
50
|
+
assert.equal(inside, 1);
|
|
51
|
+
assert.equal(getHttpClientPolicy().retries, 0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("When the callback is async, it should restore the policy after it settles", async () => {
|
|
55
|
+
await runWithHttpClientPolicy(
|
|
56
|
+
{ actionTimeoutSeconds: false },
|
|
57
|
+
async () => {
|
|
58
|
+
assert.equal(getHttpClientPolicy().actionTimeoutSeconds, false);
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
assert.equal(getHttpClientPolicy().actionTimeoutSeconds, 25);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("isRetryableHttpError", () => {
|
|
66
|
+
it("When GraphQL times out, it should retry", () => {
|
|
67
|
+
assert.equal(
|
|
68
|
+
isRetryableHttpError(
|
|
69
|
+
{ code: "ECONNABORTED", message: "timeout of 30000ms exceeded" },
|
|
70
|
+
"graphql",
|
|
71
|
+
),
|
|
72
|
+
true,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("When GraphQL returns 503, it should retry", () => {
|
|
77
|
+
assert.equal(
|
|
78
|
+
isRetryableHttpError({ response: { status: 503 } }, "graphql"),
|
|
79
|
+
true,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("When Zauru GET times out, it should retry", () => {
|
|
84
|
+
assert.equal(
|
|
85
|
+
isRetryableHttpError(
|
|
86
|
+
{ code: "ETIMEDOUT", config: { method: "get" } },
|
|
87
|
+
"zauru",
|
|
88
|
+
),
|
|
89
|
+
true,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("When Zauru POST times out, it should not retry", () => {
|
|
94
|
+
assert.equal(
|
|
95
|
+
isRetryableHttpError(
|
|
96
|
+
{ code: "ECONNABORTED", config: { method: "post" } },
|
|
97
|
+
"zauru",
|
|
98
|
+
),
|
|
99
|
+
false,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("When Zauru POST gets 422, it should not retry", () => {
|
|
104
|
+
assert.equal(
|
|
105
|
+
isRetryableHttpError(
|
|
106
|
+
{ response: { status: 422 }, config: { method: "post" } },
|
|
107
|
+
"zauru",
|
|
108
|
+
),
|
|
109
|
+
false,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("handlePossibleAxiosErrors", () => {
|
|
115
|
+
it("When the action timeout is disabled, it should wait for a slow callback", async () => {
|
|
116
|
+
const result = await runWithHttpClientPolicy(
|
|
117
|
+
{ actionTimeoutSeconds: false },
|
|
118
|
+
() =>
|
|
119
|
+
handlePossibleAxiosErrors(async () => {
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
121
|
+
return "ok";
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
assert.equal(result.error, false);
|
|
125
|
+
assert.equal(result.data, "ok");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("When the action exceeds the timeout, it should return Timeout skipped", async () => {
|
|
129
|
+
const result = await handlePossibleAxiosErrors(async () => {
|
|
130
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
131
|
+
return "ok";
|
|
132
|
+
}, 0.05);
|
|
133
|
+
assert.equal(result.error, true);
|
|
134
|
+
assert.match(result.userMsg ?? "", /Timeout skipped after 0.05 seconds/);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeouts and retries for outbound HTTP (Zauru REST + Hasura GraphQL).
|
|
3
|
+
*
|
|
4
|
+
* Defaults keep the 25s action budget of Netlify sync functions. Background
|
|
5
|
+
* lambdas opt into {@link BACKGROUND_HTTP_CLIENT_POLICY}: per-request max of
|
|
6
|
+
* 30s (Heroku router H12 and Hasura `HASURA_GRAPHQL_SERVER_TIMEOUT` default)
|
|
7
|
+
* and one retry. The 25s `handlePossibleAxiosErrors` race is disabled there
|
|
8
|
+
* so a slow query is not aborted before the platform itself gives up.
|
|
9
|
+
*
|
|
10
|
+
* Use {@link runWithHttpClientPolicy} / {@link withHttpClientPolicy} around a
|
|
11
|
+
* set of calls, or pass `timeout` on a single axios config to override one
|
|
12
|
+
* request. GraphQL POSTs retry; Zauru writes do not retry on timeout (the
|
|
13
|
+
* server may have committed).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type HttpClientKind = "graphql" | "zauru";
|
|
17
|
+
|
|
18
|
+
export type HttpClientPolicy = {
|
|
19
|
+
/**
|
|
20
|
+
* Budget for the whole `handlePossibleAxiosErrors` callback.
|
|
21
|
+
* `false` disables that race so each HTTP call uses its own timeout.
|
|
22
|
+
*/
|
|
23
|
+
actionTimeoutSeconds?: number | false;
|
|
24
|
+
zauruTimeoutSeconds?: number;
|
|
25
|
+
graphqlTimeoutSeconds?: number;
|
|
26
|
+
/** Extra attempts after the first failure. `1` = retry once. */
|
|
27
|
+
retries?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ResolvedHttpClientPolicy = {
|
|
31
|
+
actionTimeoutSeconds: number | false;
|
|
32
|
+
zauruTimeoutSeconds: number;
|
|
33
|
+
graphqlTimeoutSeconds: number;
|
|
34
|
+
retries: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type HttpErrorLike = {
|
|
38
|
+
code?: string;
|
|
39
|
+
message?: string;
|
|
40
|
+
response?: { status?: number };
|
|
41
|
+
config?: { method?: string };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_ACTION_TIMEOUT_SECONDS = 25;
|
|
45
|
+
export const DEFAULT_HTTP_TIMEOUT_SECONDS = 200;
|
|
46
|
+
export const HEROKU_ROUTER_TIMEOUT_SECONDS = 30;
|
|
47
|
+
export const HASURA_SERVER_TIMEOUT_SECONDS = 30;
|
|
48
|
+
|
|
49
|
+
export const DEFAULT_HTTP_CLIENT_POLICY: ResolvedHttpClientPolicy = {
|
|
50
|
+
actionTimeoutSeconds: DEFAULT_ACTION_TIMEOUT_SECONDS,
|
|
51
|
+
zauruTimeoutSeconds: DEFAULT_HTTP_TIMEOUT_SECONDS,
|
|
52
|
+
graphqlTimeoutSeconds: DEFAULT_HTTP_TIMEOUT_SECONDS,
|
|
53
|
+
retries: 0,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const BACKGROUND_HTTP_CLIENT_POLICY: HttpClientPolicy = {
|
|
57
|
+
actionTimeoutSeconds: false,
|
|
58
|
+
zauruTimeoutSeconds: HEROKU_ROUTER_TIMEOUT_SECONDS,
|
|
59
|
+
graphqlTimeoutSeconds: HASURA_SERVER_TIMEOUT_SECONDS,
|
|
60
|
+
retries: 1,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const RETRYABLE_STATUS = new Set([408, 429, 502, 503, 504]);
|
|
64
|
+
const RETRYABLE_NETWORK_CODES = new Set([
|
|
65
|
+
"ECONNRESET",
|
|
66
|
+
"ECONNABORTED",
|
|
67
|
+
"ETIMEDOUT",
|
|
68
|
+
"ENOTFOUND",
|
|
69
|
+
"EAI_AGAIN",
|
|
70
|
+
"ERR_NETWORK",
|
|
71
|
+
"ERR_CANCELED",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
export function mergeHttpClientPolicies(
|
|
75
|
+
base: HttpClientPolicy | undefined,
|
|
76
|
+
override: HttpClientPolicy = {},
|
|
77
|
+
): ResolvedHttpClientPolicy {
|
|
78
|
+
return {
|
|
79
|
+
...DEFAULT_HTTP_CLIENT_POLICY,
|
|
80
|
+
...base,
|
|
81
|
+
...override,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type PolicyRuntime = {
|
|
86
|
+
get: () => HttpClientPolicy | undefined;
|
|
87
|
+
run: <T>(policy: ResolvedHttpClientPolicy, fn: () => T) => T;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
let fallbackStore: HttpClientPolicy | undefined;
|
|
91
|
+
|
|
92
|
+
function isThenable(value: unknown): value is Promise<unknown> {
|
|
93
|
+
return (
|
|
94
|
+
typeof value === "object" &&
|
|
95
|
+
value !== null &&
|
|
96
|
+
typeof (value as { then?: unknown }).then === "function"
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function runWithFallbackStore<T>(
|
|
101
|
+
policy: ResolvedHttpClientPolicy,
|
|
102
|
+
fn: () => T,
|
|
103
|
+
): T {
|
|
104
|
+
const previous = fallbackStore;
|
|
105
|
+
fallbackStore = policy;
|
|
106
|
+
try {
|
|
107
|
+
const result = fn();
|
|
108
|
+
if (isThenable(result)) {
|
|
109
|
+
return result.finally(() => {
|
|
110
|
+
fallbackStore = previous;
|
|
111
|
+
}) as T;
|
|
112
|
+
}
|
|
113
|
+
fallbackStore = previous;
|
|
114
|
+
return result;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
fallbackStore = previous;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const fallbackRuntime: PolicyRuntime = {
|
|
122
|
+
get: () => fallbackStore,
|
|
123
|
+
run: runWithFallbackStore,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
let runtime: PolicyRuntime = fallbackRuntime;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Services bind AsyncLocalStorage here so concurrent serverless invocations
|
|
130
|
+
* do not leak policy. Common stays browser-safe (no `node:async_hooks`).
|
|
131
|
+
*/
|
|
132
|
+
export function bindHttpClientPolicyRuntime(next: PolicyRuntime): void {
|
|
133
|
+
runtime = next;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getHttpClientPolicy(): ResolvedHttpClientPolicy {
|
|
137
|
+
return mergeHttpClientPolicies(undefined, runtime.get() ?? {});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function runWithHttpClientPolicy<T>(
|
|
141
|
+
policy: HttpClientPolicy,
|
|
142
|
+
fn: () => T,
|
|
143
|
+
): T {
|
|
144
|
+
const merged = mergeHttpClientPolicies(runtime.get(), policy);
|
|
145
|
+
return runtime.run(merged, fn);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function withHttpClientPolicy<Args extends unknown[], Result>(
|
|
149
|
+
policy: HttpClientPolicy,
|
|
150
|
+
fn: (...args: Args) => Result,
|
|
151
|
+
): (...args: Args) => Result {
|
|
152
|
+
return (...args: Args) =>
|
|
153
|
+
runWithHttpClientPolicy(policy, () => fn(...args));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isTimeoutError(error: HttpErrorLike): boolean {
|
|
157
|
+
const code = error.code ?? "";
|
|
158
|
+
if (code === "ECONNABORTED" || code === "ETIMEDOUT") {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
return /timeout/i.test(error.message ?? "");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isTransientFailure(error: HttpErrorLike): boolean {
|
|
165
|
+
const status = error.response?.status;
|
|
166
|
+
if (status != null) {
|
|
167
|
+
return RETRYABLE_STATUS.has(status);
|
|
168
|
+
}
|
|
169
|
+
const code = error.code ?? "";
|
|
170
|
+
return isTimeoutError(error) || RETRYABLE_NETWORK_CODES.has(code);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isRetryableZauruError(error: HttpErrorLike): boolean {
|
|
174
|
+
const method = (error.config?.method ?? "get").toLowerCase();
|
|
175
|
+
const idempotent =
|
|
176
|
+
method === "get" || method === "head" || method === "options";
|
|
177
|
+
if (idempotent) {
|
|
178
|
+
return isTransientFailure(error);
|
|
179
|
+
}
|
|
180
|
+
if (error.response?.status != null || isTimeoutError(error)) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
return RETRYABLE_NETWORK_CODES.has(error.code ?? "");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function isRetryableHttpError(
|
|
187
|
+
error: HttpErrorLike,
|
|
188
|
+
client: HttpClientKind,
|
|
189
|
+
): boolean {
|
|
190
|
+
switch (client) {
|
|
191
|
+
case "graphql":
|
|
192
|
+
return isTransientFailure(error);
|
|
193
|
+
case "zauru":
|
|
194
|
+
return isRetryableZauruError(error);
|
|
195
|
+
default: {
|
|
196
|
+
const _never: never = client;
|
|
197
|
+
return _never;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function httpTimeoutMsForClient(
|
|
203
|
+
client: HttpClientKind,
|
|
204
|
+
policy: ResolvedHttpClientPolicy = getHttpClientPolicy(),
|
|
205
|
+
): number {
|
|
206
|
+
switch (client) {
|
|
207
|
+
case "graphql":
|
|
208
|
+
return policy.graphqlTimeoutSeconds * 1000;
|
|
209
|
+
case "zauru":
|
|
210
|
+
return policy.zauruTimeoutSeconds * 1000;
|
|
211
|
+
default: {
|
|
212
|
+
const _never: never = client;
|
|
213
|
+
return _never;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
package/src/index.ts
CHANGED