@@ -22,6 +22,7 @@ import (
2222 "net/http"
2323 "net/http/httptest"
2424 "sync"
25+ "sync/atomic"
2526 "testing"
2627 "time"
2728
@@ -626,6 +627,99 @@ func BenchmarkTimeoutHandler(b *testing.B) {
626627 })
627628}
628629
630+ func TestTimeoutHandlerConcurrentHeaderAccess (t * testing.T ) {
631+ // This test verifies the fix for the race condition when requests time out.
632+ // It simulates the scenario where the timeout handler completes while the
633+ // inner handler is still trying to modify headers. The key is that this
634+ // should not panic with a concurrent map access error.
635+
636+ var completedCount atomic.Int32
637+ var panicCount int32
638+ innerHandler := http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
639+ // Simulate work that takes around the same time as timeout
640+ time .Sleep (55 * time .Millisecond )
641+
642+ // After potential context cancellation, try to access headers
643+ // This simulates what the error handler does
644+ if r .Context ().Err () != nil {
645+ // Try to modify headers - this should not cause a panic
646+ // even if timeout has occurred
647+ w .Header ().Set ("X-Test-Header" , "value" )
648+ http .Error (w , "context canceled" , http .StatusBadGateway )
649+ } else {
650+ // If no timeout, write normally
651+ w .WriteHeader (http .StatusOK )
652+ }
653+ completedCount .Add (1 )
654+ })
655+
656+ timeoutHandler := NewTimeoutHandler (
657+ innerHandler ,
658+ "timeout" ,
659+ func (r * http.Request ) (time.Duration , time.Duration , time.Duration ) {
660+ return 50 * time .Millisecond , 0 , 0
661+ },
662+ zaptest .NewLogger (t ).Sugar (),
663+ )
664+
665+ // Run multiple concurrent requests to increase chances of hitting the race
666+ var wg sync.WaitGroup
667+ var timeoutResponses atomic.Int32
668+ var normalResponses atomic.Int32
669+ for range 10 {
670+ wg .Add (1 )
671+ go func () {
672+ defer wg .Done ()
673+ defer func () {
674+ if r := recover (); r != nil {
675+ // Should not panic with concurrent map access
676+ atomic .AddInt32 (& panicCount , 1 )
677+ t .Errorf ("Unexpected panic: %v" , r )
678+ }
679+ }()
680+
681+ req , err := http .NewRequest (http .MethodGet , "/" , nil )
682+ if err != nil {
683+ t .Error (err )
684+ return
685+ }
686+
687+ rec := httptest .NewRecorder ()
688+
689+ // This should not panic with concurrent map access
690+ timeoutHandler .ServeHTTP (rec , req )
691+
692+ // We may get either a timeout or a normal response depending on timing
693+ // The key is that we don't panic
694+ switch rec .Code {
695+ case http .StatusGatewayTimeout :
696+ timeoutResponses .Add (1 )
697+ case http .StatusOK :
698+ normalResponses .Add (1 )
699+ default :
700+ t .Errorf ("Unexpected status code: %d" , rec .Code )
701+ }
702+ }()
703+ }
704+
705+ wg .Wait ()
706+
707+ // Give a bit more time for any lingering goroutines to complete
708+ time .Sleep (100 * time .Millisecond )
709+
710+ // Check that no panics occurred
711+ if panicCount > 0 {
712+ t .Errorf ("Got %d panics, expected 0" , panicCount )
713+ }
714+
715+ // At least some requests should have timed out
716+ if timeoutResponses .Load () == 0 {
717+ t .Error ("Expected at least some timeout responses" )
718+ }
719+
720+ t .Logf ("Got %d timeout responses and %d normal responses" , timeoutResponses .Load (), normalResponses .Load ())
721+ }
722+
629723func StaticTimeoutFunc (timeout time.Duration , requestStart time.Duration , idle time.Duration ) TimeoutFunc {
630724 return func (req * http.Request ) (time.Duration , time.Duration , time.Duration ) {
631725 return timeout , requestStart , idle
0 commit comments