package channelbridge import ( "sync" "time" ) // 本文件承载 IM 入站语音转写的**临时熔断**(切片 IM-ASR;design/06 §1.1): // 上游「再试也是白试」的失败(未配置 / 鉴权失败 / 欠费 / 限流)打开它一小段时间, // 期间**不再发起任何上游调用** —— 既不白等一次超时,也不每条语音都撞一次付费墙。 // // 三条纪律: // - **只对可分类的失败熔断**:判据是 `voice.CircuitWorthy`(哨兵链)。「格式不支持」 // 这类按内容发生的失败**不**熔断,否则一条被拒的语音会让后续语音全部降级。 // - **是临时的**:退避时长逐次翻倍、封顶;任意一次**成功**立即清零恢复,TTL 到期也 // 自动恢复。它刻意**没有**「永久禁用」这一档 —— 拿掉一个能力只能由配置决定。 // - **并发安全**:入站流水线并发(`maxConcurrent`),因此状态只经互斥锁访问。 type voiceBreaker struct { mu sync.Mutex openUntil time.Time failures int } // 熔断的具名常量:起始 5 分钟、逐次翻倍、上限 1 小时。上限同时防止位移溢出。 const ( voiceBreakerBaseBackoff = 5 * time.Minute voiceBreakerMaxBackoff = time.Hour // voiceBreakerMaxShift 是退避翻倍的次数上限(5min << 6 = 320min,已超上限, // 因此再翻也没有意义;同时避免 failures 无界增长后位移溢出)。 voiceBreakerMaxShift = 6 // voiceBreakerMinRetryAfter 是提示文案里最小可展示的剩余退避(避免出现「0s」)。 voiceBreakerMinRetryAfter = time.Second ) // allowed 报告现在是否可以发起一次上游调用。 func (b *voiceBreaker) allowed(now time.Time) bool { b.mu.Lock() defer b.mu.Unlock() return !now.Before(b.openUntil) } // open 记一次「再试也是白试」的失败,并按退避时长打开/延长熔断。 func (b *voiceBreaker) open(now time.Time) { b.mu.Lock() defer b.mu.Unlock() b.failures++ shift := b.failures - 1 if shift > voiceBreakerMaxShift { shift = voiceBreakerMaxShift } backoff := voiceBreakerBaseBackoff << shift if backoff > voiceBreakerMaxBackoff || backoff <= 0 { backoff = voiceBreakerMaxBackoff } b.openUntil = now.Add(backoff) } // succeed 记一次成功:**立即**恢复正常(熔断是临时的,成功即恢复)。 func (b *voiceBreaker) succeed() { b.mu.Lock() defer b.mu.Unlock() b.failures = 0 b.openUntil = time.Time{} } // retryAfter 报告熔断还剩多久(未打开时为 0)。只用于日志与消息里的诚实提示 —— // 调用方**不**据此做任何调度(恢复由时钟与成功两条路决定)。 func (b *voiceBreaker) retryAfter(now time.Time) time.Duration { b.mu.Lock() defer b.mu.Unlock() left := b.openUntil.Sub(now) if left < voiceBreakerMinRetryAfter { return voiceBreakerMinRetryAfter } return left }