Skip to content

HTTP/2 Server terminates all streams once exceeded #335

@Eduard-Voiculescu

Description

@Eduard-Voiculescu

The official spec for HTTP/2 states:

An endpoint that receives a HEADERS frame that causes its advertised concurrent stream limit to be exceeded MUST treat this as a stream error (Section 5.4.2) of type PROTOCOL_ERROR or REFUSED_STREAM

Interestingly, I am able to create a small hypercorn server that doesn't comply to that. Here is the example server:

main.py

import asyncio

from hypercorn.asyncio import serve
from hypercorn.config import Config
from module import app

config = Config()
config.bind = ["127.0.0.1:8443"]
config.certfile = "../cert.pem"
config.keyfile = "../key.pem"
config.h2_max_concurrent_streams = 5 # <----- here I limit to 5 requests

asyncio.run(serve(app, config))

module.py

import asyncio

from fastapi import FastAPI

app = FastAPI()


@app.get("/sleep")
async def sleep_endpoint():
    await asyncio.sleep(5)
    print("Slept for 5 seconds")
    return {"status": "ok"}

In the main.py, I set the limit to the max_concurrent_streams to 5.

Now, if I send out 6 concurrent requests, let's assume using a very bland and novice client (no pooling or whatever), I would expect that 5 out of the 6 requests see a {"status": "ok"} and the sixth would be something like PROTOCOL_ERROR or REFUSED_STREAM.

Instead, what I am seeing is 1 request going through and ALL the rest get PROTOCOL_ERROR.

Here is my client (built in golang):

package main

import (
	"context"
	"crypto/tls"
	"fmt"
	"io"
	"net/http"
	"sync"
	"time"

	"golang.org/x/net/http2"
)

func main() {
	tr := &http2.Transport{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
	}

	client := &http.Client{
		Transport: tr,
	}

	fmt.Println("Starting 6 concurrent HTTP/2 requests...")
	fmt.Println("Server is configured with h2_max_concurrent_streams=5")
	fmt.Println("Expected: 6th request should trigger GOAWAY")

	total := 6
	var wg sync.WaitGroup

	for i := range total {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			start := time.Now()
			fmt.Printf("[#%d] Sending request...\n", id)

			ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
			defer cancel()

			req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://127.0.0.1:8443/sleep", nil)
			if err != nil {
				elapsed := time.Since(start)
				fmt.Printf("[#%d] ERROR creating request after %v: %v\n", id, elapsed, err)
				return
			}

			resp, err := client.Do(req)
			if err != nil {
				elapsed := time.Since(start)
				fmt.Printf("[#%d] ERROR after %v: %v\n", id, elapsed, err)
				return
			}
			defer resp.Body.Close()

			io.Copy(io.Discard, resp.Body)
			elapsed := time.Since(start)
			fmt.Printf("[#%d] SUCCESS: status=%s time=%v proto=%s\n", id, resp.Status, elapsed, resp.Proto)
		}(i)
	}
	wg.Wait()
}

And the result:

Starting 6 concurrent HTTP/2 requests...
Server is configured with h2_max_concurrent_streams=5
Expected: 6th request should trigger GOAWAY
[#1] Sending request...
[#3] Sending request...
[#0] Sending request...
[#2] Sending request...
[#4] Sending request...
[#5] Sending request...
[#1] ERROR after 7.192417ms: Get "https://127.0.0.1:8443/sleep": http2: server sent GOAWAY and closed the connection; LastStreamID=9, ErrCode=PROTOCOL_ERROR, debug=""
[#2] ERROR after 7.108875ms: Get "https://127.0.0.1:8443/sleep": http2: server sent GOAWAY and closed the connection; LastStreamID=9, ErrCode=PROTOCOL_ERROR, debug=""
[#0] ERROR after 7.188625ms: Get "https://127.0.0.1:8443/sleep": http2: server sent GOAWAY and closed the connection; LastStreamID=9, ErrCode=PROTOCOL_ERROR, debug=""
[#5] ERROR after 7.189334ms: Get "https://127.0.0.1:8443/sleep": http2: server sent GOAWAY and closed the connection; LastStreamID=9, ErrCode=PROTOCOL_ERROR, debug=""
[#3] ERROR after 7.159208ms: Get "https://127.0.0.1:8443/sleep": http2: server sent GOAWAY and closed the connection; LastStreamID=9, ErrCode=PROTOCOL_ERROR, debug=""
[#4] SUCCESS: status=200 OK time=5.014044167s proto=HTTP/2.0

Now, here is an HTTP/2 server, in golang, mimicking the same hypercorn server:

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"

	"golang.org/x/net/http2"
)

func sleepHandler(w http.ResponseWriter, r *http.Request) {
	time.Sleep(5 * time.Second)
	fmt.Println("Slept for 5 seconds")
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok"}`))
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/sleep", sleepHandler)

	server := &http.Server{
		Addr:    "127.0.0.1:8443",
		Handler: mux,
	}

	http2Server := &http2.Server{
		MaxConcurrentStreams: 5,
	}

	if err := http2.ConfigureServer(server, http2Server); err != nil {
		log.Fatalf("Failed to configure HTTP/2: %v", err)
	}

	log.Println("Starting HTTP/2 server on https://127.0.0.1:8443")
	log.Println("Max concurrent streams: 5")

	if err := server.ListenAndServeTLS("../cert.pem", "../key.pem"); err != nil {
		log.Fatalf("Server failed: %v", err)
	}
}

And the result:

Starting 6 concurrent HTTP/2 requests...
Server is configured with h2_max_concurrent_streams=5
Expected: 6th request should trigger GOAWAY
[#5] Sending request...
[#0] Sending request...
[#3] Sending request...
[#1] Sending request...
[#2] Sending request...
[#4] Sending request...
[#4] SUCCESS: status=200 OK time=5.009443875s proto=HTTP/2.0
[#3] SUCCESS: status=200 OK time=5.009461458s proto=HTTP/2.0
[#1] SUCCESS: status=200 OK time=5.009528292s proto=HTTP/2.0
[#2] SUCCESS: status=200 OK time=5.009512083s proto=HTTP/2.0
[#5] SUCCESS: status=200 OK time=5.009583792s proto=HTTP/2.0
[#0] SUCCESS: status=200 OK time=5.009575625s proto=HTTP/2.0

Any thoughts on this?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions