gangtise-openapi-cli 0.25.0 → 0.26.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 CHANGED
@@ -4,6 +4,22 @@
4
4
 
5
5
  ## Changelog
6
6
 
7
+ ### v0.26.0 — 2026-07-11
8
+
9
+ **资金安全(重要)**
10
+ - 13 个贵档端点(`one-pager` / `investment-logic` / `peer-comparison` / `research-outline` / `theme-tracking` / `management-discuss-*`×2 / `hot-topic` / `knowledge-batch` / `earnings-review get-id` / `viewpoint-debate get-id` / `concept-info` / `concept-securities`)改为 **no-replay 重试策略**:5xx / 超时 / `999999` 不再自动重放——实测(2026-07-11)平台按次计费且**缓存命中不豁免**,同参数重放每次都扣分;仅连接期错误(`ECONNREFUSED`/DNS,请求未发出)、429 限流和 token 自愈仍重试。便宜按条计费的 list 类维持原全量重试(失败响应没有数据行、不计费)
11
+ - 连接失败 `ECONNREFUSED` / `UND_ERR_CONNECT_TIMEOUT` 纳入默认重试范围(此前这两类不重试)
12
+
13
+ **文件安全**
14
+ - 所有 `--output` 落盘(导出、流式下载主路径、签名 URL 跟随下载)改为原子写:先写同目录 `.part` 成功后 rename——重跑失败不再毁掉已有旧文件;顺带修掉中止路径上 `.part` 因流懒打开竞态残留的问题
15
+ - 签名 URL 跟随下载改走 transport 层:遵守 `GANGTISE_TIMEOUT_MS`(此前裸 `fetch` 无超时,慢滴速 CDN 可无限挂起)、网络错误自动重试、跟随最多 3 跳重定向(undici 不自动跟随,超限/缺 `Location` 报错而非把跳转页存成文件)、`--verbose` 日志剥离签名 query 只留 origin+path
16
+ - 自动命名去重后缀试尽 `-1`…`-99` 仍冲突时报错,不再静默覆盖最早的文件
17
+
18
+ **修复 / 加固**
19
+ - 下载重定向超过 3 跳或缺 `Location` 时报错,不再把跳转页 HTML 当文件内容保存
20
+ - 损坏的 gzip 响应包装为带请求上下文的 `ApiError`(此前抛裸 zlib `Z_DATA_ERROR`,与请求无关联且不可定位)
21
+ - `alternative edb-search --limit` ≤200、`indicator search --limit` ≤100 本地上限校验——实测服务端对超限值静默截断(201→200、101→100),与 v0.25.0 的 `--top` 同类同修法
22
+
7
23
  ### v0.25.0 — 2026-07-10
8
24
 
9
25
  **新增接口(4)**
@@ -60,31 +76,6 @@
60
76
  **文档 / Skill**(随 `/sync-skill` 分发,不影响 CLI 行为)
61
77
  - gangtise-openapi Agent Skill 经 fable5 审计 + 多轮 review 优化:官方积分计费速查表 + 高积分 pre-flight 闸门、AI 同步生成命令 `GANGTISE_TIMEOUT_MS=120000` 超时前置、大结果集 `--output` 落盘、异步 `--wait` 主路径、行业码口径收敛到单一权威、市值量纲实测(`qte_mkt_cptl` 仅 A 股 / 默认原始「元」/ `scale`+`currency`)等文档补全与消歧
62
78
 
63
- ### v0.22.0 — 2026-07-02
64
-
65
- **行为变更(注意)**
66
- - ⚠️ 自动翻页接口省略 `--size` 现在一律拉全量(不再区分是否传时间范围);需要只取前 N 条时请显式传 `--size N`。数据量未知时可先用 `--size 1` 从 stderr 的 `Total: N` 探明量级
67
- - 部分结果可机器识别:翻页页失败、K 线分片失败、或服务端提前短页但仍报告更大 `total` 时,结果会带 `partial: true`(页失败另有 `failedPages`,分片为 `failedShards`),非 json 行式输出仍只输出数据行,但进程退出码为 3
68
-
69
- **修复(鉴权 / 请求可靠性)**
70
- - Token 自愈覆盖服务端 `0000001008` 踢线失效,并能处理 HTTP 4xx 错误信封;`GANGTISE_TOKEN` + AK/SK 场景下环境 token 失效后不再反复回放旧 token
71
- - 并发请求同时遇到旧 token 失效时复用一次刷新结果;若刚拿到的新 token 本身被踢掉,则强制再次登录,避免"刚登录窗口期"误跳过刷新
72
- - 自动重试范围扩展到 429、DNS/网络临时错误与 undici 超时类错误;`GANGTISE_BASE_URL` 带路径前缀时 URL 拼接不再丢前缀
73
-
74
- **修复(下载 / 输出 / 数据正确性)**
75
- - 下载接口跟随最多 3 次 30x 跳转;跨域跳到对象存储签名 URL 时不携带 Authorization;服务端返回 `{url}` 且用户传 `--output` 时会真正下载文件,而不是把 URL 字符串写进文件
76
- - 自动文件名补齐清洗、截断与去重:服务端文件名、标题缓存名和 fallback 名都不会把 `/`、控制字符、过长中文名或重复标题变成路径/覆盖问题
77
- - `table`/`markdown` 输出清理控制字符、正确按 CJK 宽字符对齐,并转义 markdown 表头中的 `|`;CSV 输出转义表头、文件输出带 UTF-8 BOM,流式 CSV 遇全标量列表时回退到正常渲染而不是只写 BOM
78
- - `indicator search` / `cross-section` / `time-series` 的内层失败信封即使没有 `data` 字段也会抛出 `ApiError`,不再把"无权限/参数错误"渲染成成功结果
79
- - `--indicator-param` 等逗号列表支持全角逗号 `,`;日期型时间参数按本地零点解析,避免 `yyyy-MM-dd` 被当作 UTC 造成查询窗口偏移
80
- - `fundamental earning-forecast` 省略 `--start-date` 时按传入的 `--end-date` 往前一年计算,不再总是按今天往前一年
81
- - AI 异步 `--wait` 对 `410111` 终态失败只提示"不要重试",超时才提示稍后用 check 命令查询;等待说明同步为最长约 5 分钟
82
-
83
- **CLI / 工程**
84
- - `raw call` 会在本地拒绝 JSON endpoint 的 `--query` 和 download endpoint 的 `--body`,避免静默丢参数;`--format` 在发请求前校验,格式拼错不再先消耗接口调用
85
- - `gangtise ... | head` 遇 stdout `EPIPE` 时安静退出;只有首个参数是 `--version` / `-V` 时才触发版本快捷路径
86
- - Endpoint registry 的 `key` 改为由记录键自动派生,减少映射漂移;新增真实 CLI 选项到请求体的 stub 测试;测试 272 → 323
87
-
88
79
  > 更早版本及完整更新历史见 [CHANGELOG.md](CHANGELOG.md)。
89
80
 
90
81
  ## 首次安装
@@ -311,7 +302,7 @@ gangtise ai knowledge-batch --query 比亚迪 --query 最近热门概念
311
302
  - **HTTP keep-alive**:所有请求复用同一个 `undici.Agent`(连接池 16),避免重复 TLS 握手。
312
303
  - **流式下载**:指定 `--output` 时,二进制响应(PDF 等)直接 `pipeline` 到磁盘,不经过内存缓冲;50MB PDF 内存占用近乎为零。
313
304
  - **流式输出**:`jsonl`/`csv` 格式且 `--output` 指定时,超过 1000 行自动切换为逐行写盘,避免一次性构建百 MB 字符串。
314
- - **自动重试**:5xx / 429 / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 超时类)/ `999999` 系统错误自动指数退避重试 2 次。
305
+ - **自动重试**:5xx / 429 / `ECONNREFUSED` / `ECONNRESET` / `ETIMEDOUT` / `ENOTFOUND` / `EAI_AGAIN` / `UND_ERR_*`(undici 连接/超时类)/ `999999` 系统错误自动指数退避重试 2 次。**贵档生成类端点例外**(one-pager 等 13 个):5xx/超时不重放——按次计费不幂等,重放即重复扣分;仅连接失败、429 与 token 自愈重试。
315
306
  - **Token 自愈**:调用返回 8000014/8000015 时自动强制刷新 Token 并重试一次。
316
307
  - **K线/资金流向自动分片**:`quote day-kline --security all`、`quote fund-flow --security aShares` 等全市场查询自动按日期切分(A股 K线/资金流向 1 天/片、美股 1 天/片、HK 2 天/片、指数 30 天/片),并发执行后合并结果;按日分片自动跳过周六日。分片时如果用户未传 `--limit`,自动注入 `limit: 10000`(API 上限)避免默认 6000 截断。
317
308
  - **Token 内存缓存**:Token 在进程内存中缓存,避免每次请求读盘。
package/dist/src/cli.js CHANGED
@@ -601,7 +601,7 @@ program.addCommand(ai);
601
601
  const alternative = new Command("alternative").description("Alternative data APIs");
602
602
  alternative.command("edb-search").requiredOption("--keyword <text>", "Search keyword (e.g. '空调')").option("--limit <number>", "Max results (default: 100, max: 200)", "100").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => emit(options, (client) => client.call("alternative.edb-search", {
603
603
  keyword: options.keyword,
604
- limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
604
+ limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 200 }),
605
605
  })));
606
606
  alternative.command("edb-data").option("--indicator-id <id>", "Indicator ID (repeat, max 10)", collectList, []).requiredOption("--start-date <date>", "Start date (yyyy-MM-dd)").requiredOption("--end-date <date>", "End date (yyyy-MM-dd)").option("--format <format>", "Output format", "table").option("--output <path>").action((options) => withClient(async (client) => {
607
607
  const format = parseOutputFormat(options.format);
@@ -628,7 +628,7 @@ indicator.command("search").requiredOption("--keyword <text>", "Search keyword,
628
628
  const format = parseOutputFormat(options.format);
629
629
  const raw = await client.call("indicator.search", {
630
630
  keyword: options.keyword,
631
- limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1 }),
631
+ limit: parseNumberOption(options.limit, "--limit", { integer: true, min: 1, max: 100 }),
632
632
  });
633
633
  await printData(unwrapIndicatorData(raw), format, options.output);
634
634
  }));
@@ -354,9 +354,22 @@ export class GangtiseClient {
354
354
  // response reads as text directly (and keeps existing behavior on that path).
355
355
  const encoding = response.headers['content-encoding'];
356
356
  const gzipped = (Array.isArray(encoding) ? encoding[0] : encoding)?.toLowerCase().trim() === 'gzip';
357
- const text = gzipped
358
- ? decodeResponseBody(new Uint8Array(await response.body.arrayBuffer()), encoding)
359
- : await response.body.text();
357
+ let text;
358
+ if (gzipped) {
359
+ // A proxy/middlebox can declare gzip and deliver garbage — surface that as
360
+ // an ApiError with request context instead of a bare zlib Z_DATA_ERROR.
361
+ const bytes = new Uint8Array(await response.body.arrayBuffer());
362
+ try {
363
+ text = decodeResponseBody(bytes, encoding);
364
+ }
365
+ catch (error) {
366
+ const detail = error instanceof Error ? error.message : String(error);
367
+ throw new ApiError(`Failed to decode gzip response for ${endpoint.method} ${endpoint.path}: ${detail}`, undefined, response.statusCode);
368
+ }
369
+ }
370
+ else {
371
+ text = await response.body.text();
372
+ }
360
373
  logTiming(`${endpoint.method} ${endpoint.path}`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
361
374
  // Parse Retry-After once so every error path below (JSON parse failure AND the
362
375
  // envelope/HTTP-error throw) carries it — a non-JSON 429/503 must still honor
@@ -385,6 +398,7 @@ export class GangtiseClient {
385
398
  throw error;
386
399
  }
387
400
  }, {
401
+ policy: endpoint.retry === "no-replay" ? "no-replay" : "default",
388
402
  onRetry: (attempt, error, delay) => {
389
403
  if (!isVerbose())
390
404
  return;
@@ -434,6 +448,13 @@ export class GangtiseClient {
434
448
  dispatcher,
435
449
  });
436
450
  }
451
+ // The loop above can exit with a 3xx still in hand (hop limit exceeded, or a
452
+ // redirect without Location) — that response must never be treated as file
453
+ // content: its HTML placeholder body would be saved as the "downloaded file".
454
+ if (response.statusCode >= 300 && response.statusCode < 400) {
455
+ await response.body.text().catch(() => { });
456
+ throw new ApiError(`Download failed: unresolved redirect (HTTP ${response.statusCode})`, undefined, response.statusCode);
457
+ }
437
458
  const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
438
459
  // From the final (post-redirect) response, so a rate-limited download honors
439
460
  // Retry-After too — every error branch below passes it into the ApiError.
@@ -499,14 +520,17 @@ export class GangtiseClient {
499
520
  // Stream directly to disk when caller already knows the destination
500
521
  if (options?.streamTo) {
501
522
  await fs.mkdir(path.dirname(options.streamTo), { recursive: true });
523
+ // Stream into a .part sibling and rename over the target only on success:
524
+ // writing to the target directly would truncate an existing file on the
525
+ // FIRST byte and delete it on failure — a failed re-download (or each
526
+ // withRetry attempt) must never destroy the user's previous good file.
527
+ const partPath = `${options.streamTo}.part`;
502
528
  try {
503
- await pipeline(response.body, createWriteStream(options.streamTo));
529
+ await pipeline(response.body, createWriteStream(partPath));
530
+ await fs.rename(partPath, options.streamTo);
504
531
  }
505
532
  catch (error) {
506
- // A mid-stream failure leaves a truncated file on disk; remove it so a
507
- // failed download never looks like a complete one. withRetry may still
508
- // replay the request (the next attempt re-creates the file).
509
- await fs.unlink(options.streamTo).catch(() => { });
533
+ await fs.unlink(partPath).catch(() => { });
510
534
  throw error;
511
535
  }
512
536
  logTiming(`GET ${endpoint.path} (stream)`, Date.now() - startedAt, `${response.statusCode}`);
@@ -26,7 +26,9 @@ function truncateFilename(name, maxBytes = 200) {
26
26
  /** Pick a non-existing path by suffixing -1, -2, … before the extension, so batch
27
27
  * downloads whose titles collide ("2025年第一季度报告" from several companies) don't
28
28
  * silently overwrite each other. Only auto-derived names go through this — an
29
- * explicit --output path keeps plain overwrite semantics. */
29
+ * explicit --output path keeps plain overwrite semantics. Throws instead of
30
+ * falling back to the original path once the suffixes run out: returning `p`
31
+ * there would silently overwrite the very first file. */
30
32
  export async function uniquePath(p) {
31
33
  const exists = (f) => fs.access(f).then(() => true, () => false);
32
34
  if (!(await exists(p)))
@@ -38,7 +40,7 @@ export async function uniquePath(p) {
38
40
  if (!(await exists(candidate)))
39
41
  return candidate;
40
42
  }
41
- return p;
43
+ throw new DownloadError(`Refusing to overwrite: 100 files already share the name "${p}" — pass --output or clean up the directory`);
42
44
  }
43
45
  const MIME_EXT = {
44
46
  "application/pdf": ".pdf",
@@ -106,21 +108,70 @@ export async function resolveTitle(client, result, listEndpoint, idField, idValu
106
108
  }
107
109
  async function downloadUrlTo(url, outputPath) {
108
110
  const { createWriteStream } = await import("node:fs");
109
- const { Readable } = await import("node:stream");
110
111
  const { pipeline } = await import("node:stream/promises");
111
112
  const { dirname } = await import("node:path");
112
- const response = await fetch(url);
113
- if (!response.ok || !response.body) {
114
- throw new DownloadError(`Failed to fetch download URL (HTTP ${response.status})`);
115
- }
113
+ const { request } = await import("undici");
114
+ const { getDispatcher, logTiming, withRetry } = await import("./transport.js");
115
+ const { loadConfig } = await import("./config.js");
116
+ // Through the transport layer instead of a bare global fetch: the configured
117
+ // timeout applies (a slow-drip CDN can no longer hang the CLI indefinitely),
118
+ // network-level failures retry, and --verbose logs the request.
119
+ const timeoutMs = loadConfig().timeoutMs;
116
120
  await fs.mkdir(dirname(outputPath), { recursive: true });
117
- try {
118
- await pipeline(Readable.fromWeb(response.body), createWriteStream(outputPath));
119
- }
120
- catch (error) {
121
- await fs.unlink(outputPath).catch(() => { });
122
- throw error;
123
- }
121
+ // Signed URLs carry credentials in the query string — verbose logs must strip
122
+ // search/hash so signatures never land in terminal/CI logs.
123
+ const redactUrl = (u) => {
124
+ try {
125
+ const parsed = new URL(u);
126
+ return parsed.origin + parsed.pathname;
127
+ }
128
+ catch {
129
+ return "signed-url";
130
+ }
131
+ };
132
+ // .part + rename so a failed follow-download never destroys an existing file.
133
+ const partPath = `${outputPath}.part`;
134
+ await withRetry(async () => {
135
+ const startedAt = Date.now();
136
+ const requestOptions = {
137
+ method: "GET",
138
+ headersTimeout: timeoutMs,
139
+ bodyTimeout: timeoutMs,
140
+ dispatcher: getDispatcher(),
141
+ };
142
+ let currentUrl = url;
143
+ let response = await request(currentUrl, requestOptions);
144
+ // undici does not follow redirects (the old global fetch did): a signed URL
145
+ // may 302 to the real CDN object. Follow a bounded number of hops; no
146
+ // Authorization header is involved on this path, so nothing leaks cross-origin.
147
+ for (let hops = 0; hops < 3 && response.statusCode >= 300 && response.statusCode < 400; hops++) {
148
+ const locationHeader = response.headers.location;
149
+ const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader;
150
+ if (!location)
151
+ break;
152
+ await response.body.text().catch(() => { });
153
+ currentUrl = new URL(location, currentUrl).toString();
154
+ response = await request(currentUrl, requestOptions);
155
+ }
156
+ if (response.statusCode >= 300 && response.statusCode < 400) {
157
+ await response.body.text().catch(() => { });
158
+ throw new DownloadError(`Failed to fetch download URL: unresolved redirect (HTTP ${response.statusCode})`);
159
+ }
160
+ if (response.statusCode >= 400) {
161
+ await response.body.text().catch(() => { });
162
+ // DownloadError (not retried): signed URLs expire — replaying a 403 is useless.
163
+ throw new DownloadError(`Failed to fetch download URL (HTTP ${response.statusCode})`);
164
+ }
165
+ try {
166
+ await pipeline(response.body, createWriteStream(partPath));
167
+ }
168
+ catch (error) {
169
+ await fs.unlink(partPath).catch(() => { });
170
+ throw error;
171
+ }
172
+ logTiming(`GET ${redactUrl(currentUrl)}`, Date.now() - startedAt, String(response.statusCode));
173
+ });
174
+ await fs.rename(partPath, outputPath);
124
175
  }
125
176
  export async function saveDownloadResult(result, fallbackName, output) {
126
177
  if (!(result && typeof result === "object")) {
@@ -394,6 +394,7 @@ const ENDPOINT_DEFS = {
394
394
  path: "/application/open-data/ai/search/knowledge/batch",
395
395
  kind: "json",
396
396
  description: "Batch knowledge search",
397
+ retry: "no-replay",
397
398
  },
398
399
  "ai.knowledge-resource.download": {
399
400
  method: "GET",
@@ -414,6 +415,7 @@ const ENDPOINT_DEFS = {
414
415
  kind: "json",
415
416
  description: "Generate one pager",
416
417
  timeoutMs: 120_000,
418
+ retry: "no-replay",
417
419
  },
418
420
  "ai.investment-logic": {
419
421
  method: "POST",
@@ -421,6 +423,7 @@ const ENDPOINT_DEFS = {
421
423
  kind: "json",
422
424
  description: "Generate investment logic",
423
425
  timeoutMs: 120_000,
426
+ retry: "no-replay",
424
427
  },
425
428
  "ai.peer-comparison": {
426
429
  method: "POST",
@@ -428,12 +431,14 @@ const ENDPOINT_DEFS = {
428
431
  kind: "json",
429
432
  description: "Generate peer comparison",
430
433
  timeoutMs: 120_000,
434
+ retry: "no-replay",
431
435
  },
432
436
  "ai.earnings-review.get-id": {
433
437
  method: "POST",
434
438
  path: "/application/open-ai/agent/earnings-review-getid",
435
439
  kind: "json",
436
440
  description: "Get earnings review ID",
441
+ retry: "no-replay",
437
442
  },
438
443
  "ai.earnings-review.get-content": {
439
444
  method: "POST",
@@ -447,6 +452,7 @@ const ENDPOINT_DEFS = {
447
452
  kind: "json",
448
453
  description: "Get theme tracking daily report",
449
454
  timeoutMs: 120_000,
455
+ retry: "no-replay",
450
456
  },
451
457
  "ai.research-outline": {
452
458
  method: "POST",
@@ -454,6 +460,7 @@ const ENDPOINT_DEFS = {
454
460
  kind: "json",
455
461
  description: "Get company research outline",
456
462
  timeoutMs: 120_000,
463
+ retry: "no-replay",
457
464
  },
458
465
  "ai.hot-topic": {
459
466
  method: "POST",
@@ -461,6 +468,7 @@ const ENDPOINT_DEFS = {
461
468
  kind: "json",
462
469
  description: "List hot topic reports",
463
470
  pagination: { enabled: true, maxPageSize: 20 },
471
+ retry: "no-replay",
464
472
  },
465
473
  "ai.management-discuss-announcement": {
466
474
  method: "POST",
@@ -468,6 +476,7 @@ const ENDPOINT_DEFS = {
468
476
  kind: "json",
469
477
  description: "Management discussion from financial reports (half-year/annual)",
470
478
  timeoutMs: 120_000,
479
+ retry: "no-replay",
471
480
  },
472
481
  "ai.management-discuss-earnings-call": {
473
482
  method: "POST",
@@ -475,12 +484,14 @@ const ENDPOINT_DEFS = {
475
484
  kind: "json",
476
485
  description: "Management discussion from earnings calls",
477
486
  timeoutMs: 120_000,
487
+ retry: "no-replay",
478
488
  },
479
489
  "ai.viewpoint-debate.get-id": {
480
490
  method: "POST",
481
491
  path: "/application/open-ai/agent/viewpoint-debate-getid",
482
492
  kind: "json",
483
493
  description: "Get viewpoint debate ID",
494
+ retry: "no-replay",
484
495
  },
485
496
  "ai.viewpoint-debate.get-content": {
486
497
  method: "POST",
@@ -573,12 +584,14 @@ const ENDPOINT_DEFS = {
573
584
  path: "/application/open-alternative/concept/info",
574
585
  kind: "json",
575
586
  description: "Query latest concept (theme index) profile by conceptId",
587
+ retry: "no-replay",
576
588
  },
577
589
  "alternative.concept-securities": {
578
590
  method: "POST",
579
591
  path: "/application/open-alternative/concept/securities",
580
592
  kind: "json",
581
593
  description: "Query concept (theme index) constituent securities, grouped",
594
+ retry: "no-replay",
582
595
  },
583
596
  // ─── indicator (EDE: security-level data indicators) ───
584
597
  "indicator.search": {
@@ -147,7 +147,10 @@ export async function streamOutputToFile(value, format, outputPath) {
147
147
  const { dirname } = await import("node:path");
148
148
  const { createWriteStream } = await import("node:fs");
149
149
  await fs.mkdir(dirname(outputPath), { recursive: true });
150
- const stream = createWriteStream(outputPath, { encoding: "utf8" });
150
+ // Stream into a .part sibling and rename over the target only on success, so a
151
+ // failed re-export never destroys the previous good file.
152
+ const partPath = `${outputPath}.part`;
153
+ const stream = createWriteStream(partPath, { encoding: "utf8" });
151
154
  // A stream 'error' with no listener (EACCES on open, ENOSPC mid-write) crashes the
152
155
  // process before any write callback fires. Swallow the event here — the failure
153
156
  // still surfaces through the write/end callbacks below.
@@ -169,11 +172,20 @@ export async function streamOutputToFile(value, format, outputPath) {
169
172
  await new Promise((resolve, reject) => {
170
173
  stream.end((err) => err ? reject(err) : resolve());
171
174
  });
175
+ await fs.rename(partPath, outputPath);
172
176
  }
173
177
  catch (error) {
174
178
  // Mirror the download path: never leave a truncated file that looks complete.
179
+ // createWriteStream opens lazily — an early abort can reach unlink BEFORE the
180
+ // open() creates the file, so wait for 'close' (fd released or open aborted)
181
+ // or the .part would reappear right after being "removed".
175
182
  stream.destroy();
176
- await fs.unlink(outputPath).catch(() => { });
183
+ // Already-closed happens when the failure was the rename (stream ended fine);
184
+ // then 'close' has fired and waiting for it again would hang forever.
185
+ if (!stream.closed) {
186
+ await new Promise((resolve) => stream.once("close", resolve));
187
+ }
188
+ await fs.unlink(partPath).catch(() => { });
177
189
  throw error;
178
190
  }
179
191
  return true;
@@ -222,9 +234,20 @@ export async function saveOutputIfNeeded(content, outputPath) {
222
234
  }
223
235
  const { dirname } = await import("node:path");
224
236
  await fs.mkdir(dirname(outputPath), { recursive: true });
225
- if (typeof content === "string") {
226
- await fs.writeFile(outputPath, content, "utf8");
227
- return;
237
+ // Write to a .part sibling and rename over the target only on success, so a
238
+ // failed re-export/download never destroys the previous good file.
239
+ const partPath = `${outputPath}.part`;
240
+ try {
241
+ if (typeof content === "string") {
242
+ await fs.writeFile(partPath, content, "utf8");
243
+ }
244
+ else {
245
+ await fs.writeFile(partPath, content);
246
+ }
247
+ await fs.rename(partPath, outputPath);
248
+ }
249
+ catch (error) {
250
+ await fs.unlink(partPath).catch(() => { });
251
+ throw error;
228
252
  }
229
- await fs.writeFile(outputPath, content);
230
253
  }
@@ -65,13 +65,20 @@ export async function runWithConcurrency(items, concurrency, fn) {
65
65
  /** Fan-out width for pagination and kline shards — one env knob tunes both. */
66
66
  export const PAGE_CONCURRENCY = Number(process.env.GANGTISE_PAGE_CONCURRENCY ?? 5) || 5;
67
67
  const RETRYABLE_HTTP_STATUS = new Set([429, 500, 502, 503, 504]);
68
- const RETRYABLE_NETWORK_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
68
+ const RETRYABLE_NETWORK_CODES = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]);
69
69
  const RETRYABLE_API_CODES = new Set(["999999"]);
70
- function isRetryableError(error) {
70
+ // Connect-phase / DNS failures: the request provably never reached the server, so a
71
+ // replay cannot double-execute (or double-bill) anything even under "no-replay".
72
+ const NO_REPLAY_NETWORK_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "UND_ERR_CONNECT_TIMEOUT"]);
73
+ function isRetryableError(error, policy) {
71
74
  if (error && typeof error === "object" && error.__retryable === true) {
72
75
  return true;
73
76
  }
74
77
  if (error instanceof ApiError) {
78
+ if (error.statusCode === 429)
79
+ return true;
80
+ if (policy === "no-replay")
81
+ return false;
75
82
  if (error.statusCode != null && RETRYABLE_HTTP_STATUS.has(error.statusCode))
76
83
  return true;
77
84
  if (error.code && RETRYABLE_API_CODES.has(error.code))
@@ -80,9 +87,11 @@ function isRetryableError(error) {
80
87
  }
81
88
  if (error && typeof error === "object" && "code" in error) {
82
89
  const code = String(error.code);
83
- if (RETRYABLE_NETWORK_CODES.has(code))
90
+ if ((policy === "no-replay" ? NO_REPLAY_NETWORK_CODES : RETRYABLE_NETWORK_CODES).has(code))
84
91
  return true;
85
92
  }
93
+ if (policy === "no-replay")
94
+ return false;
86
95
  if (error instanceof Error && /timeout|ETIMEDOUT|ECONNRESET|socket hang up/i.test(error.message)) {
87
96
  return true;
88
97
  }
@@ -101,7 +110,7 @@ export async function withRetry(fn, options = {}) {
101
110
  return await fn();
102
111
  }
103
112
  catch (error) {
104
- if (attempt >= retries || !isRetryableError(error))
113
+ if (attempt >= retries || !isRetryableError(error, options.policy ?? "default"))
105
114
  throw error;
106
115
  // A server-sent Retry-After (429) wins over exponential backoff, but is capped
107
116
  // so it can't stall the CLI; otherwise fall back to jittered exponential backoff.
@@ -1,2 +1,2 @@
1
1
  // Auto-generated — DO NOT EDIT
2
- export const CLI_VERSION = "0.25.0";
2
+ export const CLI_VERSION = "0.26.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gangtise-openapi-cli",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "CLI for Gangtise OpenAPI",
5
5
  "license": "MIT",
6
6
  "repository": {