fix(tools): forgejo fetch timeout + retries #41

Merged
slaid098 merged 4 commits from fix/tools/forgejo-fetch-timeout into main 2026-08-11 15:56:30 +03:00
Owner

Что сделано

Обёрнут fetch() в callForgejo (_shared.ts) в retry-цикл с таймаутом через AbortSignal.any([userSignal, AbortSignal.timeout(timeoutMs)]). Retry на timeout/5xx, НЕ на 4xx. Skip sleep на первой попытке (как pipeline-status.py:596). Худший кейс latency: 10×3 + 2×1 = 32с (вместо 5 минут).

  • resolveForgejoRepo: spawnSync получил timeout: 5000 (раньше вис бесконечно).
  • callForgejoGh, runGh, getPull, getRepo, resolveLabelIds: добавлен опциональный signal?: AbortSignal, проброшен до callForgejo.
  • create-issue.ts, create-pr.ts, merge-pr.ts, post-review.ts: проброс context.abort (SDK ToolContext.abort: AbortSignal) — пользовательская отмена tool-call из UI теперь работает.
  • Env vars: OPENCODE_FORGEJO_TIMEOUT (10с), OPENCODE_FORGEJO_RETRY (3), OPENCODE_FORGEJO_RETRY_INTERVAL (1с) через helper _envInt.
  • Логи ретраев/ошибок в process.stderr.write с [forgejo] prefix (конвенция репо, NOT console.*). Успешные вызовы не логируются.
  • Тесты: новый .opencode/tools/_shared.test.ts (Bun test) — 11 кейсов: timeout→retry, 5xx→retry, 4xx no-retry, retries-exhausted readable error, user-abort no-retry, success no-retry, retry-after-5xx-success, RETRY=0 fallback, resolveLabelIds timeout, resolveForgejoRepo spawnSync timeout, explicit repo.
  • test_create_issue_tool.py::test_labels_lookup_failure обновлён: 500 теперь ретраит (3 вызова), тест использует OPENCODE_FORGEJO_RETRY=1 для мгновенного фейла.

Почему

create-issue tool висел ~5 минут после инвокации. fetch() к Forgejo API вызывался без signal/таймаута — Bun.fetch не имеет дефолтного таймаута. Forgejo за Nginx + SQLite (write-lock contention) — 5 минут серверсайд-таймаут, пока fetch покорно ждал. context.abort существовал в SDK, но нигде не использовался — отмена из UI не работала.

Watch out

  • AbortSignal.any и AbortSignal.timeout требуют Bun 1.2+ (подтверждено через Context7, C++ реализация в AbortSignal.cpp). Node 22+ также поддерживает (проверено — тесты через _ts_loader.mjs работают).
  • _ts_loader.mjs не стрипает as с составными выражениями (r.error as NodeJS.ErrnoException) — избегал as в выражениях, использовал String() проверку для ETIMEDOUT.
  • Существующие Python-тесты с 5xx responses теперь получают 3 fetch-вызова (retry). Если в будущем добавятся 5xx кейсы — устанавливайте OPENCODE_FORGEJO_RETRY=1 в env.
  • OPENCODE_FORGEJO_TIMEOUT=0 — невалидный, fallback на дефолт через _envInt (Number.isFinite + n >= 0, но 0×1000=0ms = мгновенный таймаут — в проде не ставьте 0).
  • node_modules/ и untracked SVG-шаблоны в .opencode/draw-image/templates/ НЕ закоммичены (были до меня, вне scope).

Pending

  • Bun test (_shared.test.ts) не запускается в CI (bun не установлен в Docker-образе) — файл будет работать на машине с bun. Python-тесты через _ts_loader.mjs покрывают retry-логику.
  • Server-side Forgejo (SQLite PRAGMA busy_timeout, Nginx proxy_read_timeout) — отдельный issue в forgejo-infra.

Closes #40

Closes #40

## Что сделано Обёрнут `fetch()` в `callForgejo` (`_shared.ts`) в retry-цикл с таймаутом через `AbortSignal.any([userSignal, AbortSignal.timeout(timeoutMs)])`. Retry на timeout/5xx, НЕ на 4xx. Skip sleep на первой попытке (как `pipeline-status.py:596`). Худший кейс latency: 10×3 + 2×1 = 32с (вместо 5 минут). - `resolveForgejoRepo`: `spawnSync` получил `timeout: 5000` (раньше вис бесконечно). - `callForgejoGh`, `runGh`, `getPull`, `getRepo`, `resolveLabelIds`: добавлен опциональный `signal?: AbortSignal`, проброшен до `callForgejo`. - `create-issue.ts`, `create-pr.ts`, `merge-pr.ts`, `post-review.ts`: проброс `context.abort` (SDK `ToolContext.abort: AbortSignal`) — пользовательская отмена tool-call из UI теперь работает. - Env vars: `OPENCODE_FORGEJO_TIMEOUT` (10с), `OPENCODE_FORGEJO_RETRY` (3), `OPENCODE_FORGEJO_RETRY_INTERVAL` (1с) через helper `_envInt`. - Логи ретраев/ошибок в `process.stderr.write` с `[forgejo]` prefix (конвенция репо, NOT `console.*`). Успешные вызовы не логируются. - Тесты: новый `.opencode/tools/_shared.test.ts` (Bun test) — 11 кейсов: timeout→retry, 5xx→retry, 4xx no-retry, retries-exhausted readable error, user-abort no-retry, success no-retry, retry-after-5xx-success, RETRY=0 fallback, resolveLabelIds timeout, resolveForgejoRepo spawnSync timeout, explicit repo. - `test_create_issue_tool.py::test_labels_lookup_failure` обновлён: 500 теперь ретраит (3 вызова), тест использует `OPENCODE_FORGEJO_RETRY=1` для мгновенного фейла. ## Почему `create-issue` tool висел ~5 минут после инвокации. `fetch()` к Forgejo API вызывался без `signal`/таймаута — Bun.fetch не имеет дефолтного таймаута. Forgejo за Nginx + SQLite (write-lock contention) — 5 минут серверсайд-таймаут, пока fetch покорно ждал. `context.abort` существовал в SDK, но нигде не использовался — отмена из UI не работала. ## Watch out - `AbortSignal.any` и `AbortSignal.timeout` требуют Bun 1.2+ (подтверждено через Context7, C++ реализация в `AbortSignal.cpp`). Node 22+ также поддерживает (проверено — тесты через `_ts_loader.mjs` работают). - `_ts_loader.mjs` не стрипает `as` с составными выражениями (`r.error as NodeJS.ErrnoException`) — избегал `as` в выражениях, использовал `String()` проверку для ETIMEDOUT. - Существующие Python-тесты с 5xx responses теперь получают 3 fetch-вызова (retry). Если в будущем добавятся 5xx кейсы — устанавливайте `OPENCODE_FORGEJO_RETRY=1` в env. - `OPENCODE_FORGEJO_TIMEOUT=0` — невалидный, fallback на дефолт через `_envInt` (Number.isFinite + n >= 0, но 0×1000=0ms = мгновенный таймаут — в проде не ставьте 0). - `node_modules/` и untracked SVG-шаблоны в `.opencode/draw-image/templates/` НЕ закоммичены (были до меня, вне scope). ## Pending - Bun test (`_shared.test.ts`) не запускается в CI (bun не установлен в Docker-образе) — файл будет работать на машине с bun. Python-тесты через `_ts_loader.mjs` покрывают retry-логику. - Server-side Forgejo (SQLite `PRAGMA busy_timeout`, Nginx `proxy_read_timeout`) — отдельный issue в `forgejo-infra`. Closes #40 Closes #40
docs(env): document forgejo timeout and retry env vars
All checks were successful
CI (always) / bootstrap (pull_request) Successful in 4s
CI / bootstrap (pull_request) Successful in 6s
CI / complexity (pull_request) Successful in 26s
CI / lint (pull_request) Successful in 27s
CI / typecheck (pull_request) Successful in 27s
CI / test (3.13) (pull_request) Successful in 1m38s
5bb7d16c5b
Author
Owner

Code Review Summary

PR добавляет таймаут и retry-логику в callForgejo (.opencode/tools/_shared.ts) и пробрасывает context.abort во все 4 tool-файла (create-issue, create-pr, merge-pr, post-review). 8 файлов, +389/−32. Качество реализации высокое, тесты покрывают все ветки.

Что правильно

  • AbortSignal.any([timeout, userSignal]) — корректная композиция таймаута и user-abort в один signal. Fallback на единственный signal когда userSignal не передан — грамотно.
  • Retry-цикл: retry только на 5xx и TimeoutError/AbortError; 4xx — немедленный fail без retry. Это правильная стратегия (4xx — клиентская ошибка, повтор не поможет).
  • User-abort не триггерит retry: проверка userSignal?.aborted перед каждой попыткой и в catch-блоке. AbortController пользователя уважается.
  • _sleep с cleanup: clearTimeout + { once: true } abort-listener — нет утечки таймеров. При ms <= 0 — no-op (skip-sleep-on-first не нужен, т.к. sleep только между попытками attempt < maxAttempts).
  • _envInt: Number.isFinite + >= 0 + Math.trunc — защита от NaN, Infinity, отрицательных и дробных значений. Fallback при undefined/"".
  • resolveForgejoRepo: spawnSync с timeout: 5000 — нет зависания при подвисшем git. Проверка ETIMEDOUT логирует в stderr.
  • _forgejoLog в stderr — не засоряет stdout (который парсится как JSON tool-результат).
  • Backward compat: signal — optional параметр в конце сигнатуры всех функций (runGh, getPull, getRepo, resolveLabelIds, callForgejoGh). Существующие вызовы без signal продолжают работать.
  • Тесты (10 cases): timeout→retry, 5xx→retry, 4xx→no-retry, user-abort→no-retry, success→no-retry, retry-succeeds-after-5xx, RETRY=0→1-attempt, label-timeout, spawnSync-timeout, explicit-repo. Покрытие релевантных веток полное.
  • .env.example: документация с формулой worst-case latency (10×3 + 2×1 = 32s) — помогает оператору понять потолок задержки.
  • Python-тест адаптирован: OPENCODE_FORGEJO_RETRY=1 в test_labels_lookup_failure — тест остаётся детерминированным (1 fetch, не 3 retry).

Замечания (info, не блокирующие)

  • _shared.ts:55-58 [dead-log] Проверка r.error && String(r.error).includes("ETIMEDOUT") стоит после if (r.status !== 0) return null. При ETIMEDOUT r.status = null (≠ 0) → return null срабатывает раньше → log-line недостижим. Функционально return null корректен, но log-сообщение мёртвое. Можно перенести проверку error выше проверки status, либо убрать log.
  • _shared.test.ts:256 [style] Нет trailing newline (EOF without newline). Minor, не влияет на работу.
  • _shared.test.ts [test-hygiene] globalThis.fetch = fetchMock — глобальный mock, но в afterEach восстанавливается только env, не fetch. Между тестами в одном describe mock накапливается, но т.к. каждый тест заново делает await import("./_shared") после установки mock — работает. Для строгости можно добавить originalFetch save/restore в beforeEach/afterEach.

Замечаний критического уровня нет. PR готов к merge.

Verdict: APPROVE

## Code Review Summary PR добавляет таймаут и retry-логику в `callForgejo` (`.opencode/tools/_shared.ts`) и пробрасывает `context.abort` во все 4 tool-файла (`create-issue`, `create-pr`, `merge-pr`, `post-review`). 8 файлов, +389/−32. Качество реализации высокое, тесты покрывают все ветки. ### Что правильно - **AbortSignal.any([timeout, userSignal])** — корректная композиция таймаута и user-abort в один signal. Fallback на единственный signal когда userSignal не передан — грамотно. - **Retry-цикл**: retry только на 5xx и TimeoutError/AbortError; 4xx — немедленный fail без retry. Это правильная стратегия (4xx — клиентская ошибка, повтор не поможет). - **User-abort не триггерит retry**: проверка `userSignal?.aborted` перед каждой попыткой и в catch-блоке. AbortController пользователя уважается. - **`_sleep` с cleanup**: `clearTimeout` + `{ once: true }` abort-listener — нет утечки таймеров. При `ms <= 0` — no-op (skip-sleep-on-first не нужен, т.к. sleep только между попытками `attempt < maxAttempts`). - **`_envInt`**: `Number.isFinite` + `>= 0` + `Math.trunc` — защита от NaN, Infinity, отрицательных и дробных значений. Fallback при undefined/"". - **`resolveForgejoRepo`**: `spawnSync` с `timeout: 5000` — нет зависания при подвисшем git. Проверка ETIMEDOUT логирует в stderr. - **`_forgejoLog` в stderr** — не засоряет stdout (который парсится как JSON tool-результат). - **Backward compat**: `signal` — optional параметр в конце сигнатуры всех функций (`runGh`, `getPull`, `getRepo`, `resolveLabelIds`, `callForgejoGh`). Существующие вызовы без signal продолжают работать. - **Тесты (10 cases)**: timeout→retry, 5xx→retry, 4xx→no-retry, user-abort→no-retry, success→no-retry, retry-succeeds-after-5xx, RETRY=0→1-attempt, label-timeout, spawnSync-timeout, explicit-repo. Покрытие релевантных веток полное. - **`.env.example`**: документация с формулой worst-case latency (10×3 + 2×1 = 32s) — помогает оператору понять потолок задержки. - **Python-тест адаптирован**: `OPENCODE_FORGEJO_RETRY=1` в `test_labels_lookup_failure` — тест остаётся детерминированным (1 fetch, не 3 retry). ### Замечания (info, не блокирующие) - **`_shared.ts:55-58`** [dead-log] Проверка `r.error && String(r.error).includes("ETIMEDOUT")` стоит после `if (r.status !== 0) return null`. При ETIMEDOUT `r.status` = null (≠ 0) → return null срабатывает раньше → log-line недостижим. Функционально return null корректен, но log-сообщение мёртвое. Можно перенести проверку error выше проверки status, либо убрать log. - **`_shared.test.ts:256`** [style] Нет trailing newline (EOF without newline). Minor, не влияет на работу. - **`_shared.test.ts`** [test-hygiene] `globalThis.fetch = fetchMock` — глобальный mock, но в `afterEach` восстанавливается только env, не fetch. Между тестами в одном `describe` mock накапливается, но т.к. каждый тест заново делает `await import("./_shared")` после установки mock — работает. Для строгости можно добавить `originalFetch` save/restore в beforeEach/afterEach. Замечаний критического уровня нет. PR готов к merge. ### Verdict: APPROVE
slaid098 deleted branch fix/tools/forgejo-fetch-timeout 2026-08-11 15:56:30 +03:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
slaid098/opencode-config!41
No description provided.