package aastro import ( "context" "fmt" "io" "net/http" "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" "github.com/voidrunner3074/aastro/internal/tracing" "github.com/voidrunner3074/aastro/sdk" ) const streamBuffer = 16 * 1024 type proxyCapable interface { proxy(ctx context.Context, w http.ResponseWriter, original *http.Request) error } type trackingWriter struct { http.ResponseWriter written bool statusCode int } func (tw *trackingWriter) WriteHeader(code int) { tw.written = true tw.statusCode = code tw.ResponseWriter.WriteHeader(code) } func (tw *trackingWriter) Write(b []byte) (int, error) { tw.written = true if tw.statusCode == 0 { tw.statusCode = http.StatusOK } return tw.ResponseWriter.Write(b) } func (tw *trackingWriter) Flush() { if f, ok := tw.ResponseWriter.(http.Flusher); ok { f.Flush() } } // handleStreaming streams a request directly to the single upstream without buffering, // enabling SSE and chunked transfer. Request plugins still run. func (r *Router) handleStreaming(w http.ResponseWriter, req *http.Request, f *flow, log *zap.Logger) { if len(f.upstreams) != 1 { log.Error("streaming flow must have exactly one upstream", zap.Int("configured", len(f.upstreams)), ) WriteError(w, ClientErrInternal, http.StatusInternalServerError) return } u := f.upstreams[0] proxy, ok := u.(proxyCapable) if !ok { log.Error("upstream does not support streaming (does not implement proxyCapable)", zap.String("upstream", u.name()), ) WriteError(w, ClientErrInternal, http.StatusInternalServerError) return } rc := http.NewResponseController(w) if err := rc.SetWriteDeadline(time.Time{}); err != nil { log.Warn("cannot disable write deadline for streaming", zap.Error(err)) } if err := rc.SetReadDeadline(time.Time{}); err != nil { log.Warn("cannot disable read deadline for streaming", zap.Error(err)) } actx := newContext(req) if !r.executePlugins(sdk.PluginTypeRequest, w, actx, f, log) { return } tracer := otel.Tracer(tracing.TracerName) ctx, span := tracer.Start(actx.Request().Context(), "aastro.upstream", trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes( attribute.String("aastro.upstream.name", u.name()), attribute.String("aastro.upstream.mode", "streaming"), attribute.String("aastro.flow.path", f.path), ), ) defer span.End() tw := &trackingWriter{ResponseWriter: w} err := proxy.proxy(ctx, tw, actx.Request()) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "streaming upstream error") if !tw.written { WriteError(w, ClientErrUpstreamUnavailable, http.StatusBadGateway) } log.Error("streaming upstream error", zap.Error(err)) } if tw.statusCode != 0 { span.SetAttributes(attribute.Int("http.status_code", tw.statusCode)) if tw.statusCode >= http.StatusInternalServerError && err == nil { span.SetStatus(codes.Error, http.StatusText(tw.statusCode)) } } } // streamCopy copies src to dst, flushing after each read if the writer supports it. // buf size of 16 KiB is intentional: small enough for SSE events, large enough for chunked blobs. func streamCopy(dst http.ResponseWriter, src io.Reader) error { flusher, canFlush := dst.(http.Flusher) buf := make([]byte, streamBuffer) for { n, readErr := src.Read(buf) if n > 0 { if _, writeErr := dst.Write(buf[:n]); writeErr != nil { return fmt.Errorf("write to client: %w", writeErr) } if canFlush { flusher.Flush() } } if readErr == io.EOF { return nil } if readErr != nil { return fmt.Errorf("read from upstream: %w", readErr) } } }