feat: first-byte timeout with same-endpoint retry
Some checks failed
Build Docker Image / build (push) Has been cancelled

Upstream sometimes accepts a request (HTTP 200 headers) but stalls without
sending any event-stream packet. Add a configurable timeout that counts
from request dispatch until the first AWS event-stream prelude is read,
and retry on the same endpoint before falling back.

- Config: FirstByteTimeoutSec (default 10s, 0=disabled, range 0-300),
  FirstByteRetries (default 1, range 0-10), with Get/Update helpers.
- kiro.go: parseEventStream signature gains onFirstByte callback, fired
  once when the first 12-byte prelude reads successfully. CallKiroAPI
  wraps each attempt in a context.WithCancel + time.AfterFunc timer that
  cancels the HTTP request if no event arrives before the deadline.
  Separate retry budgets for INVALID_MODEL_ID and first-byte timeout,
  tracked on the same attempt loop; maxAttempts = max(both)+1.
- handler.go: /admin/api/general extended to read/write the two new
  fields with validation (timeout 0-300, retries 0-10).
- web/index.html: General Settings card gains two numeric inputs plus
  CN/EN i18n and the corresponding load/save JS.
This commit is contained in:
2026-05-12 09:04:11 +08:00
parent de4524ad19
commit 89f731cb19
4 changed files with 196 additions and 13 deletions

View File

@@ -977,6 +977,18 @@
<small style="color:#64748b;font-size:12px;margin-top:4px;display:block"
data-i18n="settings.invalidModelRetriesHint"></small>
</div>
<div class="form-group">
<label data-i18n="settings.firstByteTimeoutSec"></label>
<input type="number" id="firstByteTimeoutSec" min="0" max="300" step="1" placeholder="10">
<small style="color:#64748b;font-size:12px;margin-top:4px;display:block"
data-i18n="settings.firstByteTimeoutHint"></small>
</div>
<div class="form-group">
<label data-i18n="settings.firstByteRetries"></label>
<input type="number" id="firstByteRetries" min="0" max="10" step="1" placeholder="1">
<small style="color:#64748b;font-size:12px;margin-top:4px;display:block"
data-i18n="settings.firstByteRetriesHint"></small>
</div>
<button class="btn btn-primary" onclick="saveGeneralConfig()"
data-i18n="settings.saveGeneral"></button>
</div>
@@ -1165,6 +1177,10 @@
'settings.generalSettings': '通用设置',
'settings.invalidModelRetries': 'INVALID_MODEL_ID 同端点重试次数',
'settings.invalidModelRetriesHint': '当上游返回 INVALID_MODEL_IDHTTP 400先在当前端点重试 N 次后再 fallback 到下一个端点。默认 3范围 0-20',
'settings.firstByteTimeoutSec': '首字节超时(秒)',
'settings.firstByteTimeoutHint': '请求发出后,若在 N 秒内未收到上游任何 event-stream 数据包,则判定首字节超时并在同端点重试。默认 10设为 0 禁用。范围 0-300',
'settings.firstByteRetries': '首字节超时同端点重试次数',
'settings.firstByteRetriesHint': '首字节超时发生时,在当前端点重试 N 次后再 fallback 到下一个端点。默认 1范围 0-10',
'settings.saveGeneral': '保存通用设置',
'settings.generalSaved': '通用设置已保存',
'settings.thinkingSettings': 'Thinking 模式设置',
@@ -1386,6 +1402,10 @@
'settings.generalSettings': 'General Settings',
'settings.invalidModelRetries': 'INVALID_MODEL_ID same-endpoint retries',
'settings.invalidModelRetriesHint': 'When upstream returns INVALID_MODEL_ID (HTTP 400), retry the current endpoint N times before falling back. Default 3, range 0-20',
'settings.firstByteTimeoutSec': 'First-byte timeout (seconds)',
'settings.firstByteTimeoutHint': 'After sending a request, if no upstream event-stream packet arrives within N seconds, treat as first-byte timeout and retry on the same endpoint. Default 10, set 0 to disable. Range 0-300',
'settings.firstByteRetries': 'First-byte timeout same-endpoint retries',
'settings.firstByteRetriesHint': 'On first-byte timeout, retry the current endpoint N times before falling back. Default 1, range 0-10',
'settings.saveGeneral': 'Save General Settings',
'settings.generalSaved': 'General settings saved',
'settings.thinkingSettings': 'Thinking Mode Settings',
@@ -2094,19 +2114,32 @@
async function loadGeneralConfig() {
const res = await fetch('/admin/api/general', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
const v = (d && typeof d.invalidModelRetries === 'number') ? d.invalidModelRetries : 3;
document.getElementById('invalidModelRetries').value = v;
document.getElementById('invalidModelRetries').value =
(d && typeof d.invalidModelRetries === 'number') ? d.invalidModelRetries : 3;
document.getElementById('firstByteTimeoutSec').value =
(d && typeof d.firstByteTimeoutSec === 'number') ? d.firstByteTimeoutSec : 10;
document.getElementById('firstByteRetries').value =
(d && typeof d.firstByteRetries === 'number') ? d.firstByteRetries : 1;
}
async function saveGeneralConfig() {
const raw = document.getElementById('invalidModelRetries').value;
const n = parseInt(raw, 10);
const n = parseInt(document.getElementById('invalidModelRetries').value, 10);
const t1 = parseInt(document.getElementById('firstByteTimeoutSec').value, 10);
const r1 = parseInt(document.getElementById('firstByteRetries').value, 10);
if (isNaN(n) || n < 0 || n > 20) {
alert(t('common.saveFailed') + ': 0-20');
alert(t('common.saveFailed') + ': invalidModelRetries 0-20');
return;
}
if (isNaN(t1) || t1 < 0 || t1 > 300) {
alert(t('common.saveFailed') + ': firstByteTimeoutSec 0-300');
return;
}
if (isNaN(r1) || r1 < 0 || r1 > 10) {
alert(t('common.saveFailed') + ': firstByteRetries 0-10');
return;
}
const res = await fetch('/admin/api/general', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ invalidModelRetries: n })
body: JSON.stringify({ invalidModelRetries: n, firstByteTimeoutSec: t1, firstByteRetries: r1 })
});
const d = await res.json();
if (d.success) { alert(t('settings.generalSaved')); } else { alert(t('common.saveFailed') + ': ' + d.error); }