Skip to content

Commit 99e4207

Browse files
authored
perf(mcp-proxy): reduce response body allocations (#3174)
* perf(mcp-proxy): reduce response body allocations * perf(mcp-proxy): preallocate responses up to 10 MiB
1 parent 467af25 commit 99e4207

3 files changed

Lines changed: 110 additions & 10 deletions

File tree

src/mcp-proxy/pkg/infra/proxy/proxy.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
package proxy
2121

2222
import (
23+
"bytes"
2324
"context"
2425
"crypto/tls"
2526
"encoding/json"
@@ -56,6 +57,9 @@ import (
5657
"mcp_proxy/pkg/util"
5758
)
5859

60+
// maxResponseBodyPreallocateSize limits only the initial allocation; response bodies are always read to EOF.
61+
const maxResponseBodyPreallocateSize = 10 << 20
62+
5963
// sharedTransport 是所有 tool call 共用的 HTTP Transport,避免每次调用创建新连接池。
6064
// 通过 InitSharedTransport 从配置初始化,参数可在 config.yaml 的 mcpServer.transport 段调整。
6165
var (
@@ -1123,7 +1127,10 @@ func genToolHandler(toolApiConfig *ToolConfig, serverName string, rawResponseEna
11231127
var bodyBytes []byte
11241128
if response.Body() != nil {
11251129
var e error
1126-
bodyBytes, e = io.ReadAll(response.Body())
1130+
bodyBytes, e = readResponseBody(
1131+
response.Body(),
1132+
response.GetHeader("Content-Length"),
1133+
)
11271134
if e != nil {
11281135
return nil, e
11291136
}
@@ -1327,6 +1334,17 @@ func recordToolCallMetrics(
13271334
}
13281335
}
13291336

1337+
func readResponseBody(body io.Reader, contentLength string) ([]byte, error) {
1338+
size, err := strconv.ParseInt(contentLength, 10, 64)
1339+
if err != nil || size <= 0 || size > maxResponseBodyPreallocateSize {
1340+
return io.ReadAll(body)
1341+
}
1342+
1343+
buffer := bytes.NewBuffer(make([]byte, 0, int(size)+bytes.MinRead))
1344+
_, err = buffer.ReadFrom(body)
1345+
return buffer.Bytes(), err
1346+
}
1347+
13301348
// logToolCall logs MCP protocol-level request/response for tools/call.
13311349
// This is needed because tools/call handler is invoked inside callTool(),
13321350
// which does not go through the MCP middleware chain.

src/mcp-proxy/pkg/infra/proxy/proxy_benchmark_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ var (
5858
func BenchmarkGenToolHandlerLargeJSONResponse(b *testing.B) {
5959
initBenchmarkRuntime(b)
6060

61-
for _, size := range []int{64 << 10, 1 << 20} {
61+
for _, size := range []int{64 << 10, 1 << 20, 10 << 20} {
6262
size := size
6363
b.Run(strconv.Itoa(size)+"B", func(b *testing.B) {
6464
responseBody := buildBenchmarkJSONBody(size)
@@ -286,10 +286,10 @@ func BenchmarkMCPToolResultWireEncode(b *testing.B) {
286286
}
287287
}
288288

289-
// BenchmarkReadLargeResponseBody compares the current unhinted io.ReadAll path with
290-
// the allocation headroom available when an upstream Content-Length can be trusted.
289+
// BenchmarkReadLargeResponseBody compares the previous unhinted io.ReadAll path with
290+
// the production response reader when an upstream Content-Length is available.
291291
func BenchmarkReadLargeResponseBody(b *testing.B) {
292-
for _, size := range []int{64 << 10, 1 << 20} {
292+
for _, size := range []int{64 << 10, 1 << 20, 10 << 20} {
293293
size := size
294294
b.Run(strconv.Itoa(size)+"B", func(b *testing.B) {
295295
body := buildBenchmarkJSONBody(size)
@@ -307,16 +307,16 @@ func BenchmarkReadLargeResponseBody(b *testing.B) {
307307
}
308308
})
309309

310-
b.Run("content-length-preallocated", func(b *testing.B) {
310+
b.Run("production-content-length", func(b *testing.B) {
311311
b.ReportAllocs()
312312
b.SetBytes(int64(len(body)))
313313
for i := 0; i < b.N; i++ {
314314
reader := benchmarkChunkReader{body: body}
315-
buffer := bytes.NewBuffer(make([]byte, 0, len(body)+bytes.MinRead))
316-
if _, err := buffer.ReadFrom(&reader); err != nil {
317-
b.Fatalf("read preallocated response body: %v", err)
315+
readBody, err := readResponseBody(&reader, strconv.Itoa(len(body)))
316+
if err != nil {
317+
b.Fatalf("read response body: %v", err)
318318
}
319-
benchmarkResponseBody = buffer.Bytes()
319+
benchmarkResponseBody = readBody
320320
}
321321
})
322322
})
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* TencentBlueKing is pleased to support the open source community by making
3+
* 蓝鲸智云 - API 网关(BlueKing - APIGateway) available.
4+
* Copyright (C) Tencent. All rights reserved.
5+
* Licensed under the MIT License (the "License"); you may not use this file except
6+
* in compliance with the License. You may obtain a copy of the License at
7+
*
8+
* http://opensource.org/licenses/MIT
9+
*
10+
* Unless required by applicable law or agreed to in writing, software distributed under
11+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
12+
* either express or implied. See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*
15+
* We undertake not to change the open source license (MIT license) applicable
16+
* to the current version of the project delivered to anyone in the future.
17+
*/
18+
19+
package proxy
20+
21+
import (
22+
"errors"
23+
"io"
24+
"strconv"
25+
"strings"
26+
27+
. "github.com/onsi/ginkgo/v2"
28+
. "github.com/onsi/gomega"
29+
)
30+
31+
var _ = Describe("readResponseBody", func() {
32+
DescribeTable("reads the complete response body regardless of the Content-Length hint",
33+
func(contentLength, body string) {
34+
result, err := readResponseBody(strings.NewReader(body), contentLength)
35+
36+
Expect(err).NotTo(HaveOccurred())
37+
Expect(result).To(Equal([]byte(body)))
38+
},
39+
Entry("with an exact length", "13", "complete-body"),
40+
Entry("without a length", "", "complete-body"),
41+
Entry("with an invalid length", "not-a-number", "complete-body"),
42+
Entry("with a zero length", "0", "complete-body"),
43+
Entry("with a negative length", "-1", "complete-body"),
44+
Entry("with a length above the preallocation limit",
45+
strconv.Itoa(maxResponseBodyPreallocateSize+1), "complete-body"),
46+
Entry("with an underestimated length", "3", "complete-body"),
47+
Entry("with an overestimated length", "1024", "complete-body"),
48+
)
49+
50+
It("returns bytes read before a reader error and preserves the error", func() {
51+
readErr := errors.New("response read failed")
52+
reader := &errorResponseReader{
53+
body: []byte("partial-body"),
54+
err: readErr,
55+
}
56+
57+
result, err := readResponseBody(reader, strconv.Itoa(len(reader.body)))
58+
59+
Expect(result).To(Equal([]byte("partial-body")))
60+
Expect(err).To(MatchError(readErr))
61+
})
62+
})
63+
64+
type errorResponseReader struct {
65+
body []byte
66+
err error
67+
}
68+
69+
func (r *errorResponseReader) Read(p []byte) (int, error) {
70+
if len(r.body) == 0 {
71+
return 0, r.err
72+
}
73+
74+
n := copy(p, r.body)
75+
r.body = r.body[n:]
76+
if len(r.body) == 0 {
77+
return n, r.err
78+
}
79+
return n, nil
80+
}
81+
82+
var _ io.Reader = (*errorResponseReader)(nil)

0 commit comments

Comments
 (0)