Skip to content

Commit 4e14707

Browse files
rdimitrovclaude
andauthored
metrics: stop counting client-cancelled requests as server errors (#1255)
## Summary - Remap recorded status to `499` (NGINX-style "client closed") when `ctx.Context().Err()` is `context.Canceled` after the handler returns - Skip the `http_errors_total` increment for `499`s so the availability metric reflects server-visible errors only ## Why Under scraper-driven load, clients with short timeouts paginate `/v0.1/servers`, give up while PG is busy, and close the TCP connection. The handler's DB iteration returns `context canceled`, the handler converts that to `huma.Error500InternalServerError`, and tries to write a response to the now-closed socket. NGINX records `499` (client closed) and never delivers anything to the client — but the registry's middleware records `status_code=500` and bumps `mcp_registry_http_errors_total`. That's what's been firing the `Availability dropped below 95%` alert during the daily 17:00 UTC bursts. Recent prod numbers from one pod (~19h uptime): ``` mcp_registry_http_errors_total{path="/v0.1/servers", status_code="500"} 7396 mcp_registry_http_errors_total{path="/v0.1/servers/{serverName}/versions", status_code="500"} 283 mcp_registry_http_errors_total{path="/v0/servers", status_code="500"} 352 ``` …against zero 5xx in NGINX ingress logs over the same window. The alert was correct *given the data it had*; the data was misclassifying client cancellations as server errors. ## What this does In `internal/api/router/router.go` (the metric middleware): after `next(ctx)`, check `ctx.Context().Err()`. If it's `context.Canceled`, override `statusCode` to `499` and skip the error counter. Requests counter and duration histogram still record so the cancellation rate stays visible. `context.DeadlineExceeded` is intentionally **not** remapped — that would indicate a server-side timeout if we ever add per-request deadlines, and should still count as a server error. ## What this does *not* do - Doesn't reduce the underlying load (ServiceNow + notion catalog scrapers driving the daily peak — separate work, see the `updated_since` outreach we're doing). - Doesn't change handler behaviour — handlers still call `huma.Error500…` on `context.Canceled`. We could short-circuit earlier (return without writing) to save the work, but that's a bigger refactor and the metric fix unblocks the alert immediately. ## Companion change (no code, just a Grafana annotation tweak) The `Availability dropped below 95%` alert (UID `bexrc60etvhmoa`) currently has the annotation: > No. of 5xx are increased from acceptable limit i.e. 5% of total throughput The query is on `mcp_registry_http_errors_total`, not 5xx specifically. After this change the count is meaningful again, but the wording is still misleading. Suggest updating to: > Internal error rate exceeded 5% of total throughput (no PromQL change required.) ## Test plan - [x] `go test ./internal/api/router/...` — new test passes - [x] `golangci-lint run ./internal/...` — clean - [ ] After merge, watch a daily 17:00 UTC burst — `mcp_registry_http_errors_total{status_code="500", path="/v0.1/servers"}` should stay flat while `mcp_registry_http_requests_total{status_code="499", path="/v0.1/servers"}` grows. Availability alert should not fire on cancellation-only bursts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6d6a887 commit 4e14707

2 files changed

Lines changed: 146 additions & 1 deletion

File tree

internal/api/router/router.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
package router
33

44
import (
5+
"context"
56
"encoding/json"
7+
"errors"
68
"fmt"
79
"net/http"
810
"strings"
@@ -19,6 +21,12 @@ import (
1921
"github.com/modelcontextprotocol/registry/internal/telemetry"
2022
)
2123

24+
// statusClientClosed mirrors NGINX's non-standard 499 — used for requests
25+
// where the client disconnected before we finished. Distinguishing these from
26+
// real server errors keeps the availability metric meaningful under bursts of
27+
// scraper traffic that time out and reconnect.
28+
const statusClientClosed = 499
29+
2230
// Middleware configuration options
2331
type middlewareConfig struct {
2432
skipPaths map[string]bool
@@ -67,6 +75,22 @@ func MetricTelemetryMiddleware(metrics *telemetry.Metrics, options ...Middleware
6775
duration := time.Since(start).Seconds()
6876
statusCode := ctx.Status()
6977

78+
// If the client disconnected before the handler finished, the handler
79+
// likely converted the resulting context.Canceled into a huma 5xx and
80+
// tried to write a response to a closed socket. NGINX records that
81+
// case as a 499 (client closed). Without this remap we count it as a
82+
// server error: a single ServiceNow-style burst that times out a
83+
// few thousand list-servers requests inflates http_errors_total even
84+
// though no client ever saw a 5xx, and the availability alert fires
85+
// on what is effectively just slow responses.
86+
//
87+
// Only context.Canceled is remapped — context.DeadlineExceeded would
88+
// indicate a server-side timeout we set ourselves and should still
89+
// count as a server error if/when we add per-request deadlines.
90+
if reqErr := ctx.Context().Err(); reqErr != nil && errors.Is(reqErr, context.Canceled) {
91+
statusCode = statusClientClosed
92+
}
93+
7094
// Combine common and custom attributes
7195
attrs := []attribute.KeyValue{
7296
attribute.String("method", method),
@@ -77,7 +101,9 @@ func MetricTelemetryMiddleware(metrics *telemetry.Metrics, options ...Middleware
77101
// Record metrics
78102
metrics.Requests.Add(ctx.Context(), 1, metric.WithAttributes(attrs...))
79103

80-
if statusCode >= 400 {
104+
// Skip the error counter for client-closed requests so the availability
105+
// metric reflects server-visible errors only.
106+
if statusCode >= 400 && statusCode != statusClientClosed {
81107
metrics.ErrorCount.Add(ctx.Context(), 1, metric.WithAttributes(attrs...))
82108
}
83109

internal/api/router/router_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package router_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
11+
"github.com/danielgtaylor/huma/v2"
12+
"github.com/danielgtaylor/huma/v2/adapters/humago"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
16+
"github.com/modelcontextprotocol/registry/internal/api/router"
17+
"github.com/modelcontextprotocol/registry/internal/telemetry"
18+
)
19+
20+
// TestMetricMiddleware_ClientCancelledNotCountedAsError verifies that a request
21+
// the client gave up on (request-context Canceled) is recorded as 499 in
22+
// http_requests_total and is NOT incremented in http_errors_total — even when
23+
// the handler returned huma.Error500InternalServerError because its DB call
24+
// surfaced context.Canceled.
25+
//
26+
// Regression test for the case where a ServiceNow-style scraper burst
27+
// generated thousands of internal 500s for cancelled requests, tripping the
28+
// "Availability dropped below 95%" alert despite zero 5xx reaching clients.
29+
func TestMetricMiddleware_ClientCancelledNotCountedAsError(t *testing.T) {
30+
shutdown, metrics, err := telemetry.InitMetrics("test")
31+
require.NoError(t, err)
32+
defer func() { _ = shutdown(context.Background()) }()
33+
34+
mux := http.NewServeMux()
35+
api := humago.New(mux, huma.DefaultConfig("Test API", "1.0.0"))
36+
api.UseMiddleware(router.MetricTelemetryMiddleware(metrics))
37+
38+
// Handler that simulates: DB returns context.Canceled because the client
39+
// disconnected, handler converts to huma 500.
40+
huma.Register(api, huma.Operation{
41+
OperationID: "cancelled-list",
42+
Method: http.MethodGet,
43+
Path: "/v0/servers",
44+
}, func(_ context.Context, _ *struct{}) (*struct{}, error) {
45+
return nil, huma.Error500InternalServerError("Failed to get registry list",
46+
errors.New("error iterating rows: context canceled"))
47+
})
48+
49+
mux.Handle("/metrics", metrics.PrometheusHandler())
50+
51+
cancelledCtx, cancel := context.WithCancel(context.Background())
52+
cancel()
53+
54+
req := httptest.NewRequest(http.MethodGet, "/v0/servers", nil).WithContext(cancelledCtx)
55+
rec := httptest.NewRecorder()
56+
mux.ServeHTTP(rec, req)
57+
58+
scrapeReq := httptest.NewRequest(http.MethodGet, "/metrics", nil)
59+
scrapeRec := httptest.NewRecorder()
60+
mux.ServeHTTP(scrapeRec, scrapeReq)
61+
assert.Equal(t, http.StatusOK, scrapeRec.Code)
62+
63+
body := scrapeRec.Body.String()
64+
65+
// The request is recorded — but as 499, not 500.
66+
assert.True(t,
67+
containsMetric(body, `mcp_registry_http_requests_total`, `status_code="499"`, `path="/v0/servers"`),
68+
"expected requests_total to record status_code=499 for the cancelled request; got:\n%s",
69+
filterMetricLines(body, "mcp_registry_http_requests_total"),
70+
)
71+
assert.False(t,
72+
containsMetric(body, `mcp_registry_http_requests_total`, `status_code="500"`, `path="/v0/servers"`),
73+
"did not expect requests_total to record status_code=500 for a client-cancelled request; got:\n%s",
74+
filterMetricLines(body, "mcp_registry_http_requests_total"),
75+
)
76+
77+
// The error counter is NOT bumped for 499s — that is the point of this fix.
78+
assert.False(t,
79+
containsMetric(body, `mcp_registry_http_errors_total`, `status_code="499"`),
80+
"errors_total must not be incremented for client-cancelled requests; got:\n%s",
81+
filterMetricLines(body, "mcp_registry_http_errors_total"),
82+
)
83+
assert.False(t,
84+
containsMetric(body, `mcp_registry_http_errors_total`, `status_code="500"`, `path="/v0/servers"`),
85+
"errors_total must not record a 500 for a request the client already gave up on; got:\n%s",
86+
filterMetricLines(body, "mcp_registry_http_errors_total"),
87+
)
88+
}
89+
90+
// containsMetric returns true iff some line in body starts with `metric{...}`
91+
// and that label set contains every requested label fragment.
92+
func containsMetric(body, metric string, labels ...string) bool {
93+
for _, line := range strings.Split(body, "\n") {
94+
if !strings.HasPrefix(line, metric+"{") {
95+
continue
96+
}
97+
ok := true
98+
for _, l := range labels {
99+
if !strings.Contains(line, l) {
100+
ok = false
101+
break
102+
}
103+
}
104+
if ok {
105+
return true
106+
}
107+
}
108+
return false
109+
}
110+
111+
func filterMetricLines(body, metric string) string {
112+
var lines []string
113+
for _, line := range strings.Split(body, "\n") {
114+
if strings.HasPrefix(line, metric) {
115+
lines = append(lines, line)
116+
}
117+
}
118+
return strings.Join(lines, "\n")
119+
}

0 commit comments

Comments
 (0)