Skip to content

Commit 16dc08d

Browse files
author
SqlRush
committed
Honor remote delivery retry-after
1 parent 0da0517 commit 16dc08d

4 files changed

Lines changed: 120 additions & 2 deletions

File tree

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ M10 补充:remote poll/WebSocket 共用解码器现在兼容 `data`、`event`
131131

132132
M10 补充:remote poll/WebSocket 事件现在会解析 `ack_url`/`ackUrl`/`acknowledge_url`/`receipt_url``lease_id`/`lease_expires_at``ack`/`lease` nested object 元数据;daemon 会在 delivered/duplicate/failed 后对注册 poll/websocket 同源的 ack URL 做 best-effort POST,带 Bearer auth 和 event/status/sent_count/duplicate/error payload,并在 pump state、structured result 和 `/status show remote` 记录 ack event/sent/error 与 lease event 计数;非同源 ack URL 会被拒绝且脱敏;已过期 lease 会被跳过投递并 ack `expired`,同时记录 `lease_expired_count`。更深的 lease renew/refresh 和服务端协议协商仍未完成。
133133

134+
M10 补充:remote ack 和 lease renew 的 transient retry 现在会优先遵守服务端 `Retry-After` header(秒数或 HTTP-date),再回退到本地指数退避,并继续受最大退避上限约束,减少云端 429/503 限流时的协议偏差。
135+
134136
M7 补充:interaction script paste payload 现在接受 ClipboardItem 风格的 `items[].getAsString`/`get_as_string` 以及 `stringData`/`textData` 文本字段,DOM clipboard 录制脚本可直接恢复 pasted text。
135137

136138
M7 补充:scripted task runtime payload 和 task expectation 现在接受 `taskID``jobId``runId``label``displayName``phase``taskState``message``currentStep``percent`/`percentage`/`pct` 等相邻字段,并支持数字 task ID 与数字字符串 progress。

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,7 @@ test/parity/ # golden tests against TS/official behavior
756756
- 本轮补充:`--daemon` 常驻模式现在会在初始 tick 后启动 remote WebSocket stream goroutine,按 heartbeat 间隔重试注册状态,复用 `StreamWebSocketEvents``RemoteTrigger` delivery/dedupe,把推送事件实时注入 running team,并在 `remote-pump.json` 中持续更新 `websocket_stream` transport、frame/connect/reconnect、delivered/duplicate/error 计数;daemon heartbeat/tick 在已注册 `websocket_url` 时会跳过短 WebSocket 读取,避免 stream 和 tick 双连接/重复写 pump state,poll-only 注册仍走原 tick 路径;daemon stop/context cancel 会取消 stream,并在 pump state 与 `/status show remote` 中记录 stream start/end/stop reason。完整云端协议 hardening 仍未完成。
757757
- 本轮补充:remote poll/WebSocket 共用解码器现在兼容 `data``event``remote_event``delivery``payload` 包裹的单条事件,以及这些 wrapper 下的 `events/items/messages/deliveries` 列表;云端可以用 envelope 协议携带 cursor 和事件内容,而无需强制把事件字段铺在顶层。更深的鉴权刷新、ack/lease 和服务端协议协商仍未完成。
758758
- 本轮补充:remote poll/WebSocket 事件现在会解析 `ack_url`/`ackUrl`/`acknowledge_url`/`receipt_url``lease_id`/`lease_expires_at``ack`/`lease` nested object 元数据;daemon 会在 delivered/duplicate/failed 后对注册 poll/websocket 同源的 ack URL 做 best-effort POST,带 Bearer auth 和 event/status/sent_count/duplicate/error payload,并对 transport error、408/429/5xx 做一次短退避重试;pump state、structured result 和 `/status show remote` 会记录 ack event/sent/error 与 lease event 计数;非同源 ack URL 会被拒绝且脱敏;已过期 lease 会被跳过投递并 ack `expired`,同时记录 `lease_expired_count`。更深的服务端协议协商仍未完成。
759+
- 本轮补充:remote ack 和 lease renew 的 transient retry 现在会优先遵守服务端 `Retry-After` header(秒数或 HTTP-date),再回退到本地指数退避,并继续受最大退避上限约束,减少云端 429/503 限流时的协议偏差。
759760
- 本轮补充:daemon remote delivery 现在会对带 `lease_id` 且未过期的事件,在投递前向注册响应提供的同源 `lease_renew_url`/`lease_refresh_url` 做 best-effort POST,携带 Bearer auth、event_id 和 lease_id;renew 对 transport error、408/429/5xx 做一次短退避重试,成功/失败计数会写入 `remote-pump.json`、structured result 和 `/status show remote`。完整租约续期策略和云端协议演进策略仍未完成。
760761

761762
### M11: Bridge 和高级集成

internal/remote/pump.go

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"net/url"
1212
"os"
1313
"path/filepath"
14+
"strconv"
1415
"strings"
1516
"time"
1617

@@ -193,7 +194,7 @@ func SendAck(ctx context.Context, options AckOptions) AckResult {
193194
result.StatusCode = resp.StatusCode
194195
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
195196
result.Error = remoteRegistrationError(resp.Status, body)
196-
if remoteDeliveryStatusRetryable(resp.StatusCode) && attempt < retries && sleepRemoteDeliveryRetry(ctx, options.RetryInitialDelay, options.RetryMaxDelay, attempt) {
197+
if remoteDeliveryStatusRetryable(resp.StatusCode) && attempt < retries && sleepRemoteDeliveryRetryAfter(ctx, resp.Header.Get("Retry-After"), options.RetryInitialDelay, options.RetryMaxDelay, attempt, time.Now()) {
197198
continue
198199
}
199200
return result
@@ -261,7 +262,7 @@ func SendLeaseRenewal(ctx context.Context, options LeaseRenewOptions) LeaseRenew
261262
result.StatusCode = resp.StatusCode
262263
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
263264
result.Error = remoteRegistrationError(resp.Status, body)
264-
if remoteDeliveryStatusRetryable(resp.StatusCode) && attempt < retries && sleepRemoteDeliveryRetry(ctx, options.RetryInitialDelay, options.RetryMaxDelay, attempt) {
265+
if remoteDeliveryStatusRetryable(resp.StatusCode) && attempt < retries && sleepRemoteDeliveryRetryAfter(ctx, resp.Header.Get("Retry-After"), options.RetryInitialDelay, options.RetryMaxDelay, attempt, time.Now()) {
265266
continue
266267
}
267268
return result
@@ -384,6 +385,27 @@ func remoteDeliveryStatusRetryable(status int) bool {
384385
}
385386

386387
func sleepRemoteDeliveryRetry(ctx context.Context, initialDelay, maxDelay time.Duration, attempt int) bool {
388+
return sleepRemoteDeliveryRetryDelay(ctx, remoteDeliveryRetryDelay(initialDelay, maxDelay, attempt))
389+
}
390+
391+
func sleepRemoteDeliveryRetryAfter(ctx context.Context, retryAfter string, initialDelay, maxDelay time.Duration, attempt int, now time.Time) bool {
392+
delay := remoteDeliveryRetryDelay(initialDelay, maxDelay, attempt)
393+
if retryDelay, ok := remoteRetryAfterDelay(retryAfter, now); ok {
394+
delay = retryDelay
395+
if maxDelay <= 0 {
396+
maxDelay = time.Second
397+
}
398+
if delay > maxDelay {
399+
delay = maxDelay
400+
}
401+
if delay < 0 {
402+
delay = 0
403+
}
404+
}
405+
return sleepRemoteDeliveryRetryDelay(ctx, delay)
406+
}
407+
408+
func remoteDeliveryRetryDelay(initialDelay, maxDelay time.Duration, attempt int) time.Duration {
387409
delay := initialDelay
388410
if delay <= 0 {
389411
delay = 100 * time.Millisecond
@@ -401,6 +423,35 @@ func sleepRemoteDeliveryRetry(ctx context.Context, initialDelay, maxDelay time.D
401423
if delay > maxDelay {
402424
delay = maxDelay
403425
}
426+
return delay
427+
}
428+
429+
func remoteRetryAfterDelay(value string, now time.Time) (time.Duration, bool) {
430+
value = strings.TrimSpace(value)
431+
if value == "" {
432+
return 0, false
433+
}
434+
if seconds, err := strconv.ParseInt(value, 10, 64); err == nil {
435+
if seconds < 0 {
436+
return 0, false
437+
}
438+
return time.Duration(seconds) * time.Second, true
439+
}
440+
after, err := http.ParseTime(value)
441+
if err != nil {
442+
return 0, false
443+
}
444+
if now.IsZero() {
445+
now = time.Now()
446+
}
447+
delay := after.Sub(now)
448+
if delay < 0 {
449+
delay = 0
450+
}
451+
return delay, true
452+
}
453+
454+
func sleepRemoteDeliveryRetryDelay(ctx context.Context, delay time.Duration) bool {
404455
timer := time.NewTimer(delay)
405456
defer timer.Stop()
406457
select {

internal/remote/pump_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,37 @@ func TestSendAckRetriesTransientFailure(t *testing.T) {
168168
}
169169
}
170170

171+
func TestSendAckHonorsRetryAfterHeader(t *testing.T) {
172+
calls := 0
173+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
174+
calls++
175+
if calls == 1 {
176+
w.Header().Set("Retry-After", "0")
177+
http.Error(w, "slow down", http.StatusTooManyRequests)
178+
return
179+
}
180+
w.WriteHeader(http.StatusAccepted)
181+
}))
182+
defer server.Close()
183+
184+
start := time.Now()
185+
result := SendAck(context.Background(), AckOptions{
186+
AckURL: server.URL + "/ack",
187+
EventID: "evt-ack",
188+
Status: "delivered",
189+
AllowedOrigins: []string{server.URL + "/poll"},
190+
RetryAttempts: 1,
191+
RetryInitialDelay: time.Second,
192+
RetryMaxDelay: time.Second,
193+
})
194+
if result.Error != "" || result.StatusCode != http.StatusAccepted || result.AttemptCount != 2 || calls != 2 {
195+
t.Fatalf("ack result = %#v calls=%d", result, calls)
196+
}
197+
if elapsed := time.Since(start); elapsed >= 500*time.Millisecond {
198+
t.Fatalf("ack retry ignored Retry-After header; elapsed=%s", elapsed)
199+
}
200+
}
201+
171202
func TestSendAckRejectsDisallowedOriginAndRedactsURL(t *testing.T) {
172203
result := SendAck(context.Background(), AckOptions{
173204
AckURL: "https://user:pass@example.invalid/ack?token=secret",
@@ -240,6 +271,39 @@ func TestSendLeaseRenewalRetriesTransientFailure(t *testing.T) {
240271
}
241272
}
242273

274+
func TestSendLeaseRenewalHonorsRetryAfterHeader(t *testing.T) {
275+
calls := 0
276+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
277+
calls++
278+
if calls == 1 {
279+
w.Header().Set("Retry-After", "0")
280+
http.Error(w, "slow down", http.StatusTooManyRequests)
281+
return
282+
}
283+
w.Header().Set("content-type", "application/json")
284+
w.WriteHeader(http.StatusAccepted)
285+
_, _ = w.Write([]byte(`{"lease_expires_at":"2026-06-17T12:15:00Z"}`))
286+
}))
287+
defer server.Close()
288+
289+
start := time.Now()
290+
result := SendLeaseRenewal(context.Background(), LeaseRenewOptions{
291+
LeaseRenewURL: server.URL + "/leases/renew",
292+
EventID: "evt-renew",
293+
LeaseID: "lease-1",
294+
AllowedOrigins: []string{server.URL + "/poll"},
295+
RetryAttempts: 1,
296+
RetryInitialDelay: time.Second,
297+
RetryMaxDelay: time.Second,
298+
})
299+
if result.Error != "" || result.StatusCode != http.StatusAccepted || result.LeaseExpiresAt != "2026-06-17T12:15:00Z" || result.AttemptCount != 2 || calls != 2 {
300+
t.Fatalf("renew result = %#v calls=%d", result, calls)
301+
}
302+
if elapsed := time.Since(start); elapsed >= 500*time.Millisecond {
303+
t.Fatalf("lease renew retry ignored Retry-After header; elapsed=%s", elapsed)
304+
}
305+
}
306+
243307
func TestSendLeaseRenewalRejectsDisallowedOriginAndRedactsURL(t *testing.T) {
244308
result := SendLeaseRenewal(context.Background(), LeaseRenewOptions{
245309
LeaseRenewURL: "https://user:pass@example.invalid/leases/renew?token=secret",

0 commit comments

Comments
 (0)