DEV Community

vikram parihar
vikram parihar

Posted on

Troubleshooting HTTP/3 QUIC Reverse Proxy for Chunked Uploads to S3 Pre-Signed URLs

Hello Community!!

I’m working on a project where I’m using a QUIC-based reverse proxy (implemented with the quic-go library) to forward chunked data uploads to AWS S3 pre-signed URLs. Here’s an overview of my setup, goals, and the issues I’m facing:

Setup Server:

A custom HTTP/3 QUIC server listens on a specific endpoint (e.g., /post-reverse) to receive PUT requests with chunked data. The request contains: Chunked data in the body. A custom header (X-Presigned-URL) with the target S3 pre-signed URL. Upon receiving the request: The server extracts the X-Presigned-URL from the headers. It forwards the request body to the pre-signed URL using a reverse proxy mechanism. It streams the response from S3 back to the client.

package main

import (
    "context"
    "errors"
    "fmt"
    "log/slog"
    "net/http"
    "net/http/httputil"
    "net/url"
    "os"
    "os/signal"
    "time"

    "github.com/Private-repo/go-httperr"
    "github.com/Private-repo/go-reqlog"
    "github.com/quic-go/quic-go"
    "github.com/quic-go/quic-go/http3"
    "github.com/quic-go/quic-go/qlog"
)

const (
    Host                     = "0.0.0.0"
    Port                     = 4242
    webServerShutdownTimeout = 5 * time.Second
)

func main() {

    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    mux := http.NewServeMux()

    // V1 APIs
    mux.Handle("/post-reverse", httperr.HandlerFunc(ReverseProxy))

    hdlr := reqlog.RequestLogger(mux, nil)
    addr := fmt.Sprintf("%s:%d", Host, Port)

    server := http3.Server{
        Handler: hdlr,
        Addr:    addr,
        QUICConfig: &quic.Config{
            Tracer:    qlog.DefaultConnectionTracer,
        },
    }

    go func() {
        if err := server.ListenAndServeTLS("server.crt", "server.key"); err != nil && !errors.Is(err, http.ErrServerClosed) {
            slog.ErrorContext(ctx, "error starting server", "error", err)
            os.Exit(1)
        }
    }()

    slog.InfoContext(ctx, "started UDP-Srv", "addr", addr)

    <-ctx.Done()

    // Shutdown gracefully.
    ctx, cancel = context.WithTimeout(context.Background(), webServerShutdownTimeout)
    defer cancel()

    slog.InfoContext(ctx, "shutting down")
    if err := server.Shutdown(ctx) ; err != nil {
        slog.ErrorContext(ctx, "http server shutdown error", "error", err)
    }
}

func ReverseProxy(w http.ResponseWriter, r *http.Request) error {
    slog.Info("ReverseProxy called", "method", r.Method, "path", r.URL.Path)

    if r.Method != http.MethodPut {
        slog.Warn("Method not allowed", "method", r.Method)
        return httperr.Errorf(http.StatusMethodNotAllowed, "Method not allowed")
    }

    // Extract Pre-Signed URL
    presignedURL := r.Header.Get("X-Presigned-URL")
    if presignedURL == "" {
        slog.Warn("Missing X-Presigned-URL header")
        return httperr.Errorf(http.StatusBadRequest, "Missing X-Presigned-URL header")
    }

    // Validate Pre-Signed URL
    url, err := url.Parse(presignedURL)
    if err != nil {
        slog.Warn("Invalid X-Presigned-URL header", "error", err)
        return httperr.Errorf(http.StatusBadRequest, "Invalid X-Presigned-URL header")
    }
    slog.Info("Using Pre-Signed URL", "url", presignedURL)

    // Configure reverse proxy
    proxy := httputil.NewSingleHostReverseProxy(url)
    proxy.Director = func(req *http.Request) {
        req.URL = url
        req.Host = url.Host
        req.Method = r.Method
        req.Header = r.Header.Clone() // Clone headers
        req.Header.Del("X-Presigned-URL")
        req.ContentLength = r.ContentLength
    }

    // Handle proxy errors
    proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, err error) {
        slog.Error("Proxy error", "error", err)
        http.Error(rw, "Proxy error: "+err.Error(), http.StatusBadGateway)
    }

    // Serve the proxied request
    slog.Info("Forwarding request to S3")
    proxy.ServeHTTP(w, r)

    return nil
}

Enter fullscreen mode Exit fullscreen mode

Client:

The client sends chunked data via HTTP/3 using the quic-go client. Each request contains the X-Presigned-URL header with the S3 URL and the chunk payload. It sends 8MB chunk

func (lu *Uploader) sendChunk(client *http.Client, chunkObject models.ChunkUrl, assetPath string, subtitleRelativeMap *sync.Map) error {
    var err error
    var dataReader io.Reader
    for attempt := 0; attempt < 4; attempt++ {
        if strings.Contains(assetPath, ".tar") {
            lu.logger.Info("path", "path", assetPath, "offset", chunkObject.Offset, "size", chunkObject.Size)
            path := strings.TrimSuffix(assetPath, ".tar")
            lu.logger.Info("create.tar", "path", filepath.Base(path))
            dataReader, err = lu.createTar(path, subtitleRelativeMap)
            if err != nil {
                return err
            }
            _, err = io.CopyN(io.Discard, dataReader, chunkObject.Offset)
            if err != nil {
                return err
            }

            dataReader = io.LimitReader(dataReader, chunkObject.Size)

        } else {
            var file *os.File
            file, err = os.Open(assetPath)
            if err != nil {
                return err
            }
            defer file.Close()

            dataReader = io.NewSectionReader(file, chunkObject.Offset, chunkObject.Size)

        }

        req, err := http.NewRequest(http.MethodPut, "https://localhost:4242/post-reverse", dataReader)
        if err != nil {
            return err
        }
        req.Header.Add("Content-Type", "application/octet-stream")
        req.Header.Add("X-Presigned-URL", chunkObject.UploadUrl)
        req.ContentLength = chunkObject.Size

        resp, err := client.Do(req)
        if err != nil {
            lu.logger.Error("upload.chunk.error", "attempt", attempt, "err", err)
            continue
        }

        // handle empty response
        responseBody, _ := io.ReadAll(resp.Body)
        defer resp.Body.Close()
        if resp.StatusCode != http.StatusOK {
            lu.logger.Error("upload.chunk.error", "attempt", attempt, "response", string(responseBody))
            time.Sleep(2 * time.Second)
            continue
        }
        // SUCCESS
        lu.logger.Info("upload.chunk.success", "path", assetPath, "offset", chunkObject.Offset, "size", chunkObject.Size)
        return nil
    }
    return err
}

func makeOptimizedClient() *http.Client {
    tr := &http3.Transport{
        TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
        QUICConfig:      &quic.Config{},
    }
    defer tr.Close()
    client := &http.Client{
        Transport: tr,
        Timeout:   10 * time.Minute,
    }
    return client
}
Enter fullscreen mode Exit fullscreen mode

my Goals are

Enable efficient chunked uploads to S3 using a reverse proxy that leverages QUIC for low-latency data transfer. Ensure successful forwarding of chunked data from the client to the S3 pre-signed URL via the reverse proxy. Provide proper responses (e.g., HTTP 200 for successful uploads or error codes for issues) to the client after the S3 upload.

Issues: Failed Proxy to S3:

When the server forwards the chunked request to the S3 pre-signed URL All i get is 502 Bad Gateway with the error: http3: parsing frame failed: timeout: no recent network activity.

I tried using the same code with a basic HTTP client and server, and it works fine with TCP connections. However, when I switch to QUIC implementation, it starts throwing errors.

Please help, and feel free to ask for clarification if my question is unclear.

Top comments (2)

Collapse
 
initeshjain profile image
Nitesh Jain

server := http3.Server{
Handler: hdlr,
Addr: addr,
QUICConfig: &quic.Config{
Tracer: qlog.DefaultConnectionTracer,
},
}

try adding Keep alive header. Also I found some timeout headers to prevent early timeout.

server := http3.Server{
Handler: hdlr,
Addr: addr,
QUICConfig: &quic.Config{
Tracer: qlog.DefaultConnectionTracer,
KeepAlive: true, // Enable keep-alive to prevent network inactivity timeouts
HandshakeIdleTimeout: 10 * time.Second,
MaxIdleTimeout: 30 * time.Second,
},
}

Collapse
 
vikram_parihar profile image
vikram parihar

Tried this approach didn't work.