Add client RequestID middleware for cross-service propagation

Introduces internal/requestid package with shared context key to avoid
circular imports between server and middleware packages. Server's
RequestID middleware now uses the shared key. Client middleware picks up
the ID from context and sets X-Request-Id on outgoing requests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 21:47:58 +03:00
parent 3395f70abd
commit 49be6f8a7e
4 changed files with 115 additions and 5 deletions

23
middleware/requestid.go Normal file
View File

@@ -0,0 +1,23 @@
package middleware
import (
"net/http"
"git.codelab.vc/pkg/httpx/internal/requestid"
)
// RequestID returns a middleware that propagates the request ID from the
// request context to the outgoing X-Request-Id header. This pairs with
// the server.RequestID middleware: the server stores the ID in the context,
// and the client middleware forwards it to downstream services.
func RequestID() Middleware {
return func(next http.RoundTripper) http.RoundTripper {
return RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
if id := requestid.FromContext(req.Context()); id != "" {
req = req.Clone(req.Context())
req.Header.Set("X-Request-Id", id)
}
return next.RoundTrip(req)
})
}
}

View File

@@ -0,0 +1,69 @@
package middleware_test
import (
"context"
"net/http"
"testing"
"git.codelab.vc/pkg/httpx/internal/requestid"
"git.codelab.vc/pkg/httpx/middleware"
)
func TestRequestID(t *testing.T) {
t.Run("propagates ID from context", func(t *testing.T) {
var gotHeader string
base := middleware.RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
gotHeader = req.Header.Get("X-Request-Id")
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mw := middleware.RequestID()(base)
ctx := requestid.NewContext(context.Background(), "test-id-123")
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.com", nil)
_, err := mw.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotHeader != "test-id-123" {
t.Fatalf("X-Request-Id = %q, want %q", gotHeader, "test-id-123")
}
})
t.Run("no ID in context skips header", func(t *testing.T) {
var gotHeader string
base := middleware.RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
gotHeader = req.Header.Get("X-Request-Id")
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mw := middleware.RequestID()(base)
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example.com", nil)
_, err := mw.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotHeader != "" {
t.Fatalf("expected no X-Request-Id header, got %q", gotHeader)
}
})
t.Run("does not mutate original request", func(t *testing.T) {
base := middleware.RoundTripperFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mw := middleware.RequestID()(base)
ctx := requestid.NewContext(context.Background(), "test-id")
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://example.com", nil)
_, _ = mw.RoundTrip(req)
if req.Header.Get("X-Request-Id") != "" {
t.Fatal("original request was mutated")
}
})
}