@coraltravelcenter/b2c-landing-builder 2.7.0 → 2.7.1

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/README.md CHANGED
@@ -38,6 +38,9 @@ b2c-landing-vite update
38
38
  скрытый пароль через ADFS. На диск сохраняется только токен (не пароль), токен
39
39
  автоматически используется до истечения. Для CI можно передать
40
40
  `B2C_BACKOFFICE_TOKEN`.
41
+ - Backoffice-запросы ограничены 30 секундами. Безопасные GET-запросы повторяются
42
+ до двух раз при сетевом сбое, timeout, HTTP 429, 502, 503 или 504. Изменяющие
43
+ CMS запросы автоматически не повторяются, чтобы не создать дубликаты.
41
44
  - `deploy` при первом запуске предлагает выбрать application, layout и HTML-зону,
42
45
  создаёт CMS-страницу и размещает блоки в порядке `order.json`. При повторном
43
46
  запуске для опубликованной страницы создаётся checkout-версия.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.7.0",
3
+ "version": "2.7.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@coraltravelcenter/b2c-landing-builder",
9
- "version": "2.7.0",
9
+ "version": "2.7.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clack/prompts": "1.7.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coraltravelcenter/b2c-landing-builder",
3
- "version": "2.7.0",
3
+ "version": "2.7.1",
4
4
  "description": "CLI and build toolkit for B2C landing projects",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,15 +4,62 @@ import path from "node:path";
4
4
  export const RU_LANGUAGE_ID = "1049";
5
5
  export const HTML_WIDGET_ID = "d400b0e7-918d-4d4c-bea3-8522253b6544";
6
6
  export const STATUS = Object.freeze({published: 1, checkout: 2, unpublished: 4});
7
+ const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
8
+
9
+ export class BackofficeRequestError extends Error {
10
+ constructor(message, {code, status, method, endpoint, retryable = false, cause} = {}) {
11
+ super(message, {cause});
12
+ this.name = "BackofficeRequestError";
13
+ this.code = code;
14
+ this.status = status;
15
+ this.method = method;
16
+ this.endpoint = endpoint;
17
+ this.retryable = retryable;
18
+ }
19
+ }
20
+
21
+ function errorCode(status) {
22
+ if (status === 400 || status === 422) return "VALIDATION";
23
+ if (status === 401 || status === 403) return "AUTH";
24
+ if (status === 404) return "NOT_FOUND";
25
+ if (status === 429) return "RATE_LIMIT";
26
+ if (status >= 500) return "SERVER";
27
+ return "HTTP";
28
+ }
29
+
30
+ function retryDelay(response, attempt) {
31
+ const header = response?.headers.get("retry-after");
32
+ if (header) {
33
+ const seconds = Number(header);
34
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
35
+ const timestamp = Date.parse(header);
36
+ if (Number.isFinite(timestamp)) return Math.max(0, timestamp - Date.now());
37
+ }
38
+ return 500 * (2 ** attempt);
39
+ }
40
+
41
+ const defaultSleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
7
42
 
8
43
  export class BackofficeClient {
9
- constructor({brand, token, fetchImpl = globalThis.fetch}) {
44
+ constructor({
45
+ brand,
46
+ token,
47
+ fetchImpl = globalThis.fetch,
48
+ timeoutMs = 30_000,
49
+ maxRetries = 2,
50
+ sleep = defaultSleep,
51
+ log = console.warn,
52
+ }) {
10
53
  if (!new Set(["coral", "sunmar"]).has(brand)) throw new Error(`Unsupported backoffice brand: ${brand}`);
11
54
  if (!token) throw new Error("Backoffice token is required");
12
55
  if (typeof fetchImpl !== "function") throw new Error("Fetch API is unavailable");
13
56
  this.baseUrl = `https://b2capi.${brand}.ru/BackOffice`;
14
57
  this.token = token;
15
58
  this.fetchImpl = fetchImpl;
59
+ this.timeoutMs = timeoutMs;
60
+ this.maxRetries = maxRetries;
61
+ this.sleep = sleep;
62
+ this.log = log;
16
63
  }
17
64
 
18
65
  async request(method, endpoint, {query, body, form} = {}) {
@@ -20,17 +67,53 @@ export class BackofficeClient {
20
67
  for (const [key, value] of Object.entries(query || {})) url.searchParams.set(key, value);
21
68
  const headers = {token: this.token, authorization: `Bearer ${this.token}`};
22
69
  if (body !== undefined) headers["content-type"] = "application/json";
23
- const response = await this.fetchImpl(url, {
24
- method,
25
- headers,
26
- body: form || (body === undefined ? undefined : JSON.stringify(body)),
27
- });
28
- if (!response.ok) {
29
- const detail = await response.text().catch(() => "");
30
- throw new Error(`Backoffice ${method} ${endpoint} failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
70
+ const canRetry = method === "GET";
71
+ for (let attempt = 0; ; attempt += 1) {
72
+ let response;
73
+ try {
74
+ response = await this.fetchImpl(url, {
75
+ method,
76
+ headers,
77
+ body: form || (body === undefined ? undefined : JSON.stringify(body)),
78
+ signal: AbortSignal.timeout(this.timeoutMs),
79
+ });
80
+ } catch (cause) {
81
+ const timeout = cause?.name === "TimeoutError" || cause?.name === "AbortError";
82
+ const retryable = canRetry && attempt < this.maxRetries;
83
+ if (retryable) {
84
+ const delay = 500 * (2 ** attempt);
85
+ this.log(`[backoffice] ${timeout ? "timeout" : "network error"}; retrying GET ${endpoint} in ${delay}ms`);
86
+ await this.sleep(delay);
87
+ continue;
88
+ }
89
+ throw new BackofficeRequestError(
90
+ `Backoffice ${method} ${endpoint} ${timeout ? `timed out after ${this.timeoutMs}ms` : `failed: ${cause?.message || "network error"}`}`,
91
+ {code: timeout ? "TIMEOUT" : "NETWORK", method, endpoint, retryable: canRetry, cause}
92
+ );
93
+ }
94
+ if (!response.ok) {
95
+ const detail = await response.text().catch(() => "");
96
+ const retryableStatus = RETRYABLE_STATUSES.has(response.status);
97
+ if (canRetry && retryableStatus && attempt < this.maxRetries) {
98
+ const delay = retryDelay(response, attempt);
99
+ this.log(`[backoffice] HTTP ${response.status}; retrying GET ${endpoint} in ${delay}ms`);
100
+ await this.sleep(delay);
101
+ continue;
102
+ }
103
+ throw new BackofficeRequestError(
104
+ `Backoffice ${method} ${endpoint} failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`,
105
+ {
106
+ code: errorCode(response.status),
107
+ status: response.status,
108
+ method,
109
+ endpoint,
110
+ retryable: canRetry && retryableStatus,
111
+ }
112
+ );
113
+ }
114
+ const payload = await response.json();
115
+ return payload?.result;
31
116
  }
32
- const payload = await response.json();
33
- return payload?.result;
34
117
  }
35
118
 
36
119
  getContent(contentId) {