@andrey4emk/npm-app-back-b24 3.8.2 → 4.0.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/README.md +165 -13
- package/bitrix24/b24/config.ts +24 -0
- package/bitrix24/b24/facade.ts +371 -0
- package/bitrix24/b24/instance.ts +186 -0
- package/bitrix24/b24/proxy.ts +120 -0
- package/bitrix24/b24/retry.ts +490 -0
- package/bitrix24/b24/state.ts +39 -0
- package/bitrix24/b24/tokens.ts +280 -0
- package/bitrix24/b24/types.ts +93 -0
- package/bitrix24/b24.ts +97 -955
- package/bitrix24/errTaskB24.ts +8 -5
- package/bitrix24/eventB24.ts +301 -63
- package/index.ts +4 -1
- package/package.json +26 -7
- package/sendMessage/chatApp.ts +101 -24
- package/sendMessage/email.ts +233 -32
- package/sendMessage/smsgold.ts +11 -2
- package/sendMessage/wappi.ts +41 -15
- package/utils/fetchRetry.ts +70 -9
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { B24OAuth } from "@bitrix24/b24jssdk";
|
|
2
|
+
import { createFacade } from "./facade.ts";
|
|
3
|
+
import { withRetry } from "./retry.ts";
|
|
4
|
+
import type { AsyncFn } from "./retry.ts";
|
|
5
|
+
import type { B24Client, FacadeMethods } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
// ==================== Константы и типы ====================
|
|
8
|
+
|
|
9
|
+
/** Имя метода, который Proxy резолвит в реализацию пакета */
|
|
10
|
+
type FacadeMethodName = keyof FacadeMethods;
|
|
11
|
+
|
|
12
|
+
/** Методы фасада, оборачиваемые retry-логикой (fetchListMethod — async-генератор, его оборачивать нельзя) */
|
|
13
|
+
const RETRYABLE_METHOD_NAMES = ["callMethod", "callListMethod", "callBatch"] as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Имена, которые Proxy резолвит в собственную реализацию пакета поверх `actions.v2.*`.
|
|
17
|
+
*
|
|
18
|
+
* Шире, чем RETRYABLE_METHOD_NAMES: `fetchListMethod` и `callBatchByChunk` мы реализуем,
|
|
19
|
+
* но не ретраим. `callBatchByChunk` в наборе обязателен — пока хоть один deprecated-метод
|
|
20
|
+
* SDK достижим через `$b24`, он будет писать предупреждение об устаревании.
|
|
21
|
+
*
|
|
22
|
+
* `satisfies` ловит опечатку и переименование: имя, которого нет среди методов фасада,
|
|
23
|
+
* уронит тайпчек здесь, а не молча уедет мимо Proxy в SDK.
|
|
24
|
+
*/
|
|
25
|
+
const FACADE_METHOD_NAMES = ["callMethod", "callListMethod", "fetchListMethod", "callBatch", "callBatchByChunk"] as const satisfies readonly FacadeMethodName[];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Обратная страховка к `satisfies` выше: ловит НЕдостачу.
|
|
29
|
+
* Забыли имя — `Exclude` перестанет быть `never`, и тип станет `never`,
|
|
30
|
+
* а `true` в него не присвоится. Значение нигде не используется
|
|
31
|
+
*/
|
|
32
|
+
const _facadeNamesAreExhaustive: Exclude<FacadeMethodName, (typeof FACADE_METHOD_NAMES)[number]> extends never ? true : never = true;
|
|
33
|
+
void _facadeNamesAreExhaustive;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Имя метода фасада, который дополнительно оборачивается retry.
|
|
37
|
+
*
|
|
38
|
+
* Уже, чем `FacadeMethodName`: `fetchListMethod` — async-генератор, он возвращает
|
|
39
|
+
* не `Promise`, под `AsyncFn` не подходит и в `withRetry` не попадает даже по типу.
|
|
40
|
+
* Тип выведен из самого списка, поэтому список и тип разойтись не могут
|
|
41
|
+
*/
|
|
42
|
+
type RetryableMethodName = (typeof RETRYABLE_METHOD_NAMES)[number];
|
|
43
|
+
|
|
44
|
+
/** Тип `ReadonlySet<string>`, а не `Set<RetryableMethodName>`: `has()` вызывается с произвольным ключом Proxy */
|
|
45
|
+
export const RETRYABLE_METHODS: ReadonlySet<string> = new Set<string>(RETRYABLE_METHOD_NAMES);
|
|
46
|
+
|
|
47
|
+
/** См. FACADE_METHOD_NAMES */
|
|
48
|
+
export const FACADE_METHODS: ReadonlySet<string> = new Set<string>(FACADE_METHOD_NAMES);
|
|
49
|
+
|
|
50
|
+
/** Метод резолвится в реализацию пакета, а не SDK */
|
|
51
|
+
function isFacadeMethod(prop: string): prop is FacadeMethodName {
|
|
52
|
+
return FACADE_METHODS.has(prop);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Метод фасада оборачивается retry-логикой */
|
|
56
|
+
function isRetryableMethod(prop: string): prop is RetryableMethodName {
|
|
57
|
+
return RETRYABLE_METHODS.has(prop);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ==================== Proxy-обёртка ====================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Proxy-обёртка вокруг B24OAuth.
|
|
64
|
+
*
|
|
65
|
+
* Имена из FACADE_METHODS резолвятся в собственные реализации пакета поверх
|
|
66
|
+
* `actions.v2.*`; из них методы RETRYABLE_METHODS дополнительно оборачиваются
|
|
67
|
+
* retry-логикой. Все остальные методы привязываются к оригинальному объекту
|
|
68
|
+
* через bind: класс B24OAuth использует приватные поля (#authOAuthManager),
|
|
69
|
+
* и при вызове метода с this === Proxy движок бросает "Cannot read private member".
|
|
70
|
+
*
|
|
71
|
+
* Обёртки кешируются — по одной на метод, чтобы не ломать сравнение по ссылке.
|
|
72
|
+
*/
|
|
73
|
+
export function wrapB24WithRetry(b24: B24OAuth): B24Client {
|
|
74
|
+
const methodCache = new Map<string, Function>();
|
|
75
|
+
// Фасад создаётся один раз на экземпляр: он замкнут на конкретный target,
|
|
76
|
+
// а пересоздание на каждом обращении ломало бы кеш и сравнение по ссылке
|
|
77
|
+
const facade = createFacade(b24);
|
|
78
|
+
|
|
79
|
+
return new Proxy(b24, {
|
|
80
|
+
get(target, prop) {
|
|
81
|
+
// Имена фасада проверяем до Reflect.get: пока SDK ещё объявляет свои
|
|
82
|
+
// deprecated-методы, иначе мы отдавали бы их, а не свои
|
|
83
|
+
if (typeof prop === "string" && isFacadeMethod(prop)) {
|
|
84
|
+
if (!methodCache.has(prop)) {
|
|
85
|
+
// Индексируем внутри ветки: после сужения prop до RetryableMethodName
|
|
86
|
+
// из типа facade[prop] уходит fetchListMethod, и withRetry принимает
|
|
87
|
+
// остаток без приведения
|
|
88
|
+
methodCache.set(prop, isRetryableMethod(prop) ? withRetry(facade[prop], target, prop) : facade[prop]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return methodCache.get(prop);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Передаём target третьим аргументом: геттеры (например, auth)
|
|
95
|
+
// тоже должны исполняться с this === target
|
|
96
|
+
const value = Reflect.get(target, prop, target);
|
|
97
|
+
|
|
98
|
+
if (typeof prop !== "string" || typeof value !== "function") {
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!methodCache.has(prop)) {
|
|
103
|
+
const wrapped = RETRYABLE_METHODS.has(prop)
|
|
104
|
+
? withRetry(value as AsyncFn, target, prop)
|
|
105
|
+
: value.bind(target);
|
|
106
|
+
methodCache.set(prop, wrapped);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return methodCache.get(prop);
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
// Страховка на будущее: когда SDK уберёт методы из прототипа,
|
|
113
|
+
// "callMethod" in $b24 обязано остаться истинным (eventB24.ts проверяет
|
|
114
|
+
// наличие метода перед работой). Цель расширяема, лишние true законны
|
|
115
|
+
has(target, prop) {
|
|
116
|
+
if (typeof prop === "string" && FACADE_METHODS.has(prop)) return true;
|
|
117
|
+
return Reflect.has(target, prop);
|
|
118
|
+
},
|
|
119
|
+
}) as B24Client;
|
|
120
|
+
}
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { AjaxError, RefreshTokenError, SdkError } from "@bitrix24/b24jssdk";
|
|
2
|
+
import { logs } from "../../logs/logs.ts";
|
|
3
|
+
import { isNetworkError, isPreConnectionError } from "../../utils/fetchRetry.ts";
|
|
4
|
+
|
|
5
|
+
// ==================== Типы ====================
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Любая асинхронная функция.
|
|
9
|
+
*
|
|
10
|
+
* `never[]` в параметрах делает тип надмножеством всех сигнатур: `never`
|
|
11
|
+
* присваивается чему угодно, поэтому под ограничение подходит и `callMethod`,
|
|
12
|
+
* и `callBatch`. Возврат `Promise<unknown>` отсекает `fetchListMethod` —
|
|
13
|
+
* это async-генератор, и оборачивать его retry-обёрткой нельзя (`for await` ломается).
|
|
14
|
+
*/
|
|
15
|
+
export type AsyncFn = (...args: never[]) => Promise<unknown>;
|
|
16
|
+
|
|
17
|
+
// ==================== Константы ====================
|
|
18
|
+
|
|
19
|
+
/** Количество попыток при сетевых ошибках */
|
|
20
|
+
export const RETRY_COUNT = 5;
|
|
21
|
+
/** Задержка между попытками (мс) */
|
|
22
|
+
export const RETRY_DELAY_MS = 500;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Коды транспортного сбоя. Один набор и для SdkError (AjaxError, RefreshTokenError),
|
|
26
|
+
* и для вложенного AxiosError — списки совпадали, держать их раздельно смысла нет.
|
|
27
|
+
*/
|
|
28
|
+
export const NETWORK_ERROR_CODES = new Set(["NETWORK_ERROR", "REQUEST_TIMEOUT", "ERR_NETWORK", "ECONNABORTED"]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* REST-методы B24, повтор которых создаёт дубликат сущности или запускает
|
|
32
|
+
* повторное действие: add, create, start, send, uploadfile, import, register.
|
|
33
|
+
*
|
|
34
|
+
* Транспортная ошибка не говорит, дошёл ли запрос до портала: обрыв приходит
|
|
35
|
+
* и когда соединение не состоялось, и когда оно оборвалось после того, как портал уже
|
|
36
|
+
* принял и выполнил запрос. Во втором случае retry создаст вторую задачу, второй файл,
|
|
37
|
+
* второй экземпляр бизнес-процесса. Идемпотентные записи (update, delete, set) повторять
|
|
38
|
+
* безопасно: повторное применение даёт то же состояние. При доказанном pre-connection
|
|
39
|
+
* (isPreConnectionError) ограничение снимается: соединение с целевым хостом не состоялось,
|
|
40
|
+
* портал запроса не видел, и дубликата повтор дать не может.
|
|
41
|
+
*
|
|
42
|
+
* По той же причине у SDK отключены собственные повторы транспортных сбоев
|
|
43
|
+
* (SDK_RESTRICTION_PARAMS) — он этой разницы не знает вовсе.
|
|
44
|
+
*
|
|
45
|
+
* Ключевое слово ловится **в любой позиции** имени, а не только в конце. Иначе мимо
|
|
46
|
+
* гейта проходил `imconnector.send.messages` (дефект T-48) — метод создаёт в открытой
|
|
47
|
+
* линии сообщение внешней системы. Дедупликация по `message.id` в документации портала
|
|
48
|
+
* не описана, поэтому повтор считаем задваивающим переписку у оператора — fail-closed.
|
|
49
|
+
* После ключевого слова обязателен разделитель (`.`, `?` или конец строки), поэтому
|
|
50
|
+
* `crm.deal.addcustom`, `landing.landing.addbytemplate` и `tasks.task.result.addresult`
|
|
51
|
+
* по-прежнему повторяются. Отдельная группа `(\.json)?` больше не нужна: хвост `.json`
|
|
52
|
+
* покрывается общей веткой `\.`.
|
|
53
|
+
*
|
|
54
|
+
* Побочный улов расширения — `imconnector.send.status.delivery`: он лишь помечает уже
|
|
55
|
+
* отправленное сообщение доставленным и, скорее всего, идемпотентен. Повторяться он
|
|
56
|
+
* перестанет, и это осознанная плата за правило без списка исключений: потеря отметки
|
|
57
|
+
* о доставке косметическая, переписку она не ломает, а ручной список молча устаревал бы
|
|
58
|
+
* при появлении нового метода портала — ровно тот дефект, которым T-48 и был.
|
|
59
|
+
*/
|
|
60
|
+
export const NON_IDEMPOTENT_METHOD_RE = /\.(add|create|start|send|uploadfile|import|register)(\.|\?|$)/i;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Методы, повтор которых теряет данные, хотя сущностей не создаёт.
|
|
64
|
+
* Совпадение точное, по имени целиком (после нормализации), а не по префиксу.
|
|
65
|
+
*
|
|
66
|
+
* `event.offline.get` резервирует пакет очереди офлайн-событий и прячет его от следующих
|
|
67
|
+
* запросов. Если ответ первой попытки потерян по дороге, `process_id` пакета не узнает
|
|
68
|
+
* никто: записи висят зарезервированными до автоудаления через 30 дней и повторно
|
|
69
|
+
* не выдаются.
|
|
70
|
+
*
|
|
71
|
+
* Ограничение действует независимо от параметра `clear`, и вот почему:
|
|
72
|
+
*
|
|
73
|
+
* 1. при `clear: 0` первый вызов резервирует пакет и прячет его от следующих запросов —
|
|
74
|
+
* потерянный ответ уносит `process_id` с собой;
|
|
75
|
+
* 2. при `clear: 1` портал отдаёт записи и тут же удаляет их — потерянный ответ уносит
|
|
76
|
+
* их безвозвратно, то есть повтор так же теряет пакет, только иначе;
|
|
77
|
+
* 3. разбор по параметрам означал бы чтение `args[1]` в гейте, который смотрит только
|
|
78
|
+
* на имя, — лишняя сложность ради ветки, где обе стороны одинаково плохи;
|
|
79
|
+
* 4. fail-closed дешевле: единственный потребитель опрашивает очередь по таймеру,
|
|
80
|
+
* следующий опрос всё равно будет.
|
|
81
|
+
*
|
|
82
|
+
* Чего правка **не** делает: она не спасает первый потерянный пакет — он уже
|
|
83
|
+
* зарезервирован. Она предотвращает вторую потерю, то есть повтор, который зарезервировал
|
|
84
|
+
* бы ещё один пакет поверх первого. Ограничение действует только на слой пакета:
|
|
85
|
+
* собственный цикл повторов SDK (429, 503, неизвестные 5xx) под него не попадает —
|
|
86
|
+
* при 503 лимитер считает ответ временным отказом раньше, чем смотрит на hardErrorCodes,
|
|
87
|
+
* и делает свои три запроса, см. T-44.
|
|
88
|
+
*
|
|
89
|
+
* При доказанном pre-connection (соединение не состоялось) ограничение снимается вместе
|
|
90
|
+
* с гейтом идемпотентности: портал запроса не видел, резервировать было нечего.
|
|
91
|
+
*/
|
|
92
|
+
export const NON_RETRYABLE_METHODS = new Set(["event.offline.get"]);
|
|
93
|
+
|
|
94
|
+
/** Префикс кодов, которыми SDK помечает собственные отказы обмена refresh-токена */
|
|
95
|
+
const OAUTH_REFRESH_CODE_PREFIX = "JSSDK_OAUTH_TOKEN_REFRESH";
|
|
96
|
+
|
|
97
|
+
// ==================== Утилиты ====================
|
|
98
|
+
|
|
99
|
+
/** Задержка на указанное количество миллисекунд */
|
|
100
|
+
export const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Приводит имя REST-метода к виду, пригодному для точного сравнения:
|
|
104
|
+
* без пробелов по краям, без регистра, без хвоста `?query` и без расширения `.json`.
|
|
105
|
+
*
|
|
106
|
+
* Нужна списку NON_RETRYABLE_METHODS: он сравнивает имя целиком, а до него имя доезжает
|
|
107
|
+
* в любой из форм — `event.offline.get`, `Event.Offline.Get`, `event.offline.get.json`,
|
|
108
|
+
* `event.offline.get?clear=0`.
|
|
109
|
+
*/
|
|
110
|
+
function normalizeMethodName(raw: string): string {
|
|
111
|
+
// noUncheckedIndexedAccess: индексация массива даёт string | undefined
|
|
112
|
+
const head = raw.trim().toLowerCase().split("?")[0] ?? "";
|
|
113
|
+
return head.replace(/\.json$/, "");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Фраза для лога: повтор отменён, потому что вызов создаёт сущности */
|
|
117
|
+
const creatingReason = (what: string): string => `вызов создаёт сущности (${what})`;
|
|
118
|
+
|
|
119
|
+
/** Фраза для лога: повтор отменён, потому что вызов резервирует данные на портале */
|
|
120
|
+
const reservingReason = (what: string): string => `вызов резервирует данные на портале (${what})`;
|
|
121
|
+
|
|
122
|
+
// ==================== Retry-обёртка ====================
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Проверяет, является ли ошибка из SDK сетевой.
|
|
126
|
+
*
|
|
127
|
+
* Форма транспортного сбоя в Node: до нас доезжает **сырой errno** — ENOTFOUND,
|
|
128
|
+
* ECONNREFUSED, ECONNRESET со status 0. Ветки NETWORK_ERROR и REQUEST_TIMEOUT в SDK
|
|
129
|
+
* рассчитаны на другое: первая подставляется только при axios-коде ERR_NETWORK
|
|
130
|
+
* (браузерный адаптер), вторая — при ECONNABORTED. Node-адаптер зовёт
|
|
131
|
+
* `AxiosError.from(err, null, ...)`, и код остаётся исходным errno. Держим оба набора:
|
|
132
|
+
* сырые коды ловит последняя строка через isNetworkError(), а именованные — набор
|
|
133
|
+
* NETWORK_ERROR_CODES.
|
|
134
|
+
*
|
|
135
|
+
* Проверку по status 0 оставляем: она ловит всё, что SDK отдаёт без внятного кода.
|
|
136
|
+
* Сюда же попадает провал обновления токена внутри callMethod: SDK перезаворачивает
|
|
137
|
+
* SdkError в AjaxError с кодом JSSDK_UNKNOWN_ERROR и status 0, а исходную ошибку кладёт
|
|
138
|
+
* в originalError. RefreshTokenError приходит как SdkError с кодом вроде ENOTFOUND;
|
|
139
|
+
* при status 500 такая ошибка признаётся сетевой не веткой status === 0, а последней
|
|
140
|
+
* строкой — код есть в списке utils/fetchRetry.ts.
|
|
141
|
+
*
|
|
142
|
+
* Сетевой провал обмена токена остаётся, а повторяться перестаёт: решение принимает
|
|
143
|
+
* runWithRetry через isTokenRefreshFailure(). Классификация «сетевая ли ошибка»
|
|
144
|
+
* и решение «повторять ли её» — разные вопросы, и смешивать их здесь нельзя: иначе
|
|
145
|
+
* ошибка ушла бы наружу первой веткой цикла, без единой строки в логе.
|
|
146
|
+
*/
|
|
147
|
+
export function isB24NetworkError(error: unknown): boolean {
|
|
148
|
+
if (error instanceof AjaxError) {
|
|
149
|
+
// Практически недостижимо (SDK бросает реальную ошибку раньше),
|
|
150
|
+
// но если код всё же пришёл — SDK уже исчерпал свои попытки
|
|
151
|
+
if (error.code === "JSSDK_CALL_ALL_ATTEMPTS_EXHAUSTED") return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (error instanceof SdkError) {
|
|
155
|
+
const { code, originalError, status } = error;
|
|
156
|
+
const originalCode = (originalError as { code?: string } | undefined)?.code;
|
|
157
|
+
|
|
158
|
+
if (NETWORK_ERROR_CODES.has(code)) return true;
|
|
159
|
+
if (originalError && isNetworkError(originalError)) return true;
|
|
160
|
+
if (originalCode && NETWORK_ERROR_CODES.has(originalCode)) return true;
|
|
161
|
+
if (status === 0) return true;
|
|
162
|
+
|
|
163
|
+
// 502 Bad Gateway: шлюз перед порталом не смог получить ответ от upstream —
|
|
164
|
+
// запрос почти наверняка не выполнялся, повтор осмыслен. Мы держим этот код
|
|
165
|
+
// в SDK_RESTRICTION_PARAMS.hardErrorCodes, чтобы SDK его не повторял, но
|
|
166
|
+
// повторить его должен наш слой: там создающие вызовы отсекает гейт.
|
|
167
|
+
//
|
|
168
|
+
// 504 сюда намеренно не входит, хотя приходит тем же кодом ERR_BAD_RESPONSE:
|
|
169
|
+
// это «портал взял запрос и считает прямо сейчас, шлюз устал ждать». Повтор
|
|
170
|
+
// создающего вызова дал бы дубликат, а читающего — ничего: за таймаут шлюза
|
|
171
|
+
// запрос не уложился один раз, не уложится и на второй, зато пять тяжёлых
|
|
172
|
+
// повторов добавят нагрузки порталу ровно тогда, когда ему и так плохо.
|
|
173
|
+
if (status === 502) return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return isNetworkError(error);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Провалился ли внутри вызова обмен refresh-токена.
|
|
181
|
+
*
|
|
182
|
+
* SDK перезаворачивает такую ошибку в `_convertUnknownErrorToAjaxError`
|
|
183
|
+
* (`core/http/abstract-http.mjs`): RefreshTokenError наследует SdkError, а не AjaxError
|
|
184
|
+
* и не AxiosError, поэтому попадает в ветку «неизвестная ошибка» и **кладётся целиком
|
|
185
|
+
* в originalError**. Снаружи всегда одно и то же — AjaxError JSSDK_UNKNOWN_ERROR
|
|
186
|
+
* со status 0, а исходная причина различима внутри:
|
|
187
|
+
*
|
|
188
|
+
* | Что случилось при обмене | originalError |
|
|
189
|
+
* |---|---|
|
|
190
|
+
* | oauth-хост не резолвится | RefreshTokenError ENOTFOUND, status 0 |
|
|
191
|
+
* | oauth-хост отверг соединение | RefreshTokenError ECONNREFUSED, status 0 |
|
|
192
|
+
* | 400 invalid_grant (мёртвый токен) | RefreshTokenError invalid_grant, status 400 |
|
|
193
|
+
* | 500 от oauth-хоста | RefreshTokenError ERR_BAD_RESPONSE, status 500 |
|
|
194
|
+
* | 200 с полем error в теле | SdkError JSSDK_OAUTH_TOKEN_REFRESH_FAILED, status 0 |
|
|
195
|
+
*
|
|
196
|
+
* Форма одна и та же независимо от того, откуда запустился обмен: после 401 от портала
|
|
197
|
+
* (`_makeRequestWithAuthRetry`) или заранее, из-за протухшего `expires` (`_ensureAuth`).
|
|
198
|
+
* Обе ветки зовут `_refreshAuth()` и бросают в один catch метода `call()`.
|
|
199
|
+
*
|
|
200
|
+
* Две ловушки, из-за которых разбор выглядит именно так:
|
|
201
|
+
*
|
|
202
|
+
* - RefreshTokenError **не переопределяет `name`** — у экземпляра `name === "SdkError"`,
|
|
203
|
+
* и проверка по имени не работает. Работают instanceof и constructor.name;
|
|
204
|
+
* - последняя строка таблицы даёт не RefreshTokenError, а голый SdkError с кодом
|
|
205
|
+
* из семейства JSSDK_OAUTH_TOKEN_REFRESH_* — её ловим отдельно.
|
|
206
|
+
*
|
|
207
|
+
* Запасная проверка по constructor.name нужна на случай двух копий SDK в node_modules
|
|
208
|
+
* у потребителя: instanceof тогда промахнётся. Промах дешёвый — поведение откатится
|
|
209
|
+
* к прежнему (пять попыток и шум в логе), данные не пострадают.
|
|
210
|
+
*
|
|
211
|
+
* @param error — пойманная ошибка
|
|
212
|
+
* @returns true, если внутри лежит отказ обмена refresh-токена
|
|
213
|
+
*/
|
|
214
|
+
export function isTokenRefreshFailure(error: unknown): boolean {
|
|
215
|
+
if (!(error instanceof SdkError)) return false;
|
|
216
|
+
|
|
217
|
+
const { originalError } = error;
|
|
218
|
+
if (!originalError || typeof originalError !== "object") return false;
|
|
219
|
+
|
|
220
|
+
if (originalError instanceof RefreshTokenError) return true;
|
|
221
|
+
if (originalError.constructor?.name === "RefreshTokenError") return true;
|
|
222
|
+
|
|
223
|
+
return originalError instanceof SdkError && originalError.code.startsWith(OAUTH_REFRESH_CODE_PREFIX);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Код исходной ошибки обмена токена со статусом ответа oauth-хоста, если он известен:
|
|
228
|
+
* `invalid_grant/400`, `ERR_BAD_RESPONSE/503`, `ENOTFOUND`,
|
|
229
|
+
* `JSSDK_OAUTH_TOKEN_REFRESH_FAILED`.
|
|
230
|
+
*
|
|
231
|
+
* Нужен лог-строке: код внешней ошибки всегда JSSDK_UNKNOWN_ERROR и для разбора
|
|
232
|
+
* инцидента бесполезен.
|
|
233
|
+
*
|
|
234
|
+
* Статус дописывается потому, что без него один код накрывает разные инциденты:
|
|
235
|
+
* `ERR_BAD_RESPONSE` одинаков для 500 и 503, `ERR_BAD_REQUEST` — для 429 и прочих 4xx.
|
|
236
|
+
* По логу нельзя отличить «токен мёртв, нужна переавторизация» от «oauth-хост занят,
|
|
237
|
+
* пройдёт само», а это первый вопрос дежурного. Доступ законный: `status` у `SdkError` —
|
|
238
|
+
* публичный геттер. При status 0, отсутствующем или нечисловом хвоста нет: ноль SDK
|
|
239
|
+
* ставит транспортному сбою, где статуса ответа не существует.
|
|
240
|
+
*/
|
|
241
|
+
export function refreshFailureCode(error: unknown): string {
|
|
242
|
+
const originalError = error instanceof SdkError ? error.originalError : undefined;
|
|
243
|
+
const code = (originalError as { code?: unknown } | undefined)?.code;
|
|
244
|
+
|
|
245
|
+
if (typeof code !== "string" || code.length === 0) return "код неизвестен";
|
|
246
|
+
|
|
247
|
+
const status = (originalError as { status?: unknown } | undefined)?.status;
|
|
248
|
+
|
|
249
|
+
return typeof status === "number" && Number.isFinite(status) && status > 0 ? `${code}/${status}` : code;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Достаёт имя REST-метода из одной команды batch.
|
|
254
|
+
* Возвращает null, если форма команды не распознана.
|
|
255
|
+
*/
|
|
256
|
+
export function extractBatchCommandMethod(cmd: unknown): string | null {
|
|
257
|
+
// Кортеж ["crm.deal.add", { ... }] — основная форма в SDK 2.x
|
|
258
|
+
if (Array.isArray(cmd)) {
|
|
259
|
+
return typeof cmd[0] === "string" ? cmd[0] : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Объект { method, params }
|
|
263
|
+
if (cmd && typeof cmd === "object") {
|
|
264
|
+
const method = (cmd as { method?: unknown }).method;
|
|
265
|
+
return typeof method === "string" ? method : null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Строка "crm.deal.add?ID=1" — в SDK 2.x эта форма уже не поддерживается
|
|
269
|
+
// (ParseRow бросает JSSDK_INTERACTION_BATCH_ROW_FAIL), разбираем на случай,
|
|
270
|
+
// если потребитель остался на ней со старой версии
|
|
271
|
+
if (typeof cmd === "string") {
|
|
272
|
+
return cmd;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** К какому виду ограничения относится имя метода */
|
|
279
|
+
type MethodRestriction = "creating" | "reserving" | null;
|
|
280
|
+
|
|
281
|
+
/** Классифицирует одно имя REST-метода: создаёт, резервирует либо повторяется свободно */
|
|
282
|
+
function classifyMethod(name: string): MethodRestriction {
|
|
283
|
+
if (NON_RETRYABLE_METHODS.has(normalizeMethodName(name))) return "reserving";
|
|
284
|
+
if (NON_IDEMPOTENT_METHOD_RE.test(name)) return "creating";
|
|
285
|
+
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Собирает итоговую фразу причины из двух списков имён.
|
|
291
|
+
*
|
|
292
|
+
* Порядок фиксирован — сначала создающие, потом резервирующие: лог-строка должна быть
|
|
293
|
+
* одинаковой при одинаковом составе вызова.
|
|
294
|
+
*/
|
|
295
|
+
function joinReasons(creating: string[], reserving: string[]): string | null {
|
|
296
|
+
const phrases: string[] = [];
|
|
297
|
+
|
|
298
|
+
if (creating.length > 0) phrases.push(creatingReason(creating.join(", ")));
|
|
299
|
+
if (reserving.length > 0) phrases.push(reservingReason(reserving.join(", ")));
|
|
300
|
+
|
|
301
|
+
return phrases.length > 0 ? phrases.join("; ") : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Возвращает готовую фразу причины, по которой вызов запрещено повторять, либо null.
|
|
306
|
+
*
|
|
307
|
+
* Функция отдаёт именно фразу, а не имя метода: причин теперь две (вызов создаёт
|
|
308
|
+
* сущности либо резервирует данные на портале), и шаблон в лог-строке соврал бы про
|
|
309
|
+
* `event.offline.get` — он ничего не создаёт. runWithRetry дописывает вокруг только
|
|
310
|
+
* «повтор отменён, ...», поэтому строки для создающих вызовов остались прежними
|
|
311
|
+
* до последнего байта: по ним грепают в логах и на них настроены триггеры задач.
|
|
312
|
+
*
|
|
313
|
+
* Для callBatch действует правило fail-closed: если хоть одну команду разобрать
|
|
314
|
+
* не удалось, вызов считается создающим. Ошибиться в сторону лишней осторожности
|
|
315
|
+
* дешевле — потребитель получит ошибку вместо тихого дубликата.
|
|
316
|
+
*/
|
|
317
|
+
export function getRetryBlockReason(sdkMethod: string, args: readonly unknown[]): string | null {
|
|
318
|
+
const first = args[0];
|
|
319
|
+
|
|
320
|
+
// callMethod(method, params) и callListMethod(method, params, ...)
|
|
321
|
+
if (sdkMethod === "callMethod" || sdkMethod === "callListMethod") {
|
|
322
|
+
if (typeof first !== "string") return null;
|
|
323
|
+
|
|
324
|
+
// В фразу идёт имя, которое реально передали (без пробелов по краям),
|
|
325
|
+
// а не нормализованное: в логе полезнее видеть исходную форму
|
|
326
|
+
const name = first.trim();
|
|
327
|
+
const restriction = classifyMethod(name);
|
|
328
|
+
|
|
329
|
+
if (restriction === "creating") return creatingReason(name);
|
|
330
|
+
if (restriction === "reserving") return reservingReason(name);
|
|
331
|
+
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (sdkMethod !== "callBatch") return null;
|
|
336
|
+
|
|
337
|
+
// callBatch(calls, ...) — команды приходят массивом либо объектом-словарём
|
|
338
|
+
if (!first || typeof first !== "object") {
|
|
339
|
+
return creatingReason("аргументы batch не разобраны");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const commands: unknown[] = Array.isArray(first) ? first : Object.values(first);
|
|
343
|
+
const creating: string[] = [];
|
|
344
|
+
const reserving: string[] = [];
|
|
345
|
+
|
|
346
|
+
for (const cmd of commands) {
|
|
347
|
+
const method = extractBatchCommandMethod(cmd);
|
|
348
|
+
|
|
349
|
+
if (method === null) return creatingReason("команда batch не разобрана");
|
|
350
|
+
|
|
351
|
+
const name = method.trim();
|
|
352
|
+
const restriction = classifyMethod(name);
|
|
353
|
+
|
|
354
|
+
if (restriction === "creating") creating.push(name);
|
|
355
|
+
if (restriction === "reserving") reserving.push(name);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return joinReasons(creating, reserving);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Общий цикл повторов при сетевых ошибках.
|
|
363
|
+
*
|
|
364
|
+
* Из цикла есть три выхода без повтора:
|
|
365
|
+
*
|
|
366
|
+
* 1. ошибка не сетевая — уходит наружу как есть, без записи в лог;
|
|
367
|
+
* 2. провал обмена refresh-токена, не доказанный pre-connection — строка error и бросок:
|
|
368
|
+
* повтор небезопасен, потому что соединение состоялось, сервер мог провести ротацию,
|
|
369
|
+
* и тогда локальный refresh-токен мёртв независимо от повтора. Транзиентные отказы
|
|
370
|
+
* oauth-хоста (429, 503) под правило попадают тоже — различить их от ротации
|
|
371
|
+
* по ответу нельзя;
|
|
372
|
+
* 3. действует ограничение (blockReason) и pre-connection не доказан — строка error
|
|
373
|
+
* и бросок.
|
|
374
|
+
*
|
|
375
|
+
* Доказанный pre-connection (`isPreConnectionError`) снимает ограничение: DNS не
|
|
376
|
+
* разрешился или хост отверг соединение, значит соединение с целевым хостом не состоялось
|
|
377
|
+
* и повтор дубликата создать не может. Это единственное основание повторить то, что
|
|
378
|
+
* повторять вообще-то нельзя, — то же правило действует и в обмене refresh-токена.
|
|
379
|
+
*
|
|
380
|
+
* @param fn — вызов без аргументов, уже замкнутый на нужные параметры
|
|
381
|
+
* @param label — префикс лог-строк, по нему в логе видно источник повтора
|
|
382
|
+
* @param blockReason — готовая фраза причины, по которой повтор запрещён, либо null
|
|
383
|
+
*/
|
|
384
|
+
export async function runWithRetry<T>(fn: () => Promise<T>, label: string, blockReason: string | null): Promise<T> {
|
|
385
|
+
let lastError: unknown;
|
|
386
|
+
|
|
387
|
+
for (let attempt = 1; attempt <= RETRY_COUNT; attempt++) {
|
|
388
|
+
try {
|
|
389
|
+
return await fn();
|
|
390
|
+
} catch (error: unknown) {
|
|
391
|
+
lastError = error;
|
|
392
|
+
|
|
393
|
+
// Ошибка не сетевая (бизнес-логика B24, неверные параметры) — отдаём
|
|
394
|
+
// вызывающему коду как есть, он решает, что с ней делать
|
|
395
|
+
if (!isB24NetworkError(error)) throw error;
|
|
396
|
+
|
|
397
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
398
|
+
const code = error instanceof SdkError ? ` [${error.code}]` : "";
|
|
399
|
+
|
|
400
|
+
// Соединение с целевым хостом не состоялось: повторять безопасно
|
|
401
|
+
// даже создающий вызов
|
|
402
|
+
const proven = isPreConnectionError(error);
|
|
403
|
+
|
|
404
|
+
// Окончательные отказы логируем на error: собственные модули пакета
|
|
405
|
+
// (errorB24, Event, Smsgold) ошибку только возвращают вызывающему коду,
|
|
406
|
+
// но не пишут в лог — без этих строк сбой $b24 не виден нигде
|
|
407
|
+
|
|
408
|
+
// Ветка обмена токена стоит до проверки исчерпания попыток: иначе она
|
|
409
|
+
// сработала бы только на пятой попытке, ради чего всё и затевалось
|
|
410
|
+
if (!proven && isTokenRefreshFailure(error)) {
|
|
411
|
+
logs.add(`${label}${code}: обновление токена не удалось (${refreshFailureCode(error)}), повтор небезопасен: сервер мог провести ротацию — ${msg}`, "error");
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (attempt === RETRY_COUNT) {
|
|
416
|
+
logs.add(`${label}${code}: исчерпаны все ${RETRY_COUNT} попыток — ${msg}`, "error");
|
|
417
|
+
throw error;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Обрыв и таймаут не говорят, дошёл ли запрос до портала: ответ мог
|
|
421
|
+
// потеряться уже после того, как портал его выполнил. Различить нельзя,
|
|
422
|
+
// поэтому вызовы под ограничением не повторяем — лучше вернуть ошибку,
|
|
423
|
+
// чем создать дубликат или потерять зарезервированный пакет
|
|
424
|
+
if (blockReason && !proven) {
|
|
425
|
+
logs.add(`${label}${code}: повтор отменён, ${blockReason} — ${msg}`, "error");
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Начало строки менять нельзя: по «попытка N/5 не удалась» грепают в логах
|
|
430
|
+
const lifted = blockReason ? ` — соединение не состоялось, ограничение (${blockReason}) снято` : "";
|
|
431
|
+
|
|
432
|
+
// Промежуточные попытки — warn: в чат B24 уходит только уровень error,
|
|
433
|
+
// иначе один упавший вызов дал бы пять сообщений
|
|
434
|
+
logs.add(`${label}${code}: попытка ${attempt}/${RETRY_COUNT} не удалась (${msg}), повтор через ${RETRY_DELAY_MS}мс${lifted}`, "warn");
|
|
435
|
+
|
|
436
|
+
await delay(RETRY_DELAY_MS);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Недостижимо при RETRY_COUNT >= 1, нужно для TypeScript
|
|
441
|
+
throw lastError;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Оборачивает async-функцию retry-логикой при сетевых ошибках */
|
|
445
|
+
export function withRetry<T extends AsyncFn>(fn: T, context: object, methodName: string): T {
|
|
446
|
+
return (async (...args: Parameters<T>) => {
|
|
447
|
+
// Состав вызова между попытками не меняется — разбираем один раз
|
|
448
|
+
const blockReason = getRetryBlockReason(methodName, args as readonly unknown[]);
|
|
449
|
+
return runWithRetry(() => fn.apply(context, args), `$b24.${methodName}`, blockReason);
|
|
450
|
+
}) as T;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Прогоняет произвольный вызов `actions.*` через тот же retry и гейт идемпотентности,
|
|
455
|
+
* что и методы фасада. Прямой `$b24.actions.v3.call.make()` идёт мимо защиты — если она
|
|
456
|
+
* нужна (например, ради `FilterV3` или keyset-пагинации), вызов оборачивают этой функцией.
|
|
457
|
+
*
|
|
458
|
+
* @param run — сам вызов, например `() => $b24.actions.v3.call.make({ method, params })`
|
|
459
|
+
* @param methodName — имя REST-метода B24 (не метода SDK) либо список имён, если внутри
|
|
460
|
+
* batch: по ним гейт решает, создаёт ли вызов сущность и не входит ли
|
|
461
|
+
* он в список методов, повтор которых теряет данные
|
|
462
|
+
*
|
|
463
|
+
* @example
|
|
464
|
+
* const response = await callProtected(
|
|
465
|
+
* () => $b24!.actions.v3.call.make({ method: "crm.item.list", params }),
|
|
466
|
+
* "crm.item.list"
|
|
467
|
+
* );
|
|
468
|
+
*
|
|
469
|
+
* @example
|
|
470
|
+
* const response = await callProtected(
|
|
471
|
+
* () => $b24!.actions.v3.batch.make({ calls }),
|
|
472
|
+
* ["crm.item.get", "crm.item.add"]
|
|
473
|
+
* );
|
|
474
|
+
*/
|
|
475
|
+
export async function callProtected<T>(run: () => Promise<T>, methodName: string | string[]): Promise<T> {
|
|
476
|
+
const names = (Array.isArray(methodName) ? methodName : [methodName]).map((name) => String(name).trim()).filter((name) => name.length > 0);
|
|
477
|
+
|
|
478
|
+
// Fail-closed, как в гейте callBatch: список пуст или имя не похоже на REST-метод
|
|
479
|
+
// (легальные имена всегда с точкой — crm.deal.add, disk.folder.uploadfile) — считаем
|
|
480
|
+
// вызов создающим. Иначе callProtected(() => batch.make({ calls }), "batch") прошёл бы
|
|
481
|
+
// мимо гейта, и батч с crm.deal.add внутри повторился бы до пяти раз при status 0.
|
|
482
|
+
const isUnparsed = names.length === 0 || names.some((name) => !name.includes("."));
|
|
483
|
+
|
|
484
|
+
const creating = names.filter((name) => classifyMethod(name) === "creating");
|
|
485
|
+
const reserving = names.filter((name) => classifyMethod(name) === "reserving");
|
|
486
|
+
|
|
487
|
+
const blockReason = isUnparsed ? creatingReason("имя метода не разобрано") : joinReasons(creating, reserving);
|
|
488
|
+
|
|
489
|
+
return runWithRetry(run, `actions:${names.join(", ") || "имя не указано"}`, blockReason);
|
|
490
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { B24OAuth } from "@bitrix24/b24jssdk";
|
|
2
|
+
import type { B24Client } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
// ==================== Состояние экземпляра ====================
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @module
|
|
8
|
+
* Владелец обоих слоёв экземпляра B24.
|
|
9
|
+
*
|
|
10
|
+
* Модуль-лист: не импортирует ничего, кроме типов. Так решается цикл, который иначе
|
|
11
|
+
* возник бы между `tokens.ts` и `b24.ts` — первый читает `$b24` в `refreshAuthWithMutex`,
|
|
12
|
+
* второй переприсваивает его в `reinitializeB24()`.
|
|
13
|
+
*
|
|
14
|
+
* ESM отдаёт живые связывания, и они переживают два реэкспорта подряд
|
|
15
|
+
* (`state.ts` → `b24.ts` → `index.ts`), поэтому переприсваивание видно потребителю
|
|
16
|
+
* без единой правки на его стороне.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Обёрнутый Proxy экземпляр — то, с чем работает потребитель */
|
|
20
|
+
export let $b24: B24Client | null = null;
|
|
21
|
+
|
|
22
|
+
/** Оригинальный B24OAuth без Proxy: на него вешается setCallbackRefreshAuth */
|
|
23
|
+
export let b24Raw: B24OAuth | null = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Единственная точка записи обоих слоёв.
|
|
27
|
+
*
|
|
28
|
+
* Сегодня `b24Raw` в коде пакета не читает никто: `b24.ts` вешает
|
|
29
|
+
* `setCallbackRefreshAuth` на локальную `raw` / `initialRaw` — тот же объект, но своя
|
|
30
|
+
* переменная. Поле держит ссылку на неупакованный экземпляр и читается тестом
|
|
31
|
+
* (`tests/state-binding.test.ts`).
|
|
32
|
+
*
|
|
33
|
+
* Единый сеттер нужен на будущее: с раздельными сеттерами читатель `b24Raw` мог бы
|
|
34
|
+
* получить слои от разных экземпляров — `$b24` от прежнего рядом с новым `b24Raw`.
|
|
35
|
+
*/
|
|
36
|
+
export function setB24Instance(raw: B24OAuth | null, client: B24Client | null): void {
|
|
37
|
+
b24Raw = raw;
|
|
38
|
+
$b24 = client;
|
|
39
|
+
}
|