Files
2026-08-14 23:41:57 +08:00

394 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// This file implements the shared transport driver (US-007 support): a
// provider-agnostic layer that turns an HTTP request into a stream of
// StreamEvents. Each provider degenerates to a stateful Decoder; the transport
// owns HTTP + SSE line parsing + retry + dual watchdogs + the dual failure
// model.
//
// The design mirrors pi's providerio: the transport never returns a runtime
// failure as a Go error once streaming has begun — it rides the stream as a
// terminal StreamErrorEvent. Only the earliest "cannot build the stream" case
// (bad request construction) is a returned error.
package provider
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/smallnest/pigo/internal/agentcore"
)
// StreamEvent is the transport-level alias for AssistantMessageEvent. Decoders
// produce these; the transport forwards them onto the stream. (Decision #25:
// reuse AssistantMessageEvent rather than a parallel event type.)
type StreamEvent = AssistantMessageEvent
// Decoder is the per-provider stateful SSE payload decoder. The transport calls
// Decode for every complete SSE data payload (one event's worth of bytes) and
// Finish once the stream ends so the decoder can flush any buffered terminal
// event.
type Decoder interface {
// Decode turns one SSE data payload into zero or more StreamEvents. A
// returned error is treated as a runtime stream failure (terminal error
// event), never a panic.
Decode(payload []byte) ([]StreamEvent, error)
// Finish flushes any trailing state, returning a final batch of events.
Finish() ([]StreamEvent, error)
}
// defaultIdleTimeout is the watchdog idle window; PIGO_STREAM_IDLE_TIMEOUT
// (a Go duration string, e.g. "3m") overrides it.
const defaultIdleTimeout = 5 * time.Minute
const (
// defaultMaxConnectRetries bounds connect-only retries when TransportConfig
// leaves MaxConnectRetries at zero.
defaultMaxConnectRetries = 2
// statusTooManyRequestsCF (529) is Cloudflare's "site overloaded" status,
// which some upstreams also emit; treated as retryable alongside 429/503.
statusTooManyRequestsCF = 529
// stallFactor slackens the content-stall watchdog relative to the idle
// window (stall = idle × stallFactor) so a slow-but-progressing stream is not
// killed by the stall guard.
stallFactor = 1.2
// errorBodyLimit bounds how many bytes of an upstream error body are read
// into the returned error message.
errorBodyLimit = 4096
)
// idleTimeout resolves the configured idle watchdog window.
func idleTimeout() time.Duration {
if v := os.Getenv("PIGO_STREAM_IDLE_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
}
return defaultIdleTimeout
}
// TransportConfig configures a single StreamRequest run.
type TransportConfig struct {
// Client is the HTTP client; defaults to http.DefaultClient when nil.
Client *http.Client
// NewRequest builds a fresh *http.Request for each connection attempt. It is
// called once per connect (initial + reconnects) so retries never replay a
// consumed body — the caller owns idempotent request construction.
NewRequest func(ctx context.Context) (*http.Request, error)
// Decoder converts SSE payloads to StreamEvents (required).
Decoder Decoder
// MaxConnectRetries bounds connect-only retries (default 2).
MaxConnectRetries int
}
// StreamRequest runs cfg as a transport stream. Per the dual failure model it
// returns an error only when the very first request cannot be built or the
// initial connection can never be established; every runtime failure once
// streaming begins rides the returned stream as a terminal StreamErrorEvent.
func StreamRequest(ctx context.Context, cfg TransportConfig) (*AssistantMessageEventStream, error) {
if cfg.NewRequest == nil {
return nil, errors.New("transport: NewRequest is required")
}
if cfg.Decoder == nil {
return nil, errors.New("transport: Decoder is required")
}
client := cfg.Client
if client == nil {
client = http.DefaultClient
}
maxRetries := cfg.MaxConnectRetries
if maxRetries == 0 {
maxRetries = defaultMaxConnectRetries
}
// Connect once up front so a "cannot even build the stream" failure surfaces
// as a returned error (the only early-error case per FR-13).
resp, err := connect(ctx, client, cfg.NewRequest, maxRetries)
if err != nil {
return nil, err
}
stream := NewAssistantMessageEventStream(0)
go pump(ctx, stream, resp, cfg.Decoder)
return stream, nil
}
// connect performs the initial connection with retry. It only retries when the
// server explicitly signals a retryable condition (429/503/529); it never
// replays a consumed stream, so retrying at connect time is always safe.
func connect(ctx context.Context, client *http.Client, newReq func(context.Context) (*http.Request, error), maxRetries int) (*http.Response, error) {
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
req, err := newReq(ctx)
if err != nil {
return nil, fmt.Errorf("transport: build request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
lastErr = classifyTransportError(err)
if !isRetryableNetErr(err) || attempt == maxRetries {
return nil, lastErr
}
if !sleepBackoff(ctx, attempt, 0) {
return nil, ctx.Err()
}
continue
}
if resp.StatusCode == http.StatusTooManyRequests ||
resp.StatusCode == http.StatusServiceUnavailable ||
resp.StatusCode == statusTooManyRequestsCF {
wait := retryAfter(resp.Header)
resp.Body.Close()
lastErr = fmt.Errorf("transport: upstream %d", resp.StatusCode)
if attempt == maxRetries {
return nil, lastErr
}
if !sleepBackoff(ctx, attempt, wait) {
return nil, ctx.Err()
}
continue
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, errorBodyLimit))
resp.Body.Close()
return nil, fmt.Errorf("transport: upstream %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return resp, nil
}
return nil, lastErr
}
// pump drives the SSE read loop with dual watchdogs, decoding payloads and
// forwarding events onto the stream. All runtime failures become a terminal
// error event; pump always closes the stream.
func pump(ctx context.Context, stream *AssistantMessageEventStream, resp *http.Response, dec Decoder) {
defer stream.Close()
defer resp.Body.Close()
idle := idleTimeout()
// content-stall watchdog is slightly slacker than idle (idle × stallFactor)
// so a slow but progressing stream is not killed by the stall guard.
stall := time.Duration(float64(idle) * stallFactor)
// The watchdog fires by cancelling a derived context; reads race against it.
watchCtx, cancel := context.WithCancel(ctx)
defer cancel()
// done stops the reader goroutine so it never blocks on a send after pump
// returns (watchdog / abort paths), avoiding a goroutine leak.
done := make(chan struct{})
defer close(done)
lines := make(chan string)
readErr := make(chan error, 1)
go readLines(resp.Body, lines, readErr, done)
var dataBuf strings.Builder
idleTimer := time.NewTimer(idle)
stallTimer := time.NewTimer(stall)
defer idleTimer.Stop()
defer stallTimer.Stop()
emit := func(events []StreamEvent) bool {
for _, ev := range events {
if err := stream.Emit(watchCtx, ev); err != nil {
return false
}
}
return true
}
fail := func(msg string, err error) {
stream.Emit(context.Background(), StreamErrorEvent{
Message: agentcore.AssistantMessage{
RoleField: agentcore.RoleAssistant,
StopReason: agentcore.StopReasonError,
ErrorMessage: msg,
},
Err: err,
})
}
flush := func() bool {
if dataBuf.Len() == 0 {
return true
}
payload := dataBuf.String()
dataBuf.Reset()
if payload == "[DONE]" {
return true
}
events, err := dec.Decode([]byte(payload))
if err != nil {
fail("decode error: "+err.Error(), err)
return false
}
return emit(events)
}
for {
select {
case <-ctx.Done():
fail("stream aborted", ctx.Err())
return
case <-idleTimer.C:
fail("idle timeout: no data received", errStreamIdle)
return
case <-stallTimer.C:
fail("content stall timeout", errStreamStall)
return
case err := <-readErr:
if err != nil && !errors.Is(err, io.EOF) {
fail("read error: "+classifyTransportError(err).Error(), err)
return
}
// Clean EOF: flush any buffered payload, then finish the decoder.
if !flush() {
return
}
finalEvents, ferr := dec.Finish()
if ferr != nil {
fail("finish error: "+ferr.Error(), ferr)
return
}
emit(finalEvents)
return
case line, ok := <-lines:
if !ok {
continue
}
// Any byte resets the idle watchdog; a flushed event resets stall.
resetTimer(idleTimer, idle)
line = strings.TrimRight(line, "\r\n")
switch {
case line == "":
// Blank line = event boundary: flush accumulated data.
if !flush() {
return
}
resetTimer(stallTimer, stall)
case strings.HasPrefix(line, ":"):
// Comment / keep-alive: ignore payload, watchdog already reset.
case strings.HasPrefix(line, "data:"):
dataBuf.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
default:
// Non-data field (event:, id:, etc.) — ignored for our decoders.
}
}
}
}
// readLines reads the body line by line, pushing each onto lines and the final
// error (io.EOF on clean close) onto readErr. It stops promptly when done is
// closed so pump can return on a watchdog/abort without leaking this goroutine.
func readLines(r io.Reader, lines chan<- string, readErr chan<- error, done <-chan struct{}) {
br := bufio.NewReader(r)
for {
line, err := br.ReadString('\n')
if line != "" {
select {
case lines <- line:
case <-done:
return
}
}
if err != nil {
select {
case readErr <- err:
case <-done:
}
return
}
}
}
// resetTimer stops and re-arms t to fire after d.
func resetTimer(t *time.Timer, d time.Duration) {
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(d)
}
// Sentinel errors for watchdog classification.
var (
errStreamIdle = errors.New("stream idle timeout")
errStreamStall = errors.New("stream content stall")
)
// retryAfter parses a Retry-After header (seconds or HTTP-date), returning 0
// when absent/unparseable.
func retryAfter(h http.Header) time.Duration {
v := h.Get("Retry-After")
if v == "" {
return 0
}
if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
return time.Duration(secs) * time.Second
}
if t, err := http.ParseTime(v); err == nil {
if d := time.Until(t); d > 0 {
return d
}
}
return 0
}
// sleepBackoff waits for the retry delay (Retry-After if given, else
// exponential), honoring ctx cancellation. Returns false if ctx was cancelled.
func sleepBackoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
d := retryAfter
if d == 0 {
d = time.Duration(1<<uint(attempt)) * time.Second
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
// isRetryableNetErr reports whether a client.Do error is a transient network
// condition worth reconnecting for (timeout / temporary).
func isRetryableNetErr(err error) bool {
var netErr net.Error
if errors.As(err, &netErr) {
return netErr.Timeout()
}
return false
}
// classifyTransportError maps a low-level error to a typed, descriptive error
// using net.Error / errors.Is classification.
func classifyTransportError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, context.Canceled) {
return fmt.Errorf("transport: canceled: %w", err)
}
if errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("transport: deadline exceeded: %w", err)
}
var netErr net.Error
if errors.As(err, &netErr) {
if netErr.Timeout() {
return fmt.Errorf("transport: network timeout: %w", err)
}
return fmt.Errorf("transport: network error: %w", err)
}
return fmt.Errorf("transport: %w", err)
}