From a220fa9e6b928dd402b50cfc7a8d496b4691d5c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Troed=20S=C3=A5ngberg?= Date: Sat, 1 Aug 2026 11:08:56 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20v1.2.2=20=E2=80=94=20add=20staggered?= =?UTF-8?q?=20backoff=20for=20llama-server=20polling=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +++- package.json | 4 ++-- src/stats.ts | 4 ++++ test-backoff.ts | 28 ++++++++++++++++++++++++++++ tui.tsx | 29 +++++++++++++++++++++++++++-- 5 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 test-backoff.ts diff --git a/README.md b/README.md index 4772b3c..5bb461a 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ The plugin discovers the llama-server URL by reading the OpenCode configuration Every 500ms, the plugin polls `GET /slots?model=` on each discovered server. The model parameter is required by the `/slots` endpoint. If the model cannot be discovered from the current route's session, the plugin skips polling. +When a server returns an error (e.g. HTTP 502) or is unreachable, the plugin backs off with an exponentially increasing delay per server (1s, 2s, 4s, ...) capped at 10s, with a small random jitter to stagger multiple servers. Backoff resets as soon as the server responds successfully again. + ### State Classification Each processing slot is classified as prefill (PP) or generation (TG) by comparing `n_decoded` and `n_prompt_tokens` against per-slot/per-task baselines. The plugin tracks two baseline maps keyed by slot ID: @@ -114,7 +116,7 @@ The following changes to the `/slots` endpoint would improve the plugin's functi For known issues, posting new ones, forking or contributing: -https://codeberg.org/troed/oc-ls-stats +https://git.sync.wtf/troed/oc-ls-stats ## Debug Logging diff --git a/package.json b/package.json index 625fe71..a088261 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@troed/oc-ls-stats", - "version": "1.2.1", + "version": "1.2.2", "type": "module", "exports": { "./tui": { @@ -9,7 +9,7 @@ } }, "scripts": { - "prepack": "npx -y tsx test-stats.ts" + "prepack": "npx -y tsx test-backoff.ts" }, "engines": { "opencode": ">=1.3.14" diff --git a/src/stats.ts b/src/stats.ts index f1c9889..4d05ec6 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -1,3 +1,7 @@ +export function backoffDelayMs(failures: number, baseMs: number = 1000, maxMs: number = 10000): number { + return Math.min(maxMs, baseMs * 2 ** (failures - 1)) +} + export interface SlotState { id: number is_processing: boolean diff --git a/test-backoff.ts b/test-backoff.ts new file mode 100644 index 0000000..de768af --- /dev/null +++ b/test-backoff.ts @@ -0,0 +1,28 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { backoffDelayMs } from "./src/stats.ts" + +test("first failure retries after the base delay", () => { + assert.equal(backoffDelayMs(1), 1000) +}) + +test("second failure doubles the delay", () => { + assert.equal(backoffDelayMs(2), 2000) +}) + +test("delay grows exponentially", () => { + assert.equal(backoffDelayMs(3), 4000) + assert.equal(backoffDelayMs(4), 8000) +}) + +test("delay is capped at the maximum", () => { + assert.equal(backoffDelayMs(5), 10000) + assert.equal(backoffDelayMs(10), 10000) + assert.equal(backoffDelayMs(20), 10000) +}) + +test("uses provided base and max", () => { + assert.equal(backoffDelayMs(1, 200, 5000), 200) + assert.equal(backoffDelayMs(5, 200, 5000), 3200) + assert.equal(backoffDelayMs(20, 200, 5000), 5000) +}) diff --git a/tui.tsx b/tui.tsx index dba8236..47268db 100644 --- a/tui.tsx +++ b/tui.tsx @@ -2,7 +2,7 @@ import type { TextRenderable } from "@opentui/core" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" import { onCleanup } from "solid-js" -import { processPoll } from "./src/stats.ts" +import { backoffDelayMs, processPoll } from "./src/stats.ts" type StreamSample = { at: number @@ -11,6 +11,8 @@ type StreamSample = { const STREAM_WINDOW_MS = 5_000 const SLOT_POLL_MS = 500 +const BACKOFF_BASE_MS = 1_000 +const BACKOFF_MAX_MS = 10_000 type MessageTiming = { sessionID: string requestStartAt: number @@ -214,6 +216,7 @@ const tui: TuiPlugin = async (api) => { let llamaServerUrls: string[] = [] let llamaServerModel: string | undefined + const backoffByUrl = new Map() const pollMetrics = async () => { if (!llamaServerUrls.length) return @@ -230,8 +233,30 @@ const tui: TuiPlugin = async (api) => { } for (const baseUrl of llamaServerUrls) { + const now = Date.now() + const backoff = backoffByUrl.get(baseUrl) + if (backoff && now < backoff.nextRetryAt) { + tracker.failure = "n/a" + bump() + continue + } + const slots = await fetchSlots(baseUrl, model) - const slotList = Array.isArray(slots) ? slots : slots ? Object.values(slots) : [] + if (slots == null) { + const failures = (backoff?.failures ?? 0) + 1 + const delay = backoffDelayMs(failures, BACKOFF_BASE_MS, BACKOFF_MAX_MS) + const jitter = Math.floor(Math.random() * (delay / 4)) + backoffByUrl.set(baseUrl, { + failures, + nextRetryAt: Date.now() + Math.min(delay + jitter, BACKOFF_MAX_MS), + }) + tracker.failure = "n/a" + bump() + continue + } + + backoffByUrl.delete(baseUrl) + const slotList = Array.isArray(slots) ? slots : Object.values(slots) if (slotList.length === 0) { tracker.failure = "n/a" bump()