ozon-seller-mcp 2.3.0 → 2.3.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
@@ -122,6 +122,8 @@ Windows 上如果客户端找不到 `npx`,把 `command` 改为 `"cmd"`,`args
122
122
 
123
123
  \* 2026-10-26 在一家约 480 个商品的店铺实测,从国内网络访问。
124
124
 
125
+ 营收 `revenue`(来自 `/v1/analytics/data`)单位是卢布,结果里带 `revenue_currency: "RUB"`;商品价格 `price`/`old_price`/`min_price` 是店铺结算币种(可能是 CNY),见 `price_currency`(旧字段 `currency` 保留,值相同)。两者不要直接相加或相除。
126
+
125
127
  ### 通用工具
126
128
 
127
129
  | 工具 | 用途 |
@@ -158,7 +160,7 @@ Windows 上如果客户端找不到 `npx`,把 `command` 改为 `"cmd"`,`args
158
160
  | `ozon_ads_api_catalog` | 搜索全部 48 个广告接口 + 官方说明中给出的 12 个 `/json` 报表版本,或查看单个接口的参数与响应定义 |
159
161
  | `ozon_ads_api_call` | 按路径调用任一广告接口:路径参数和查询参数放 `params`,请求体放 `body` |
160
162
 
161
- - `ozon_ads_stats` 的活动汇总来自 `/api/client/statistics/campaign/product/json`(同步,不消耗限额);商品明细来自异步活动报表 `/api/client/statistics/json`(每次最多 10 个活动,同一账号同时只生成 1 个,最多等 90 秒),包含本期内已移出活动的商品。`include_products=false` 只看活动汇总
163
+ - `ozon_ads_stats` 的活动汇总来自 `/api/client/statistics/campaign/product/json`(同步,不消耗限额);商品明细来自异步活动报表 `/api/client/statistics/json`(每次最多 10 个活动,同一账号同时只生成 1 个,最多等 90 秒),包含本期内已移出活动的商品。同一进程内同一广告账号的报表逐个生成:本服务记住每个广告账号最近提交的报表(UUID、提交时间),下一次提交前(`ozon_ads_stats` 和 `ozon_ads_api_call` 的报表提交,含外部流量报表)先在本次调用内查它的状态,等到 OK/ERROR(外部流量报表还有 TIMEOUT/CANCEL)或它已提交满 120 秒;查询失败不算完成,每次查询也受上一份报表剩余的等待上限约束。本次调用的时间预算内没等到就不提交并返回提示。报表提交不自动重试;已发出但结果未知(连接中断、超时、5xx)时,按“可能正在生成”处理,下一次提交等它满 120 秒。没有任何后台任务;相同查询同时进行时共用一份报表。`ozon_ads_stats` 整次调用(活动列表、汇总、取令牌、等待、重试、报表)共用一个 90 秒期限,超时即取消,不再发出后续请求。`include_products=false` 只看活动汇总
162
164
  - `/api/client/statistics/products/sku` 实测只接受今天或昨天(Ozon 返回 `date range must contain only today or yesterday`),所以不用于多天统计
163
165
  - `OZON_READ_ONLY=true` 时,改出价、启停活动、增删商品、开关“按订单付款”等 16 个写接口在发送前拦截(包括 `GET /api/client/campaign/all_sku_promo/activate` 这类用 GET 做写操作的接口)
164
166
  - 官方限制:每天总计 100 000 次;统计报表同一账号同时只能生成 1 个、一次最多 10 个活动、最长 62 天
@@ -185,13 +187,14 @@ Windows 上如果客户端找不到 `npx`,把 `command` 改为 `"cmd"`,`args
185
187
 
186
188
  其他接口文档未写具体数字,Ozon 仍可能返回 429。
187
189
 
188
- 本地保护:同一进程内超出上述窗口时直接返回“约 N 秒后可再调用”,不发请求;限流接口的相同只读请求 5 分钟内返回缓存(`from_cache: true`)。本地计数只统计当前进程(含被 Ozon 拒绝的调用),以 Ozon 服务端为准。只读请求遇到 429/502/503/504 且 `Retry-After` ≤ 5 秒时重试一次;写请求从不自动重试。
190
+ 本地保护:同一进程内超出上述窗口时直接返回“约 N 秒后可再调用”,不发请求;限流接口的相同只读请求 5 分钟内返回缓存(`from_cache: true`)。本地计数只统计当前进程(含被 Ozon 拒绝的调用),以 Ozon 服务端为准。只读请求遇到 429/502/503/504 且 `Retry-After`(秒数或 HTTP 日期)≤ 5 秒时重试一次;每次实际发送(含重试、等过冷却之后)在发出前一刻计入上述窗口,并受每个账号的重试额度限制(并发失败时不会把请求量翻倍),等待时间加少量随机抖动。写请求从不自动重试。Ozon 返回 429 后,同一账号(同一 Client-Id 或广告 Client ID)的后续请求都先等过 `Retry-After`(最多按 60 秒计):剩余不超过 5 秒就等待后发送,更长则直接返回“约 N 秒后可再调用”;有独立限额的接口(上表)只冷却该接口本身。
189
191
 
190
192
  **实测注意**:`/v1/analytics/data` 带 `sort` 参数的请求在 2026-10-26 连续被 Ozon 以“每秒限流”拒绝,同样的请求不带 `sort` 正常。`ozon_top_products` 因此不传 `sort`,一次取 1000 个 SKU 后本地排序。
191
193
 
192
194
  ## 参数校验
193
195
 
194
- - 必填、类型、嵌套、枚举、上下限、数组约束、日期与 int32/int64/uint64 格式、未知字段,发送前全部校验
196
+ - 必填、类型、嵌套、枚举、上下限、数组约束、日期、date-time(RFC 3339,须带时区,如 `2026-10-01T00:00:00Z`;闰秒只接受 UTC 23:59:60)与 int32/int64/uint64 格式、未知字段,发送前全部校验。少数字段(如 `/v1/finance/balance` 的 `date_from`)规范类型是 date-time、说明和官方示例却是 `YYYY-MM-DD`,这些字段两种写法都接受
197
+ - 整数参数超出 ±9007199254740991 时直接拒绝:JSON 解析时这类数字已被舍入(9007199254740993 会变成 …992),照发可能作用到别的对象
195
198
  - 482 个 schema 与原 Python 版逐个比对一致(3114 个校验用例结论相同),以下 8 处除外
196
199
  - 官方规范有 8 个 schema 把某字段列为必填、却没有定义该字段(如 `/v2/products/stocks` 的 `quant_size`,`/v1/cargoes/label/transport/status` 的 `operation_idd`),原实现因此无法调用这些接口。现在这些字段改为“已声明、不校验类型”的可选字段,说明里注明官方列为必填,最终以 Ozon 返回为准
197
200
  - 官方 `type=null` 的字段禁止传入,不猜测类型;`int`/`bool` 等书写错误按无歧义方式规范化
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // MCP stdio entry. stdout carries JSON-RPC only; diagnostics go to stderr.
3
+ import { redactText } from '../src/privacy.js';
3
4
  import { VERSION } from '../src/version.js';
4
5
 
5
6
  const args = process.argv.slice(2);
@@ -29,14 +30,15 @@ if (args.includes('--version') || args.includes('-v')) {
29
30
  `);
30
31
  } else {
31
32
  // A stray error must not kill the server mid-conversation; log it to stderr and keep serving.
32
- const report = (kind) => (error) => process.stderr.write(`[ozon-seller-mcp] ${kind}: ${error?.stack ?? error}\n`);
33
+ // Logged text goes through the same redaction as tool results (configured keys, ad tokens).
34
+ const report = (kind) => (error) => process.stderr.write(`[ozon-seller-mcp] ${kind}: ${redactText(error)}\n`);
33
35
  process.on('unhandledRejection', report('unhandledRejection'));
34
36
  process.on('uncaughtException', report('uncaughtException'));
35
37
  try {
36
38
  const { main } = await import('../src/server.js');
37
39
  await main();
38
40
  } catch (error) {
39
- process.stderr.write(`[ozon-seller-mcp] 启动失败:${error?.message ?? error}\n`);
41
+ process.stderr.write(`[ozon-seller-mcp] 启动失败:${redactText(error?.message ?? error)}\n`);
40
42
  process.exit(1);
41
43
  }
42
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ozon-seller-mcp",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "description": "Ozon Seller API + Performance (ads) API MCP server: 482 seller endpoints, 48 advertising endpoints, one-call composite queries, read-only by default, strict schema validation.",
5
5
  "keywords": [
6
6
  "mcp",
package/src/ads/http.js CHANGED
@@ -1,22 +1,32 @@
1
1
  // Performance API transport: client-credentials token, path/query parameters, JSON or CSV responses.
2
2
  // Shares the proxy setting and socket pool with the Seller API transport.
3
3
  import { OzonError, ValidationError } from '../errors.js';
4
- import { currentFetch, describeNetworkError, getDispatcher, parseJson, readCapped, sleep } from '../http.js';
5
- import { redact } from '../privacy.js';
4
+ import {
5
+ RETRY_STATUSES, WRITE_UNKNOWN, currentFetch, describeNetworkError, discardBody, getDispatcher, parseJson, readErrorText, readResponse,
6
+ sleep,
7
+ } from '../http.js';
8
+ import { forgetTokens, holdToken, redact, rememberToken } from '../privacy.js';
9
+ import {
10
+ DEFAULT_RETRY_MS, MAX_RETRY_WAIT_MS, admitRetry, admitSend, awaitCooldown, checkAdmission, noteRateLimited, parseRetryAfter,
11
+ withJitter,
12
+ } from '../ratelimit.js';
6
13
  import { enforceMode } from '../schema.js';
7
14
  import { currentShop, hasAds, shops } from '../shops.js';
8
15
  import { VERSION } from '../version.js';
9
16
  import { parseCsv } from './csv.js';
10
17
  import { SAFE_PATH_VALUE, pathParamNames, validateAdsRequest } from './schema.js';
11
- import { ADS_BASE_URL, findAdsOperation } from './spec.js';
18
+ import { ADS_BASE_URL, findAdsOperation, isReportSubmit } from './spec.js';
12
19
 
13
20
  const HOST = new URL(ADS_BASE_URL).host;
14
21
  const TOKEN_PATH = '/api/client/token';
22
+ const TOKEN_OP = Object.freeze({ path: TOKEN_PATH });
15
23
  const USER_AGENT = `Ozon-Seller-MCP/${VERSION}`;
16
24
  const TOKEN_MARGIN_MS = 60_000;
17
25
  const DEFAULT_TOKEN_TTL_MS = 30 * 60_000;
18
26
  const MAX_BODY = 2_000_000;
19
- const RETRY_STATUSES = new Set([429, 502, 503, 504]);
27
+ // A token request serves every caller of the account, so no single caller's budget cancels it;
28
+ // it has its own limit instead.
29
+ const TOKEN_TIMEOUT_MS = 30_000;
20
30
  const SAFE_SEGMENT = new RegExp(SAFE_PATH_VALUE);
21
31
 
22
32
  export const CREDENTIAL_HELP = '在卖家后台 设置 → API 密钥 → Performance API 创建服务账号并添加密钥,'
@@ -30,6 +40,7 @@ const pendingTokens = new Map();
30
40
  export function resetAdsToken() {
31
41
  cachedTokens.clear();
32
42
  pendingTokens.clear();
43
+ forgetTokens();
33
44
  }
34
45
 
35
46
  // True when any shop has Performance API credentials (the ads tools are listed then).
@@ -46,44 +57,109 @@ function adsCredentials() {
46
57
  return { clientId: shop.perfClientId, secret: shop.perfSecret, shop };
47
58
  }
48
59
 
49
- // Bearer tokens are credentials too: hide the one a request used as well as every cached one.
50
- function hideTokens(text, ...tokens) {
51
- const cached = [...cachedTokens.values()].map((t) => t.value);
52
- return [...tokens, ...cached].filter(Boolean).reduce((out, token) => out.replaceAll(token, '[已隐藏令牌]'), String(text));
60
+ // Rate-limit scope of a Performance account: its 429 cooldown and retry budget are its own,
61
+ // separate from the Seller account of the same shop.
62
+ const accountScope = (clientId) => `perf:${clientId}`;
63
+
64
+ // The Performance client of the shop this call runs as. Not a secret (ozon_status shows it).
65
+ export const adsAccount = () => adsCredentials().clientId;
66
+
67
+ // A time limit for one call: `signal` aborts after `ms`. The timer never keeps the process alive
68
+ // on its own (unref) and done() clears it once the work has settled.
69
+ export function timeLimit(ms) {
70
+ const controller = new AbortController();
71
+ const timer = setTimeout(() => controller.abort(), ms);
72
+ timer.unref?.();
73
+ return { signal: controller.signal, deadline: Date.now() + ms, done: () => clearTimeout(timer) };
53
74
  }
54
75
 
55
- function scrubTokens(data, token) {
56
- const text = JSON.stringify(data);
57
- const hidden = hideTokens(text, token);
58
- return hidden === text ? data : JSON.parse(hidden);
76
+ const NOT_SENT = '已超过本次调用的时间预算,请求未发送。';
77
+ const notSent = () => new OzonError(NOT_SENT);
78
+
79
+ // Marks a failure of a request's own exchange, after it went out, without a definite answer from
80
+ // Ozon: a network error or cancellation during the fetch, an unreadable success body, or a 5xx. Such
81
+ // a request may have been carried out (e.g. a report may be being built). Failures before it was
82
+ // sent (budget, cooldown, the token phase) and definite refusals (3xx, 4xx) carry no mark.
83
+ const outcomeUnknown = (error) => Object.assign(error, { outcomeUnknown: true });
84
+ // send() tags failures of the fetch itself; only execute() turns that into outcomeUnknown, for the
85
+ // request it was asked to make. A token request's network error never marks a report submission.
86
+ const fetchFailed = (error) => Object.assign(error, { fetchFailed: true });
87
+
88
+ // Settles like `promise`, or rejects once `signal` aborts, whichever comes first: a request, a body
89
+ // or a wait that hangs cannot outlive its caller's time budget, whatever the fetch implementation
90
+ // does. A rejection of `promise` after the race was lost is handled here, never left unhandled.
91
+ export function untilAborted(promise, signal, message = NOT_SENT) {
92
+ const raced = Promise.resolve(promise);
93
+ if (!signal) return raced;
94
+ raced.catch(() => {});
95
+ if (signal.aborted) return Promise.reject(new OzonError(message));
96
+ return new Promise((resolve, reject) => {
97
+ const abort = () => reject(new OzonError(message));
98
+ signal.addEventListener('abort', abort, { once: true });
99
+ raced.then((value) => {
100
+ signal.removeEventListener('abort', abort);
101
+ if (signal.aborted) abort();
102
+ else resolve(value);
103
+ }, (error) => {
104
+ signal.removeEventListener('abort', abort);
105
+ reject(error);
106
+ });
107
+ });
59
108
  }
60
109
 
61
- async function send(url, init, agent, { write = false } = {}) {
110
+ // Every send waits out the account's shared 429 cooldown, then, right before it goes out, is
111
+ // dropped if the caller's budget is used up, or checked and counted by the limiter. Bearer tokens
112
+ // in a failure message are hidden by redact(), which knows every token issued in this process.
113
+ async function send(url, init, agent, { write = false, scope, op, signal } = {}) {
114
+ try {
115
+ await awaitCooldown(op, scope, signal);
116
+ } catch (error) {
117
+ throw signal?.aborted ? notSent() : error;
118
+ }
119
+ if (signal?.aborted) throw notSent();
120
+ admitSend(op, Date.now(), scope);
62
121
  const sentAt = performance.now();
63
122
  try {
64
- return await currentFetch()(url, { ...init, redirect: 'manual', dispatcher: agent });
123
+ return await untilAborted(currentFetch()(url, { ...init, redirect: 'manual', dispatcher: agent, signal }), signal);
65
124
  } catch (error) {
66
- throw new OzonError(describeNetworkError(error, performance.now() - sentAt, HOST)
67
- + (write ? ' 写入结果未知,请先查询业务状态,不要自动重试。' : ''));
125
+ if (signal?.aborted) throw fetchFailed(new OzonError(`等待 Ozon 响应超时,已取消请求。${write ? WRITE_UNKNOWN : ''}`));
126
+ throw fetchFailed(new OzonError(describeNetworkError(error, performance.now() - sentAt, HOST) + (write ? WRITE_UNKNOWN : '')));
68
127
  }
69
128
  }
70
129
 
71
- async function rejectRedirect(response, path) {
130
+ function rejectRedirect(response, path) {
72
131
  if (response.status >= 300 && response.status < 400) {
73
- await response.body?.cancel().catch(() => {});
132
+ discardBody(response);
74
133
  throw new OzonError(`HTTP ${response.status} ${path}:Ozon 返回重定向,为保护凭证未跟随。`);
75
134
  }
76
135
  }
77
136
 
78
- const errorText = async (response, token) => hideTokens(redact((await response.text().catch(() => '')).slice(0, 2000)), token);
137
+ const errorText = (response, write = false) => readErrorText(response, write, 2000);
79
138
 
80
- async function requestToken({ clientId, secret, shop }, agent) {
139
+ async function requestToken(credentials, agent) {
140
+ const limit = timeLimit(TOKEN_TIMEOUT_MS);
141
+ try {
142
+ return await untilAborted(fetchToken(credentials, agent, limit.signal), limit.signal, 'Performance API 授权请求超时,已取消。');
143
+ } finally {
144
+ limit.done();
145
+ }
146
+ }
147
+
148
+ async function fetchToken({ clientId, secret, shop }, agent, signal) {
149
+ const scope = accountScope(clientId);
81
150
  const response = await send(ADS_BASE_URL + TOKEN_PATH, {
82
151
  method: 'POST',
83
152
  headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': USER_AGENT },
84
153
  body: JSON.stringify({ client_id: clientId, client_secret: secret, grant_type: 'client_credentials' }),
85
- }, agent);
154
+ }, agent, { scope, op: TOKEN_OP, signal });
86
155
  await rejectRedirect(response, TOKEN_PATH);
156
+ if (response.status === 429) {
157
+ // The token endpoint shares the account's cooldown: every caller waits instead of asking again.
158
+ const delay = parseRetryAfter(response.headers.get('retry-after')) ?? DEFAULT_RETRY_MS;
159
+ noteRateLimited(TOKEN_OP, scope, delay);
160
+ throw new OzonError(`Performance API 授权接口限流(HTTP 429):Retry-After=${Math.ceil(delay / 1000)} 秒,请告知用户稍后再查,不要重复重试。 `
161
+ + await errorText(response));
162
+ }
87
163
  if (!response.ok) {
88
164
  const fields = shop.legacy ? 'OZON_PERF_CLIENT_ID 和 OZON_PERF_CLIENT_SECRET' : `店铺「${shop.name}」的 perf_client_id 和 perf_client_secret`;
89
165
  const hint = response.status < 500
@@ -91,22 +167,27 @@ async function requestToken({ clientId, secret, shop }, agent) {
91
167
  : 'Ozon 授权服务暂时不可用,稍后再试。';
92
168
  throw new OzonError(`Performance API 授权失败(HTTP ${response.status}):${hint} ${await errorText(response)}`);
93
169
  }
94
- let data;
95
- try {
96
- data = parseJson((await readCapped(response)).toString('utf8'));
97
- } catch {
98
- throw new OzonError('Performance API 授权服务返回了无法解析的内容。');
99
- }
170
+ const data = await readResponse(response, false, (raw) => {
171
+ try {
172
+ return parseJson(raw.toString('utf8'));
173
+ } catch {
174
+ throw new OzonError('Performance API 授权服务返回了无法解析的内容。');
175
+ }
176
+ });
100
177
  if (typeof data?.access_token !== 'string' || !data.access_token) throw new OzonError('Performance API 授权响应缺少 access_token。');
101
178
  const ttl = Number(data.expires_in) > 0 ? Number(data.expires_in) * 1000 : DEFAULT_TOKEN_TTL_MS;
179
+ // Registered before first use, so no output or log line can carry it unredacted.
180
+ rememberToken(data.access_token, Date.now() + ttl);
102
181
  return { clientId, value: data.access_token, expiresAt: Date.now() + Math.max(ttl - TOKEN_MARGIN_MS, ttl / 2) };
103
182
  }
104
183
 
105
- // `rejected` is the token that just got a 401: a newer cached token is reused, otherwise a new one is fetched.
106
- async function getToken(credentials, agent, rejected) {
184
+ // `rejected` is the token that just got a 401: a newer cached token is reused, otherwise a new one is
185
+ // fetched. The caller waits within its own budget (`signal`); the shared request is not cancelled.
186
+ async function getToken(credentials, agent, rejected, signal) {
107
187
  const { clientId } = credentials;
108
188
  const cached = cachedTokens.get(clientId);
109
189
  if (cached && cached.value !== rejected && Date.now() < cached.expiresAt) return cached.value;
190
+ if (signal?.aborted) throw notSent();
110
191
  if (!pendingTokens.has(clientId)) {
111
192
  const promise = requestToken(credentials, agent)
112
193
  .then((token) => {
@@ -118,7 +199,7 @@ async function getToken(credentials, agent, rejected) {
118
199
  });
119
200
  pendingTokens.set(clientId, promise);
120
201
  }
121
- return pendingTokens.get(clientId);
202
+ return untilAborted(pendingTokens.get(clientId), signal);
122
203
  }
123
204
 
124
205
  function buildUrl(op, params) {
@@ -143,9 +224,12 @@ function buildUrl(op, params) {
143
224
 
144
225
  const ZIP_NOTE = '这是多个活动打包的 ZIP 报告,本服务不解压。请每次只传一个活动重新生成,或改用对应的 /json 接口。';
145
226
 
146
- async function readBody(response) {
227
+ function readBody(response, write) {
147
228
  const type = (response.headers.get('content-type') ?? '').toLowerCase();
148
- const raw = await readCapped(response);
229
+ return readResponse(response, write, (raw) => decodeBody(type, raw));
230
+ }
231
+
232
+ function decodeBody(type, raw) {
149
233
  if (type.includes('json')) {
150
234
  if (!raw.length) return {};
151
235
  try {
@@ -169,7 +253,7 @@ function failureHint(status, retryAfter) {
169
253
  return '检查参数是否符合官方说明(ozon_ads_api_catalog 传 endpoint 可查看)。';
170
254
  }
171
255
 
172
- async function execute(op, url, payload, credentials, agent) {
256
+ async function execute(op, url, payload, credentials, agent, signal) {
173
257
  const start = performance.now();
174
258
  const headers = { Accept: 'application/json, text/csv', 'User-Agent': USER_AGENT };
175
259
  let body;
@@ -178,44 +262,80 @@ async function execute(op, url, payload, credentials, agent) {
178
262
  if (Buffer.byteLength(body) > MAX_BODY) throw new OzonError('请求体过大,请缩小查询范围。');
179
263
  headers['Content-Type'] = 'application/json';
180
264
  }
181
- let token = await getToken(credentials, agent);
265
+ const scope = accountScope(credentials.clientId);
266
+ // A report submission is classified read-only, but repeating it could start a second report:
267
+ // like a write, it is never retried automatically.
268
+ const retryable = op.read_only && !isReportSubmit(op);
269
+ const bodyTimedOut = `读取 Ozon 响应超过本次调用的时间预算,已取消。${op.read_only ? '' : WRITE_UNKNOWN}`;
270
+ if (signal?.aborted) throw notSent();
271
+ // Fails before fetching a token when a long cooldown is on; the send itself is counted in send().
272
+ checkAdmission(op, Date.now(), scope);
273
+ let token = await getToken(credentials, agent, undefined, signal);
274
+ // The token stays listed for redaction while this request uses it, even if it is replaced meanwhile.
275
+ let release = holdToken(token);
182
276
  let refreshed = false;
183
277
  let retried = false;
184
- // At most one token refresh (a 401 means nothing was executed) and one retry for reads.
185
- for (;;) {
186
- const response = await send(url, { method: op.method, headers: { ...headers, Authorization: `Bearer ${token}` }, body }, agent,
187
- { write: !op.read_only });
188
- await rejectRedirect(response, op.path);
189
- if (response.status === 401 && !refreshed) {
190
- await response.body?.cancel().catch(() => {});
191
- refreshed = true;
192
- token = await getToken(credentials, agent, token);
193
- continue;
194
- }
195
- if (!response.ok) {
196
- const retryAfter = response.headers.get('retry-after') ?? '2';
197
- const delay = Number.isFinite(Number(retryAfter)) ? Math.max(0, Number(retryAfter)) : 2;
198
- if (op.read_only && !retried && RETRY_STATUSES.has(response.status) && delay <= 5) {
199
- await response.body?.cancel().catch(() => {});
200
- retried = true;
201
- await sleep(delay * 1000);
278
+ try {
279
+ // At most one token refresh (a 401 means nothing was executed) and one retry for reads, within
280
+ // the account's retry budget. Every send waits out the account's shared 429 cooldown.
281
+ for (;;) {
282
+ let response;
283
+ try {
284
+ response = await send(url, { method: op.method, headers: { ...headers, Authorization: `Bearer ${token}` }, body }, agent,
285
+ { write: !op.read_only, scope, op, signal });
286
+ } catch (error) {
287
+ throw error.fetchFailed ? outcomeUnknown(error) : error;
288
+ }
289
+ rejectRedirect(response, op.path);
290
+ if (response.status === 401 && !refreshed) {
291
+ discardBody(response);
292
+ refreshed = true;
293
+ release();
294
+ token = await getToken(credentials, agent, token, signal);
295
+ release = holdToken(token);
202
296
  continue;
203
297
  }
204
- throw new OzonError(`HTTP ${response.status} ${op.method} ${op.path}:${failureHint(response.status, retryAfter)} ${await errorText(response, token)}`);
298
+ if (!response.ok) {
299
+ const delay = parseRetryAfter(response.headers.get('retry-after')) ?? DEFAULT_RETRY_MS;
300
+ if (response.status === 429) noteRateLimited(op, scope, delay);
301
+ if (retryable && !retried && RETRY_STATUSES.has(response.status) && delay <= MAX_RETRY_WAIT_MS && admitRetry(op, scope)) {
302
+ discardBody(response);
303
+ retried = true;
304
+ try {
305
+ await sleep(withJitter(delay), signal);
306
+ } catch (error) {
307
+ throw signal?.aborted ? notSent() : error;
308
+ }
309
+ continue;
310
+ }
311
+ const failure = new OzonError(`HTTP ${response.status} ${op.method} ${op.path}:${failureHint(response.status, Math.ceil(delay / 1000))} `
312
+ + await untilAborted(errorText(response, !op.read_only), signal, bodyTimedOut).catch((error) => error.message));
313
+ throw response.status >= 500 ? outcomeUnknown(failure) : failure;
314
+ }
315
+ // Reading the body is part of the request: the budget covers it too.
316
+ let data;
317
+ try {
318
+ data = await untilAborted(readBody(response, !op.read_only), signal, bodyTimedOut);
319
+ } catch (error) {
320
+ throw outcomeUnknown(error);
321
+ }
322
+ return {
323
+ endpoint: op.path,
324
+ method: op.method,
325
+ http_status: response.status,
326
+ queried_at_utc: new Date().toISOString(),
327
+ elapsed_ms: Math.round(performance.now() - start),
328
+ data: redact(data),
329
+ };
205
330
  }
206
- const data = await readBody(response);
207
- return {
208
- endpoint: op.path,
209
- method: op.method,
210
- http_status: response.status,
211
- queried_at_utc: new Date().toISOString(),
212
- elapsed_ms: Math.round(performance.now() - start),
213
- data: scrubTokens(redact(data), token),
214
- };
331
+ } finally {
332
+ release();
215
333
  }
216
334
  }
217
335
 
218
- export async function adsRequest(endpoint, { method = 'GET', params = {}, body } = {}) {
336
+ // Checks everything that needs no network (mode, registry, parameters, credentials, proxy) and
337
+ // returns the request ready to send. `signal` cancels it, waits included.
338
+ export function prepareAdsRequest(endpoint, { method = 'GET', params = {}, body, signal } = {}) {
219
339
  const op = findAdsOperation(endpoint, method);
220
340
  if (!op) {
221
341
  throw new OzonError('广告接口路径或 HTTP 方法未在 Performance API 官方规范中登记,未发送请求;先用 ozon_ads_api_catalog 搜索。');
@@ -226,5 +346,10 @@ export async function adsRequest(endpoint, { method = 'GET', params = {}, body }
226
346
  const payload = validateAdsRequest(op, query, body);
227
347
  const url = buildUrl(op, query);
228
348
  const credentials = adsCredentials();
229
- return execute(op, url, payload, credentials, getDispatcher());
349
+ const agent = getDispatcher();
350
+ return (sendSignal = signal) => execute(op, url, payload, credentials, agent, sendSignal);
351
+ }
352
+
353
+ export async function adsRequest(endpoint, options = {}) {
354
+ return prepareAdsRequest(endpoint, options)();
230
355
  }
@@ -0,0 +1,118 @@
1
+ // One statistics report at a time per Performance account (Ozon's limit), with no background work.
2
+ // Each account remembers the last report this process submitted: its UUID, status endpoint and
3
+ // submission time. Before the next submission (an ozon_ads_stats batch or an ozon_ads_api_call
4
+ // report, vendor reports included) the submitting call itself polls that report's status, within
5
+ // its own time budget, until Ozon reports a final state or the report is MAX_AGE old. A per-account
6
+ // queue makes "check previous → submit → record" one step, so two calls in this process cannot
7
+ // both submit; it is released in `finally`, whatever happens.
8
+ import { OzonError } from '../errors.js';
9
+ import { sleep } from '../http.js';
10
+ import { adsAccount, adsRequest, timeLimit, untilAborted } from './http.js';
11
+
12
+ // Polling every 3 s; ozon_ads_stats as a whole and each report submission get 90 s; a report
13
+ // submitted 120 s ago no longer holds the next one back.
14
+ const DEFAULT_REPORT_TIMING = Object.freeze({ pollMs: 3000, waitMs: 90_000, maxAgeMs: 120_000 });
15
+ export let reportTiming = DEFAULT_REPORT_TIMING;
16
+ export function setReportTimingForTests(timing) {
17
+ reportTiming = { ...DEFAULT_REPORT_TIMING, ...timing };
18
+ }
19
+
20
+ // Final states. Vendor (external traffic) reports can also end as TIMEOUT or CANCEL.
21
+ const FINISHED = new Set(['OK', 'ERROR', 'TIMEOUT', 'CANCEL']);
22
+ export const isFinished = (state) => FINISHED.has(state);
23
+
24
+ // Performance client id -> { uuid, status: { path, params }, submittedAt, finished }. A submission
25
+ // that went out without a definite answer is kept as { uuid: null, unknown: true, submittedAt }: Ozon
26
+ // may be building that report, so it holds the next one back until it is MAX_AGE old.
27
+ const lastReports = new Map();
28
+ const queues = new Map(); // Performance client id -> promise settled when the slot is free
29
+
30
+ // Per-account state between tests; nothing runs in the background, so nothing needs stopping.
31
+ export function resetReportSlots() {
32
+ lastReports.clear();
33
+ queues.clear();
34
+ }
35
+
36
+ // Whoever reads a report's status reports a final state here, so the next submission need not ask again.
37
+ export function observeReportStatus(uuid, state) {
38
+ if (!isFinished(state)) return;
39
+ const last = lastReports.get(adsAccount());
40
+ if (last && last.uuid === String(uuid)) last.finished = true;
41
+ }
42
+
43
+ const describeLast = (last) => (last.unknown ? '上一份报表(已提交但结果未知)' : `上一份报表(UUID=${last.uuid})`);
44
+ const seconds = (ms) => Math.max(1, Math.ceil(ms / 1000));
45
+
46
+ // The previous report may still be building: neither final nor MAX_AGE old. Only the call's own
47
+ // budget running out stops the wait with an error; the age running out lets the submission go ahead.
48
+ const budgetUsedUp = (last, ageLeft) => new OzonError(`本次调用的时间预算已用完:同一广告账号${describeLast(last)}还在生成或状态未知,`
49
+ + `还要约 ${seconds(ageLeft)} 秒才满 ${seconds(reportTiming.maxAgeMs)} 秒的等待上限,未提交新报表;请稍后再试。`);
50
+
51
+ // Waits until the account's previous report is final or MAX_AGE old. Every status poll and every
52
+ // pause is bounded by both the call's budget and the report's remaining age: when the age runs out
53
+ // first, waiting stops and the submission goes ahead; when the budget runs out first, the caller
54
+ // fails without submitting. A failed poll is not a final state. A placeholder (result unknown) has
55
+ // no status to ask for: it is waited out, or refused at once when the budget cannot cover it.
56
+ async function previousFinished(clientId, budget) {
57
+ const last = lastReports.get(clientId);
58
+ while (last && !last.finished) {
59
+ const ageLeft = last.submittedAt + reportTiming.maxAgeMs - Date.now();
60
+ if (ageLeft <= 0) return;
61
+ if (budget.signal?.aborted) throw budgetUsedUp(last, ageLeft);
62
+ if (last.unknown && budget.deadline !== undefined && budget.deadline - Date.now() < ageLeft) throw budgetUsedUp(last, ageLeft);
63
+ const age = timeLimit(ageLeft);
64
+ const signal = budget.signal ? AbortSignal.any([budget.signal, age.signal]) : age.signal;
65
+ try {
66
+ if (!last.unknown) {
67
+ const status = (await adsRequest(last.status.path, { params: { ...last.status.params, UUID: last.uuid }, signal })).data;
68
+ if (isFinished(status?.state)) {
69
+ last.finished = true;
70
+ return;
71
+ }
72
+ }
73
+ await sleep(last.unknown ? ageLeft : Math.min(reportTiming.pollMs, ageLeft), signal);
74
+ } catch {
75
+ // A failed or cut-off poll, or a pause ended early: the loop decides what happened.
76
+ } finally {
77
+ age.done();
78
+ }
79
+ }
80
+ }
81
+
82
+ // Runs `submit(signal)` as the account's next report submission and records the report it returns
83
+ // (adsRequest result or its data, with a UUID) with `status`, where its state is read. A submission
84
+ // that went out without a definite answer (error.outcomeUnknown, or no UUID) leaves a placeholder; one
85
+ // that was never sent, or was refused outright, leaves nothing. Everything, the wait for the queue
86
+ // included, stays within `budget` ({ signal, deadline }).
87
+ export async function submitReport(status, submit, budget) {
88
+ const { signal } = budget;
89
+ const clientId = adsAccount();
90
+ const previous = queues.get(clientId) ?? Promise.resolve();
91
+ let release;
92
+ const mine = new Promise((resolve) => {
93
+ release = resolve;
94
+ });
95
+ const tail = previous.then(() => mine);
96
+ queues.set(clientId, tail);
97
+ tail.then(() => {
98
+ if (queues.get(clientId) === tail) queues.delete(clientId);
99
+ });
100
+ try {
101
+ await untilAborted(previous, signal, '同一广告账号有其他报表正在提交,本次调用的时间预算内没轮到,未提交新报表;请稍后再试。');
102
+ await previousFinished(clientId, budget);
103
+ let result;
104
+ try {
105
+ result = await submit(signal);
106
+ } catch (error) {
107
+ if (error?.outcomeUnknown) lastReports.set(clientId, { uuid: null, unknown: true, submittedAt: Date.now(), finished: false });
108
+ throw error;
109
+ }
110
+ const uuid = (result?.data ?? result)?.UUID;
111
+ lastReports.set(clientId, uuid
112
+ ? { uuid: String(uuid), status, submittedAt: Date.now(), finished: false }
113
+ : { uuid: null, unknown: true, submittedAt: Date.now(), finished: false });
114
+ return result;
115
+ } finally {
116
+ release();
117
+ }
118
+ }
package/src/ads/spec.js CHANGED
@@ -28,6 +28,25 @@ export const READ_ONLY_IDS = new Set([
28
28
  'VendorStatisticsSubmitRequest', 'VendorStatisticsListReports', 'VendorStatisticsCheck', 'GetVendorTag',
29
29
  ]);
30
30
 
31
+ // Asynchronous report submissions (each answers with a UUID to poll). Ozon builds one report at a
32
+ // time per account, so these go through the account's report slot (see src/ads/reports.js), and
33
+ // the status of each is read from its own endpoint: vendor (external traffic) reports have theirs.
34
+ const REPORT_SUBMIT_IDS = new Set([
35
+ 'SubmitRequest', 'VideoCampaignsSubmitRequest', 'AttributionSubmitRequest', 'SearchPromoOrdersReportSubmitRequest',
36
+ 'SearchPromoProductsReportSubmitRequest', 'GenerateAllSkuPromoOrdersReport', 'GenerateAllSkuPromoProductsReport',
37
+ 'ExternalStatistics_StatisticsPhrasesSubmitRequest2', 'VendorStatisticsSubmitRequest',
38
+ ]);
39
+ const baseId = (op) => op.operation_id.replace(/_json$/, '');
40
+ export const isReportSubmit = (op) => REPORT_SUBMIT_IDS.has(baseId(op));
41
+ export const REPORT_STATUS_PATH = '/api/client/statistics/{UUID}';
42
+ export const VENDOR_REPORT_STATUS_PATH = '/api/client/vendors/statistics/{UUID}';
43
+ // Where a submission's report status is read: the endpoint and its fixed query parameters (the
44
+ // vendor endpoint requires vendor=true, per its official description).
45
+ export const REPORT_STATUS = Object.freeze({ path: REPORT_STATUS_PATH, params: {} });
46
+ const VENDOR_REPORT_STATUS = Object.freeze({ path: VENDOR_REPORT_STATUS_PATH, params: { vendor: true } });
47
+ export const reportStatus = (op) => (baseId(op) === 'VendorStatisticsSubmitRequest' ? VENDOR_REPORT_STATUS : REPORT_STATUS);
48
+ export const isReportStatus = (op) => op.method === 'GET' && [REPORT_STATUS_PATH, VENDOR_REPORT_STATUS_PATH].includes(op.path);
49
+
31
50
  // Writes include GET endpoints that switch promotion on/off or set bids.
32
51
  export const WRITE_IDS = new Set([
33
52
  'CreateProductCampaignCPCV2', 'ActivateCampaign', 'DeactivateCampaign', 'PatchProductCampaign', 'AddProducts', 'UpdateProducts',