You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Reproduced against master as of 2026-05-21 (post #7985, which fixed the related but distinct double-write issue).
Summary
When a Go plugin running as a pre/auth_check/post_key_auth/post middleware writes an error response (status >= 400) to the ResponseWriter, the resulting analytics record stored in Redis has RawResponse set to the base64 encoding of a zero-value *http.Response:
HTTP/0.0 000 status code 0
Content-Length: 0
The on-the-wire response is correct (status code, headers, and body that the plugin wrote all reach the client). Only the audit/analytics record is malformed.
AnalyticsRecord.ResponseCode is correct; only RawResponse is the sentinel. As a result, any pump-side processor that parses RawResponse (status line, content-length, headers, body) blows up for every plugin-rejected request, even though those are real, healthy responses the plugin chose to emit.
Reproduce
Configure an API with a Go plugin in the pre chain that writes a 4xx response with a body, e.g.:
Enable analytics_config.enable_detailed_recording = true (so RawResponse is populated at all).
Send a request that hits the plugin.
Read the resulting analytics record from the Redis LIST analytics-tyk-system-analytics, msgpack-decode it, base64-decode RawResponse.
Observed:
ResponseCode: 429
RawResponse (b64-decoded): "HTTP/0.0 000 status code 0\r\nContent-Length: 0\r\n\r\n"
Expected:
ResponseCode: 429
RawResponse (b64-decoded): "HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nContent-Length: 23\r\n\r\n{\"reason\":\"queue_full\"}"
The 2xx admit path on the same plugin emits a correctly serialized HTTP/1.1 200 OK ...RawResponse, so the analytics pipeline itself works — only the goplugin >= 400 branch is affected.
The 2xx branch immediately below calls successHandler.RecordHit(..., rw.getHttpResponse(r), ...) with the wrapped writer's real captured response. The >= 400 branch returns to the base middleware with an error, and the wrapped response is never used for analytics.
gateway/mw_go_plugin.go::handleErrorResponse (current line ~322) returns (err, rw.statusCodeSent) with err wrapped in ErrResponseErrorSent when rw.responseSent is true.
gateway/middleware.go::createMiddleware (current line ~190) handles that error:
(&http.Response{}).Write(buf) serializes the zero-value into exactly HTTP/0.0 000 status code 0\r\nContent-Length: 0\r\n\r\n — that is the bytes that get base64'd into record.RawResponse.
The 2xx branch dodges this by recording analytics via successHandler.RecordHit(..., rw.getHttpResponse(r), ...) inside handlePluginResponse and never routing through HandleError. The >= 400 branch doesn't have an equivalent.
Why obvious workarounds don't apply
Option
Verdict
analytics_config.enable_detailed_recording toggle
Already enabled. The 200 path proves capture works on the same code path; the 4xx path simply bypasses capture.
Move plugin to a different goplugin hook position (auth_check, post_key_auth, post)
All goplugin hooks route through handlePluginResponse → same >= 400 branch → same bug.
Move to response hook
response only fires after upstream proxy. Plugins that reject before proxy (rate limiting, admission control, auth) can't move there.
Tyk analytics plugin (post-record processor)
Has access to the AnalyticsRecord but not the original ResponseWriter; cannot reconstruct a faithful RawResponse, only synthesize a plausible one.
Emit a custom analytics record from the plugin
Couples plugin source to Tyk's analytics record shape; defeats the point of the analytics layer.
Proposed fix
Stash the wrapped writer's *http.Response in the request context inside handlePluginResponse, and have HandleError use it when writeResponse=false instead of the zero-value placeholder.
The wrapped writer already constructs a real *http.Response for the 2xx analytics path (customResponseWriter.getHttpResponse(r) at mw_go_plugin.go line ~83). The fix is to reuse that for the 4xx path.
Diff sketch (4 files):
// ctx/ctx.go — add to the Key iotaconst (
// ... existing keys ...// CapturedResponse holds a *http.Response that was constructed by an// upstream middleware (currently goplugin) directly from a wrapped// ResponseWriter. ErrorHandler.HandleError reads this to populate the// analytics RawResponse when writeResponse=false, instead of serializing// a zero-value http.Response (which renders as "HTTP/0.0 000 status code 0").CapturedResponse
)
// gateway/api.go — alongside the other ctxSet/ctxGet helpersfuncctxSetCapturedResponse(r*http.Request, resp*http.Response) {
setCtxValue(r, ctx.CapturedResponse, resp)
}
funcctxGetCapturedResponse(r*http.Request) *http.Response {
ifv, ok:=r.Context().Value(ctx.CapturedResponse).(*http.Response); ok {
returnv
}
returnnil
}
// gateway/mw_go_plugin.go::handlePluginResponse — current line ~307ifrw.statusCodeSent>=http.StatusBadRequest {
// The base middleware will route this through ErrorHandler.HandleError// with writeResponse=false (to avoid clobbering the wire response the// plugin already wrote). Without help, HandleError will serialize a// zero-value *http.Response into RawResponse — see// gateway/handler_error.go line ~232 — producing// "HTTP/0.0 000 status code 0\r\nContent-Length: 0\r\n\r\n"// in every plugin-error analytics record. Stash the real response here// so HandleError can use it.ctxSetCapturedResponse(r, rw.getHttpResponse(r))
returnm.handleErrorResponse(r, rw, logger)
}
// gateway/handler_error.go::HandleError — replace the response init blockfunc (e*ErrorHandler) HandleError(w http.ResponseWriter, r*http.Request, errMsgstring, errCodeint, writeResponsebool) {
defere.Base().UpdateRequestSession(r)
response:=&http.Response{}
ifwriteResponse {
ife.Spec.IsMCP() &&e.shouldWriteJSONRPCError(r) {
response=e.writeJSONRPCErrorResponse(w, r, errMsg, errCode)
} elseifresp:=e.tryWriteOverride(w, r, errMsg, errCode); resp!=nil {
response=resp
} else {
response=e.writeTemplateErrorResponse(w, r, errMsg, errCode)
}
} elseifcaptured:=ctxGetCapturedResponse(r); captured!=nil {
// A middleware (e.g. goplugin) already wrote the wire response and// stashed its captured *http.Response here. Use it so the analytics// RawResponse reflects the real bytes the client received.response=captured
}
// ... rest unchanged ...
Why this approach
Strictly additive. Any middleware path that doesn't stash a response (everything except goplugin >= 400 today) sees the existing zero-value behavior — no regression.
No new sentinel error types, no changes to HandleError's signature or return type, no semantic overload of DoNotTrack.
Mirrors the 2xx path: both branches now use rw.getHttpResponse(r) as the source of truth for the analytics record's response shape.
The responseCode fallback at handler_error.go line ~183 already handles response.StatusCode != 0, so it correctly picks up the real status code from the stashed response (replacing the previous fallback to errCode).
Suggested test
Mirroring the test added in #7985 in goplugin/mw_go_plugin_test.go. Asserts the analytics record's RawResponse decodes to a parseable HTTP/1.1 status line carrying the plugin's status code (anti-witness: rejects HTTP/0.0 000).
funcTestGoPlugin_AnalyticsRawResponseCapturesPluginResponse(t*testing.T) {
ts:=gateway.StartTest(nil)
t.Cleanup(ts.Close)
ts.Gw.BuildAndLoadAPI(func(spec*gateway.APISpec) {
spec.Proxy.ListenPath="/test-api/"spec.UseKeylessAccess=truespec.UseStandardAuth=falsespec.EnableDetailedRecording=truespec.CustomMiddleware= apidef.MiddlewareSection{
Driver: apidef.GoPluginDriver,
Pre: []apidef.MiddlewareDefinition{{
Name: "RejectWithBody",
Path: goPluginFilename(),
}},
}
})
redisAnalyticsKeyName:=analyticsKeyName+ts.Gw.Analytics.analyticsSerializer.GetSuffix()
ts.Gw.Analytics.Store.GetAndDeleteSet(redisAnalyticsKeyName)
ts.Run(t, test.TestCase{
Path: "/test-api/get",
Code: http.StatusForbidden,
})
ts.Gw.Analytics.Flush()
results:=ts.Gw.Analytics.Store.GetAndDeleteSet(redisAnalyticsKeyName)
assert.Len(t, results, 1)
varrecord analytics.AnalyticsRecorderr:=ts.Gw.Analytics.analyticsSerializer.Decode([]byte(results[0].(string)), &record)
assert.NoError(t, err)
assert.Equal(t, http.StatusForbidden, record.ResponseCode)
raw, err:=base64.StdEncoding.DecodeString(record.RawResponse)
assert.NoError(t, err)
assert.True(t, strings.HasPrefix(string(raw), "HTTP/1.1 403 "),
"RawResponse should start with HTTP/1.1 403, got: %q", truncate(string(raw), 80))
assert.False(t, strings.HasPrefix(string(raw), "HTTP/0.0 000"),
"RawResponse must not be the zero-value sentinel: %q", truncate(string(raw), 80))
assert.Contains(t, string(raw), "hello",
"RawResponse should contain the body the plugin wrote")
}
RejectWithBody plugin already exists in test/goplugins/test_goplugin.go as of #7985.
Impact
Any downstream that parses AnalyticsRecord.RawResponse (response-header dashboards, body inspection, response-size analytics) is wrong for every plugin-rejected request. The wire response itself is unaffected — only the audit trail is malformed.
Affects all goplugin-based admission/auth/rate-limit/policy plugins that emit a body on reject.
We currently work around this in our own e2e test by softening the RawResponse assertion to a t.Logf inversion-witness; we'd like to remove that softener once this is fixed upstream.
Branch / Version
Reproduced against
masteras of 2026-05-21 (post #7985, which fixed the related but distinct double-write issue).Summary
When a Go plugin running as a
pre/auth_check/post_key_auth/postmiddleware writes an error response (status>= 400) to theResponseWriter, the resulting analytics record stored in Redis hasRawResponseset to the base64 encoding of a zero-value*http.Response:The on-the-wire response is correct (status code, headers, and body that the plugin wrote all reach the client). Only the audit/analytics record is malformed.
AnalyticsRecord.ResponseCodeis correct; onlyRawResponseis the sentinel. As a result, any pump-side processor that parsesRawResponse(status line, content-length, headers, body) blows up for every plugin-rejected request, even though those are real, healthy responses the plugin chose to emit.Reproduce
Configure an API with a Go plugin in the
prechain that writes a 4xx response with a body, e.g.:Enable
analytics_config.enable_detailed_recording = true(soRawResponseis populated at all).Send a request that hits the plugin.
Read the resulting analytics record from the Redis LIST
analytics-tyk-system-analytics, msgpack-decode it, base64-decodeRawResponse.Observed:
Expected:
The 2xx admit path on the same plugin emits a correctly serialized
HTTP/1.1 200 OK ...RawResponse, so the analytics pipeline itself works — only the goplugin>= 400branch is affected.Root cause
Traced in
master(post #7985):gateway/mw_go_plugin.go::handlePluginResponse(current line ~307):The 2xx branch immediately below calls
successHandler.RecordHit(..., rw.getHttpResponse(r), ...)with the wrapped writer's real captured response. The>= 400branch returns to the base middleware with an error, and the wrapped response is never used for analytics.gateway/mw_go_plugin.go::handleErrorResponse(current line ~322) returns(err, rw.statusCodeSent)witherrwrapped inErrResponseErrorSentwhenrw.responseSentis true.gateway/middleware.go::createMiddleware(current line ~190) handles that error:writeResponse=falsecorrectly preventsHandleErrorfrom clobbering the plugin's wire response (this was the fix in [TT-5070] Duplicate WriteHeader calls cause malformed responses from golang plugins #7985).gateway/handler_error.go::HandleError(current line ~99):With
writeResponse=false,responsestays the zero-value&http.Response{}.gateway/handler_error.go(current line ~232):(&http.Response{}).Write(buf)serializes the zero-value into exactlyHTTP/0.0 000 status code 0\r\nContent-Length: 0\r\n\r\n— that is the bytes that get base64'd intorecord.RawResponse.The 2xx branch dodges this by recording analytics via
successHandler.RecordHit(..., rw.getHttpResponse(r), ...)insidehandlePluginResponseand never routing throughHandleError. The>= 400branch doesn't have an equivalent.Why obvious workarounds don't apply
analytics_config.enable_detailed_recordingtoggleauth_check,post_key_auth,post)handlePluginResponse→ same>= 400branch → same bug.responsehookresponseonly fires after upstream proxy. Plugins that reject before proxy (rate limiting, admission control, auth) can't move there.AnalyticsRecordbut not the originalResponseWriter; cannot reconstruct a faithfulRawResponse, only synthesize a plausible one.Proposed fix
Stash the wrapped writer's
*http.Responsein the request context insidehandlePluginResponse, and haveHandleErroruse it whenwriteResponse=falseinstead of the zero-value placeholder.The wrapped writer already constructs a real
*http.Responsefor the 2xx analytics path (customResponseWriter.getHttpResponse(r)atmw_go_plugin.goline ~83). The fix is to reuse that for the 4xx path.Diff sketch (4 files):
Why this approach
>= 400today) sees the existing zero-value behavior — no regression.HandleError's signature or return type, no semantic overload ofDoNotTrack.rw.getHttpResponse(r)as the source of truth for the analytics record's response shape.responseCodefallback athandler_error.goline ~183 already handlesresponse.StatusCode != 0, so it correctly picks up the real status code from the stashed response (replacing the previous fallback toerrCode).Suggested test
Mirroring the test added in #7985 in
goplugin/mw_go_plugin_test.go. Asserts the analytics record'sRawResponsedecodes to a parseable HTTP/1.1 status line carrying the plugin's status code (anti-witness: rejectsHTTP/0.0 000).RejectWithBodyplugin already exists intest/goplugins/test_goplugin.goas of #7985.Impact
AnalyticsRecord.RawResponse(response-header dashboards, body inspection, response-size analytics) is wrong for every plugin-rejected request. The wire response itself is unaffected — only the audit trail is malformed.RawResponseassertion to at.Logfinversion-witness; we'd like to remove that softener once this is fixed upstream.Related
HandleErrorclobbering the wire body. That PR did not touch the analytics path;RawResponseis still serialized from the zero-value*http.Responseafter that fix.Happy to send the patch as a PR once a maintainer signals interest in the approach.