Skip to content

fix(middleware): support zstd Content-Encoding in DecompressRequestMiddleware - #6348

Closed
xiaoyuyu6420 wants to merge 1 commit into
QuantumNous:mainfrom
xiaoyuyu6420:fix/middleware-zstd-decompress
Closed

fix(middleware): support zstd Content-Encoding in DecompressRequestMiddleware#6348
xiaoyuyu6420 wants to merge 1 commit into
QuantumNous:mainfrom
xiaoyuyu6420:fix/middleware-zstd-decompress

Conversation

@xiaoyuyu6420

@xiaoyuyu6420 xiaoyuyu6420 commented Jul 20, 2026

Copy link
Copy Markdown

⚠️ 提交说明 / PR Notice

Important

  • 请提供人工撰写的简洁摘要,避免直接粘贴未经整理的 AI 输出。

📝 变更描述 / Description

DecompressRequestMiddleware 已挂在 /v1 relay 路由上,但它的 switch 只处理了 gzipbr没有 zstd 分支。带 Content-Encoding: zstd 的请求体被原封不动交给下游 JSON 解析器,zstd magic 28 b5 2f fd 的首字节 0x28(ASCII ()立刻触发解析失败,客户端收到 HTTP 400

invalid character '(' looking for beginning of value

影响:近期的 OpenAI Codex CLI 与 Desktop(wire_api = responses)默认开启请求体压缩(client feature enable_request_compression,默认 on)。由于完整请求体(system prompt + 工具 schema)即便只发一句 hi 也有几十 KB,必然超过压缩阈值 → 每一轮对话都必现此 400,错误信息还误导(说"invalid request"而非"请求体被压缩")。这不是边缘场景。

修复(位置:middleware/gzip.go):

新增 zstd 解压分支

  • 新增 case "zstd": 分支,镜像现有 gzip/br 分支:用 zstd.NewReader 解码 → 包入 MaxBytesReader(与 gzip/br 一致的 post-decompression 限制)→ Del("Content-Encoding") + clearContentLength()
  • zstd.NewReader 是惰性的,无效帧在首 Read 时报错而非构造时(与 gzip 不同)。已添加注释说明。

合并 PR #4936 的改进(by @nerimoe

  • Magic-byte peek 自动检测:当 Content-Encoding 缺失或为 identity 时,peek 前 4 字节自动检测 gzip(0x1f 0x8b)和 zstd(0x28 0xb5 0x2f 0xfd)压缩。JSON 首字节不可能是这两个值,无误判风险。
  • Content-Length 清理:解压后 ContentLength 设为 -1、删除 Content-Length header,避免下游使用过期的压缩前大小。
  • OpenAI 风格 JSON 错误响应:无效 gzip/zstd 返回 400 + {"error":{"message":"...","type":"invalid_request_error"}}
  • 不支持的编码返回 415deflate 等不支持的编码不再 passthrough(会导致 JSON 解析失败),而是返回 415 + JSON 错误体。⚠️ 这是一个行为变更——之前发 Content-Encoding: deflate 的请求会透传到下游(大概率 JSON 解析失败),现在会被明确拒绝。
  • bufio.Reader 仅在需要 peek 时创建(编码明确为 gzip/br/zstd 时不创建),避免不必要的 4KB 缓冲开销。

为什么这是 new-api 自身的程序缺陷而非上游/透传问题

请求在到达转发环节之前就已经在 new-api 自己的中间件里被错误处理了;同一个中间件已为 gzip/br 做了相同适配,只差一个 zstd 分支,是明确的覆盖遗漏。

🚀 变更类型 / Type of change

  • 🐛 Bug 修复 (Bug fix) - 请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug
  • ✨ 新功能 (New feature) - 重大特性建议先通过 Issue 沟通
  • ⚡ 性能优化 / 重构 (Refactor)
  • 📝 文档更新 (Documentation)

🔗 关联任务 / Related Issue

⚠️ 行为变更 / Breaking Changes

  • Content-Encoding 为不支持的编码(如 deflate)时,之前会 passthrough(下游大概率 JSON 解析失败),现在返回 415 Unsupported Media Type。这是更正确的错误处理,但如果有客户端依赖 passthrough 行为,需要调整。

✅ 提交前检查项 / Checklist

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds Zstandard request-body decompression, compressed-body auto-detection, explicit error handling, request metadata cleanup, and comprehensive middleware tests covering encoded, invalid, uncompressed, identity, and GET request paths.

Changes

Request decompression

Layer / File(s) Summary
Decompression and encoding handling
go.mod, middleware/gzip.go
Registers klauspost/compress directly and adds Zstandard decoding, magic-byte detection, size limits, cleanup, header removal, and JSON error responses.
Encoding behavior validation
middleware/gzip_test.go
Tests compression helpers, successful decoding across gzip, Brotli, and Zstandard, pass-through behavior, invalid input handling, JSON parsing, and GET skipping.
Detection, metadata, and error validation
middleware/gzip_test.go
Tests gzip/Zstandard auto-detection, identity handling, unsupported encodings, invalid gzip, content-length cleanup, and empty bodies.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant DecompressRequestMiddleware
  participant Decoder
  participant JSONHandler
  Client->>DecompressRequestMiddleware: Send encoded or auto-detectable request body
  DecompressRequestMiddleware->>Decoder: Create gzip, Brotli, or Zstandard reader
  Decoder-->>DecompressRequestMiddleware: Return decompressed stream
  DecompressRequestMiddleware->>JSONHandler: Provide limited body without encoding metadata
  JSONHandler-->>Client: Return request result
Loading

Poem

A rabbit sends a frame at night,
Zstd makes every byte feel light.
Middleware opens every gate,
Bad frames meet a proper fate.
“Hop, decoded!” the bunny sings.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The middleware now decodes Content-Encoding: zstd, strips the header, preserves size limits, and returns 400 on invalid data, matching #6313.
Out of Scope Changes check ✅ Passed The extra encoding-handling and test updates stay within request decompression behavior and support the zstd fix.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding zstd Content-Encoding support to DecompressRequestMiddleware.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
middleware/gzip.go (1)

74-74: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Set zstd decoder concurrency to 1
zstd.NewReader defaults to 4 decoders or GOMAXPROCS, so this path can still use extra goroutines. If request bodies are always processed synchronously, pass zstd.WithDecoderConcurrency(1) here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware/gzip.go` at line 74, Update the zstd.NewReader call in the
request-body decompression path to pass zstd.WithDecoderConcurrency(1), ensuring
synchronous processing uses only one decoder goroutine while preserving the
existing reader and error-handling behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@middleware/gzip.go`:
- Line 74: Update the zstd.NewReader call in the request-body decompression path
to pass zstd.WithDecoderConcurrency(1), ensuring synchronous processing uses
only one decoder goroutine while preserving the existing reader and
error-handling behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 913f835d-7785-41c8-901a-e2008bb6dfd3

📥 Commits

Reviewing files that changed from the base of the PR and between e0d5156 and a68b44e.

📒 Files selected for processing (3)
  • go.mod
  • middleware/gzip.go
  • middleware/gzip_test.go

xiaoyuyu6420 added a commit to xiaoyuyu6420/new-api that referenced this pull request Jul 20, 2026
Follow-up to QuantumNous#6348 (self-review before reviewer picks it up).

AGENTS.md:121 mandates testify for new backend tests; the initial
gzip_test.go used bare t.Fatalf/t.Errorf. Migrated to require.* for
setup + fatal assertions and assert.* for non-fatal value checks,
matching middleware/auth_test.go and rate_limit_test.go.

Also added TestDecompressRequestMiddleware_ZstdProducesValidJSON,
which feeds a zstd-compressed body through the middleware and confirms
the result is unmarshallable via common.UnmarshalJsonStr — i.e. it
guards the regression at the layer where QuantumNous#6313 actually surfaces
(downstream JSON parsing), not just at byte equality.

All 6 tests pass:
  go test ./middleware/ -run DecompressRequestMiddleware -count=1
@xiaoyuyu6420

Copy link
Copy Markdown
Author

Follow-up self-review before this gets reviewed:

All 6 tests pass (go test ./middleware/ -run DecompressRequestMiddleware -count=1). Production change in middleware/gzip.go is untouched.

@xiaoyuyu6420
xiaoyuyu6420 force-pushed the fix/middleware-zstd-decompress branch 2 times, most recently from 315dee6 to 0417e95 Compare July 20, 2026 16:16
@xiaoyuyu6420

Copy link
Copy Markdown
Author

Pre-empting a question that came up while reviewing this change against the older sibling PR #4936 (whose CodeRabbit flagged it):

Why c.Request.ContentLength is not reset after decompression (intentional, same as the existing gzip/br branches).

After the body is replaced with the decompressed reader, c.Request.ContentLength still holds the compressed byte count. I traced every downstream consumer before deciding not to touch it in this PR:

  • common/gin.go:66 hands contentLength to CreateBodyStorageFromReader, which uses it only to pick disk vs memory caching when disk caching is enabled (body_storage.go:266contentLength > 0 && contentLength >= threshold). The actual read is io.ReadAll(io.LimitReader(reader, maxBytes+1)), i.e. it drains the decompressed reader regardless of the reported length. So a stale compressed length can at worst mis-route a borderline-large request to memory instead of disk; it cannot truncate or corrupt the body.
  • applyUpstreamContentLength (relay/channel/api_request.go:36) only fires when req.ContentLength <= 0, and the value it then uses is info.UpstreamRequestBodySize, which comes from storage.Size() (the decompressed size) or a fresh marshal — not from c.Request.ContentLength. So the upstream request gets the correct length either way.
  • middleware/request_body_limit.go:33 does reset ContentLength, but that path fully drains the body into a []byte first; it is not comparable to the streaming decompression here.

Net: a stale ContentLength only biases the disk-cache routing decision, and only for requests whose compressed size happens to straddle DiskCacheThreshold while disk caching is enabled — an edge case with no functional impact. Resetting it properly would mean either draining the decompressed body up front (defeats streaming) or plumbing an unknown sentinel through BodyStorage, which is a larger change with its own risk surface. That belongs in a separate PR if anyone cares about the cache-routing edge case; it is orthogonal to #6313 (which is "zstd bodies fail JSON parsing"), and it would also have to touch the existing gzip/br branches to be consistent.

Calling it out here so reviewers don't have to re-derive it.

@KNaiFen

KNaiFen commented Jul 29, 2026

Copy link
Copy Markdown

非常需要这个PR,现在我Codex只能开本地压缩来用,把端点改成OpenAI就JSON格式错误,太难受了

PS:我自己Fork了你的PR打包了一份,很好用,感谢老哥,终于可以远程压缩了,不用再忍受上下文一长压缩了之后,Codex就和弱智一样忘事或者顾头不顾腚的痛苦了👍

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
middleware/gzip.go (2)

107-135: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider synchronous decoding for per-request zstd decoders.

By default zstd.NewReader decodes streams concurrently across multiple internal goroutines. For per-request JSON bodies (typically small), spawning that concurrency machinery on every request adds avoidable goroutine/channel overhead; the library docs note that WithDecoderConcurrency(1) decompresses synchronously without spawning goroutines.

-			zstdReader, err := zstd.NewReader(src)
+			zstdReader, err := zstd.NewReader(src, zstd.WithDecoderConcurrency(1))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware/gzip.go` around lines 107 - 135, Configure the per-request decoder
created in the zstd branch of the request-body decompression flow to use
synchronous decoding by passing the library’s single-concurrency decoder option
to zstd.NewReader. Preserve the existing error handling, wrapped read-closer
behavior, and header cleanup.

68-149: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated wrap/cleanup logic shared by gzip, br, and zstd branches.

The wrapMaxBytes(&readCloser{...}) → clearContentLength() → Header.Del("Content-Encoding") sequence is duplicated near-verbatim across all three compressed-encoding branches, differing only in the underlying reader/closeFn. A small helper would remove the repetition and reduce the chance of one branch drifting (e.g., forgetting clearContentLength()).

♻️ Proposed helper
+		finalizeDecoded := func(r io.Reader, closeFn func() error) {
+			c.Request.Body = wrapMaxBytes(&readCloser{Reader: r, closeFn: closeFn})
+			clearContentLength()
+			c.Request.Header.Del("Content-Encoding")
+		}
 		switch encoding {
 		case "gzip":
 			...
-			c.Request.Body = wrapMaxBytes(&readCloser{
-				Reader: gzipReader,
-				closeFn: func() error {
-					_ = gzipReader.Close()
-					return origBody.Close()
-				},
-			})
-			clearContentLength()
-			c.Request.Header.Del("Content-Encoding")
+			finalizeDecoded(gzipReader, func() error {
+				_ = gzipReader.Close()
+				return origBody.Close()
+			})

Apply similarly to the br and zstd branches.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware/gzip.go` around lines 68 - 149, Extract the shared request-body
setup into a helper near the middleware logic, accepting the decoded reader and
cleanup function, and have it perform wrapMaxBytes, clearContentLength, and
Content-Encoding removal. Replace the duplicated setup in the gzip, br, and zstd
branches with calls to this helper while preserving each decoder’s existing
reader and close behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@middleware/gzip_test.go`:
- Around line 145-182: Update
TestDecompressRequestMiddleware_InvalidZstdDoesNotLeak to assert the recorder
returns HTTP 400 for malformed zstd input, matching
TestDecompressRequestMiddleware_InvalidGzipReturns400. Revise the test comment
and function description to state that zstd decoding errors are surfaced as a
400 response, and remove the outdated conditional assertion focused only on
preventing raw-byte leakage.

In `@middleware/gzip.go`:
- Around line 48-65: Restrict the auto-detection block in
DecompressRequestMiddleware() to requests with a supporting JSON Content-Type,
while preserving explicit Content-Encoding handling. Only peek and infer gzip or
zstd when Content-Encoding is absent or identity and the request is JSON; leave
audio and multipart image uploads unchanged.
- Around line 107-135: Update the zstd branch around zstdReader initialization
to immediately read one decoded byte before assigning it to c.Request.Body,
treating any probe error as invalid zstd input through the existing 400 response
path. When the probe succeeds, preserve that byte by prepending it to the reader
before wrapping, so JSON binding receives the complete decompressed body.

---

Nitpick comments:
In `@middleware/gzip.go`:
- Around line 107-135: Configure the per-request decoder created in the zstd
branch of the request-body decompression flow to use synchronous decoding by
passing the library’s single-concurrency decoder option to zstd.NewReader.
Preserve the existing error handling, wrapped read-closer behavior, and header
cleanup.
- Around line 68-149: Extract the shared request-body setup into a helper near
the middleware logic, accepting the decoded reader and cleanup function, and
have it perform wrapMaxBytes, clearContentLength, and Content-Encoding removal.
Replace the duplicated setup in the gzip, br, and zstd branches with calls to
this helper while preserving each decoder’s existing reader and close behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 315326e6-4750-4c5c-88ad-f3af0365b6dd

📥 Commits

Reviewing files that changed from the base of the PR and between 0417e95 and 6fc4844.

📒 Files selected for processing (3)
  • go.mod
  • middleware/gzip.go
  • middleware/gzip_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • go.mod

Comment thread middleware/gzip_test.go
Comment thread middleware/gzip.go
Comment on lines +48 to +65

encoding := strings.ToLower(c.GetHeader("Content-Encoding"))
encoding = strings.TrimSpace(encoding)

switch c.GetHeader("Content-Encoding") {
// When Content-Encoding is absent or "identity", peek at the first few
// bytes to auto-detect gzip/zstd compression. JSON's first byte is
// never 0x1f (gzip) or 0x28 (zstd), so false-positive detection is
// impossible for valid JSON payloads.
var br *bufio.Reader
if encoding == "" || encoding == "identity" {
br = bufio.NewReader(origBody)
peek, _ := br.Peek(4)
if len(peek) >= 2 && peek[0] == 0x1f && peek[1] == 0x8b {
encoding = "gzip"
} else if len(peek) >= 4 && peek[0] == 0x28 && peek[1] == 0xb5 && peek[2] == 0x2f && peek[3] == 0xfd {
encoding = "zstd"
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check where DecompressRequestMiddleware is registered and whether binary-upload
# routes (audio/image) are excluded from it.
rg -n 'DecompressRequestMiddleware' --type=go -C3
rg -n --type=go -C2 -i 'audio|image|transcription|whisper' router

Repository: QuantumNous/new-api

Length of output: 157


🏁 Script executed:

#!/bin/bash
set -u

printf 'Tracked Go files matching gzip/compress/router names:\n'
git ls-files '*.go' | rg -i 'middleware|gzip|compress|route|router|upload|audio|image|whisper|transcription' | head -200 || true

printf '\nSearch DecompressRequestMiddleware/ gzip middleware symbols across repo:\n'
rg -n -i 'DecompressRequestMiddleware|RequestCompression|Compression|gzip|zstd|Content-Encoding' --glob '*.go' --glob '*.ts' --glob '*.tsx' . | head -300 || true

Repository: QuantumNous/new-api

Length of output: 15381


🏁 Script executed:

#!/bin/bash
set -u

printf 'middleware/gzip.go outline:\n'
ast-grep outline middleware/gzip.go --view expanded || true

printf '\nSlices of middleware/gzip.go:\n'
sed -n '1,150p' middleware/gzip.go | cat -n

printf '\nrouter/relay-router.go relevant lines:\n'
sed -n '1,220p' router/relay-router.go | cat -n

printf '\nSearch for body-limit/read body middlewares near relay routes:\n'
rg -n -i -C3 'RequestBodyLimit|LimitRequestBody|GinLimitRequestBody|C.PostForm|ReadString|Request.Body|BodyBytes|multipart|form-data' --glob '*.go' --glob '*.ts' router middleware relay relaykit service common | head -400

Repository: QuantumNous/new-api

Length of output: 39723


🏁 Script executed:

#!/bin/bash
set -u

printf 'Relay audio handler outline:\n'
ast-grep outline relay/audio_handler.go --view expanded || true

printf '\nRelay audio handler relevant lines:\n'
sed -n '1,260p' relay/audio_handler.go | cat -n

printf '\nRelay image handler outline / relevant lines:\n'
ast-grep outline relay/image_handler.go --view expanded || true
sed -n '1,220p' relay/image_handler.go | cat -n

printf '\nBehavioral probe: multipart/form-data requests reach middleware decompression before distributor?\n'
# Read-only structural check: list middleware ordering and distributor's request-body parsing behavior.
rg -n -C4 'func SetRelayRouter|Use\\(middleware\\.|Distributor\\(|ReadForm|PostForm|ParseMultipartForm|c\\.Request\\.Body' --glob '*.go' router relay middleware service | head -250

Repository: QuantumNous/new-api

Length of output: 9788


Scope auto-detected gzip/zstd decompression to JSON relay input.

DecompressRequestMiddleware() is applied to the relay router before /v1/audio/transcriptions, /v1/audio/translations, and multipart image uploads. Since the middleware peeks the raw body when Content-Encoding is missing, image/audio uploads whose first bytes coincidentally match 0x1f 0x8b or 0x28 0xb5 0x2f 0xfd can be routed through a decompressor before the distributor/handler sees them. Only apply this fallback on JSON relay requests, or gate it behind both missing Content-Encoding and a supporting Content-Type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware/gzip.go` around lines 48 - 65, Restrict the auto-detection block
in DecompressRequestMiddleware() to requests with a supporting JSON
Content-Type, while preserving explicit Content-Encoding handling. Only peek and
infer gzip or zstd when Content-Encoding is absent or identity and the request
is JSON; leave audio and multipart image uploads unchanged.

Comment thread middleware/gzip.go
Comment on lines +107 to +135
case "zstd":
src := io.Reader(origBody)
if br != nil {
src = br
}
// OpenAI Codex CLI/Desktop default to zstd request-body compression
// (client feature `enable_request_compression`). Without this branch
// the raw zstd frame (magic 0x28 0xb5 0x2f 0xfd) is handed to the
// JSON parser and fails with `invalid character '('`.
zstdReader, err := zstd.NewReader(src)
if err != nil {
_ = origBody.Close()
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"error": gin.H{
"message": "invalid zstd body",
"type": "invalid_request_error",
},
})
return
}
c.Request.Body = wrapMaxBytes(&readCloser{
Reader: zstdReader,
closeFn: func() error {
zstdReader.Close()
return origBody.Close()
},
})
clearContentLength()
c.Request.Header.Del("Content-Encoding")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does klauspost/compress zstd.NewReader validate the frame header eagerly or only on first Read?

💡 Result:

Yes, klauspost/compress zstd.NewReader validates the Zstandard frame header eagerly upon initialization, specifically during the Reset process [1][2]. When you call NewReader(r), it internally calls Reset(r) [1][2]. The Reset method initiates the decoding process for the provided io.Reader [2]. If a valid io.Reader is provided, the decoder immediately begins parsing the input to establish the Zstandard frame state [1][2]. If the frame header cannot be parsed (e.g., due to an invalid magic number or insufficient input), the error is returned immediately by NewReader [1][2]. Because the decoder is designed to handle streaming data, it must validate the frame header to understand the stream's parameters (such as window size and dictionary usage) before any Read operation can successfully yield decompressed data. While NewReader is the constructor, the underlying Reset logic ensures the stream is ready for reading by performing this initial validation [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate middleware/gzip.go and module =="
git ls-files | rg '(^|/)middleware/gzip\.go$|(^|/)go\.mod$|go\.sum$|middleware/gzip.*' || true

if [ -f go.mod ]; then
  rg -n 'klauspost/compress|zstd' go.mod go.sum || true
fi

echo "== inspect middleware/gzip.go outline and relevant section =="
if [ -f middleware/gzip.go ]; then
  wc -l middleware/gzip.go
  sed -n '1,180p' middleware/gzip.go | cat -n
fi

echo "== search zstd-related handling comments/calls in middleware/gzip.go =="
rg -n 'zstd\.NewReader|klauspost/compress|NewReader|invalid zstd|Content-Encoding|gzip' middleware/gzip.go || true

echo "== inspect module dependency metadata if available =="
if [ -f go.sum ]; then
  rg -n 'klauspost/compress' go.sum | head -20 || true
fi

Repository: QuantumNous/new-api

Length of output: 8502


🌐 Web query:

klauspost compress zstd v1.18.0 NewReader Reset magic number header validation Read behavior

💡 Result:

In the klauspost/compress library (including version 1.18.0), the zstd package handles magic number validation and stream resetting as follows: NewReader and Reset Behavior The NewReader function initializes a decoder, which is designed to be stored and reused to avoid memory allocations [1][2][3]. To reuse a decoder for a new Zstandard stream, the Reset(r io.Reader) method is used [4][1][3]. When Reset(r) is called: - If r is a valid io.Reader, the decoder clears the previous stream state, drains the output, and prepares to read from the new stream [4][3]. - If r is nil, the decoder enters a state where it expects no further operations other than another Reset, DecodeAll, or Close [4][3]. - The decoder can be safely reused even if the previous stream failed [1][2][3]. - Reset cannot be used after the Close method has been called, as calling Close terminates the internal goroutines and invalidates the decoder [1][2][3]. Magic Number Header Validation The zstd decoder performs magic number validation as part of its stream processing. If the input does not begin with the expected Zstandard magic number, the decoder will return an error, typically ErrMagicMismatch [5]. For users who need to validate the magic number explicitly without initiating a full decoding process, the library provides the zstd.Header type. The Header.DecodeAndStrip(in []byte) method can be used to inspect and validate the beginning of a byte slice, checking for the standard Zstandard frame magic number or skippable frame magic numbers [6]. It returns an error if the magic number is invalid [6]. Version 1.18.0 Specifics The v1.18.0 release (dated February 19, 2025) included various internal refactorings and bug fixes, such as improving small-block compression speed, adding unsafe little-endian loaders, and fixing minor issues related to error handling and buffer sizes [7][8][9][10]. It did not introduce breaking changes to the fundamental behavior of NewReader, Reset, or the core validation logic used in previous versions [7][10].

Citations:


🌐 Web query:

github.com/klauspost/compress v1.18 zstd decoder.go NewReader Reset reads bytes magic number

💡 Result:

In the klauspost/compress library (v1.18), the zstd Decoder does not explicitly "read" the magic number at the exact moment Reset(r) is called [1][2]. Instead, the Reset(r) method reinitializes the decoder's state to process a new stream, preparing it for subsequent operations [1][2]. The actual reading of the input stream, which includes verifying the Zstandard magic number, occurs later when the decoder begins performing read operations (such as calling Read or DecodeAll) on the new reader [1][2]. Key details regarding this behavior include: 1. Reset(r) Mechanism: The Reset method accepts an io.Reader [1][2]. If a valid (non-nil) reader is provided, the decoder clears internal buffers and prepares the internal state to begin decoding from that reader [1][2]. If a nil reader is provided, the decoder releases references to the previous reader and enters a state where it expects either a future Reset or a call to DecodeAll/Close [1][2][3]. 2. Magic Number Verification: The Zstandard format specifies a magic number at the start of a valid frame. The decoder checks for this magic number during the decoding process, specifically when it starts consuming the data provided to the Reader [4]. If the provided stream does not start with the correct magic number, the decoder will return ErrMagicMismatch [4]. 3. Resource Management: Using Reset is a recommended practice to avoid unnecessary memory allocations by reusing the existing decoder's resources [5][6]. If you are encountering issues, ensure that the io.Reader passed to Reset is valid and that it correctly supplies the Zstandard-encoded stream, as the decoder only validates the data structure once it begins processing bytes [1][4].

Citations:


🌐 Web query:

github klauspost compress v1.18 zstd decoder.go NewReader zstd.NewReader Reset calls Read magic

💡 Result:

In the klauspost/compress library (v1.18+), the zstd.Decoder handles Zstandard stream initialization and magic number validation as follows: 1. Initialization and Reset: When you create a decoder using zstd.NewReader(r,...), it internally calls the Reset(r) method to initialize the stream [1][2]. The Reset(r) method allows you to reuse an existing decoder instance with a new io.Reader, which helps avoid unnecessary allocations [1][3][4]. 2. Magic Number Validation: The decoder expects the input stream to begin with a valid Zstandard frame header, which includes a specific "magic number" [5]. If the provided io.Reader does not start with this expected magic number, the decoder returns an ErrMagicMismatch error [5]. 3. Common Pitfalls: A common cause for receiving an "invalid input: magic number mismatch" error is passing data that is not a raw Zstandard stream, such as data that has not been properly compressed or data that has been modified [5][6]. Additionally, users must ensure the provided io.Reader is correctly positioned at the start of the compressed data [1][4]. 4. Proper Resource Management: - To reuse a decoder, call Reset(r). If you are finished with the decoder, you must call Close to stop any running internal goroutines and release resources [3][4]. - After calling Close, the decoder instance can no longer be used [3][4][7]. - If you call Reset(nil), it releases references to the previous reader, but the decoder instance remains in a state where only Reset, DecodeAll, or Close can be called [1][2][7].

Citations:


🌐 Web query:

klauspost compress v1.18.0 zstd decoder.go NewReader code Read decoder Reset

💡 Result:

In the klauspost/compress library (zstd package), the Decoder is designed for high performance by minimizing allocations, which is achieved by reusing the Decoder instance across multiple streams [1][2]. Decoder Operations NewReader: Used to create a new decoder instance [3][4]. You can pass a nil reader to NewReader if you intend to initialize the decoder later using Reset [3][4]. Reset: This method allows you to reuse an existing Decoder for a new stream, which avoids the overhead of creating a new object and its associated allocations [3][1][2]. After the current stream is processed, call Reset(newReader) to begin decoding a different stream [3][5]. Handling Resources: - To release resources held by the previous reader without starting a new stream, you can call Reset(nil) [3][4][5]. - After calling Reset(nil), the decoder should only be used for subsequent Reset, DecodeAll, or Close operations [3][4][5]. - You must call Close when you are finished with the Decoder entirely to stop internal goroutines and release resources [1][2]. Once Close is called, the Decoder cannot be reused [1][4][5]. Read: The Decoder implements the io.Reader interface, so you use the standard Read(p []byte) method to pull decompressed data from the stream [5]. Example Pattern // Create a decoder for reuse decoder, _:= zstd.NewReader(nil) defer decoder.Close // Process multiple streams for _, stream:= range streams { decoder.Reset(stream) io.Copy(destination, decoder) } For more advanced requirements, ResetWithOptions(r io.Reader, opts...DOption) is also available, allowing you to reconfigure the decoder while resetting it [5][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== download klauspost compress zstd source at v1.18.0 if tool is available =="
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
cd "$tmp"
go get github.com/klauspost/compress/zstd@v1.18.0 2>/dev/null || true
echo "$GOMODCACHE"
if [ -n "$GOMODCACHE" ]; then
  find "$GOMODCACHE/github.com/klauspost/compress" -type f -path '*zstd*' 2>/dev/null | head -50 || true
  echo "== relevant decoder.go snippets =="
  find "$GOMODCACHE/github.com/klauspost/compress" -type f -path '*decoder.go' 2>/dev/null | head -20 | while read -r f; do
    echo "-- $f"
    rg -n 'func NewReader|func \(d \*Decoder\) Reset|ErrMagicMismatch|magic|Read\(' "$f" -C 3 || true
  done
fi

echo "== local test-like byte sequence check =="
python3 - <<'PY'
magic = bytes.fromhex('28b52ffd')
print("zstd magic bytes in code branch:", " ".join(f"0x{b:02x}" for b in magic))
print("JSON starts with '{':", ord('{'))
PY

Repository: QuantumNous/new-api

Length of output: 296


🌐 Web query:

github.com klauspost compress v1.18.0 zstd decoder.go NewReader Reset calls d.decodedRead ResetBytes Read header magic

💡 Result:

In the klauspost/compress library (v1.18.0 and later), the zstd decoder's Reset method is designed to re-initialize an existing decoder instance to process a new stream, which helps minimize allocations [1][2]. When Reset(r io.Reader) is called: 1. It verifies the decoder state to ensure it has not been closed (returning ErrDecoderClosed if it has) [3][4]. 2. If a non-nil reader is provided, it resets the internal state and stream pointers to prepare for the new data source [3][4]. 3. For stream-based decoding, it either restarts the sync decoder or manages the stream decoder goroutines, waiting for existing work to complete via d.streamWg.Wait before switching to the new reader [3][4]. The specific handling of header magic and bytes is managed by the lower-level decoding logic during the read process, not directly as a monolithic step within the Reset function itself [3][4]. The Reset method primarily acts to clear the previous stream's buffers and set up the decoder for the next sequence of operations [4][1]. If Reset is called with a nil reader, the decoder releases references to the previous reader and enters a state where it expects no further operations other than another Reset, DecodeAll, or Close [3][4].

Citations:


🌐 Web query:

github.com/klauspost/compress v1.18.0 zstd/decoder.go NewReader Reset calls Read d.decodedRead ResetBytes

💡 Result:

In the klauspost/compress library (specifically the zstd package), the Decoder.Reset(r io.Reader) method is designed to allow the reuse of an existing Decoder instance for a new input stream, which significantly reduces allocations compared to creating a new Decoder via NewReader [1][2][3]. When Decoder.Reset(r io.Reader) is called: 1. It first ensures the current decoder state is handled by calling d.drainOutput [1][2]. 2. If a nil reader is provided, it effectively resets the decoder to a state where it expects no input, allowing the user to release references to the previous reader [1][2][4]. 3. It initializes the internal state to process the new stream provided by the io.Reader [1][2]. Regarding your query about Read, d.decodedRead, and ResetBytes: - The Read(p []byte) method is the standard io.Reader interface implementation for the Decoder, which provides decompressed data to the caller [5][4]. - The term d.decodedRead does not appear as a standard public field or method in the current decoder implementation; internal state management typically handles data buffers directly (e.g., d.current.b) [1][5]. - There is no method named ResetBytes in the public API of the zstd Decoder. The Reset(r io.Reader) method is the primary mechanism for resetting the decoder [1][2][3]. If you are performing stateless decoding of in-memory buffers, the recommended approach is to use DecodeAll, which handles buffer management automatically and does not require manual reader resetting [3][6]. In summary, to reuse a Decoder, you should call Reset(r io.Reader) with your new stream. There is no ResetBytes method, and internal read states are managed automatically by the Read method [1][2][6].

Citations:


🌐 Web query:

@github.com/klauspost/compress v1.18.0 decRead magic mismatch NewReader

💡 Result:

The error "invalid input: magic number mismatch" in the github.com/klauspost/compress library is a generic diagnostic message indicating that the data being read does not conform to the expected format (e.g., Zstd, Snappy/S2, or Zlib) [1][2]. This error is typically raised when the decompressor attempts to parse the beginning of a stream or block and fails to find the correct magic number [1][3]. Common causes for this error include: 1. Data Corruption: The input data has been corrupted or truncated [4]. 2. Incorrect Compression Format: Attempting to decompress data using the wrong algorithm (e.g., trying to read non-Zstd data with a Zstd reader) [1][2]. 3. Improper Buffer Usage: If using EncodeAll or DecodeAll with working buffers, providing a buffer that is not sized correctly (e.g., providing a slice with length instead of capacity, or failing to pass a zero-length slice with sufficient capacity) can lead to unexpected behavior and framing errors [1]. 4. Concurrent or Unsafe Access: Using Reset on a reader while it is still actively reading or being used by another goroutine can result in race conditions, leading to unexpected data states and magic number mismatches [2]. If you encounter this, verify that: - The data being passed to the decoder is valid and matches the format expected by that specific decoder [1][2]. - Buffers passed to methods like DecodeAll are properly managed (often requiring a zero-length slice with adequate capacity) [1]. - No concurrent, unsynchronized access to the same reader instance is occurring [2]. There were no specific breaking changes in version v1.18.0 known to trigger this error as a regression; it remains a standard error for malformed input [5][6]. If the issue persists with valid data, ensure that no external factors (like network interruption or file system issues) are corrupting the stream before it reaches the decoder [4].

Citations:


🌐 Web query:

github.com/klauspost/compress @v1.18.0 zstd decoder.go "func NewReader" "Reset(r" "d.current.b"

💡 Result:

In the klauspost/compress library (v1.18.0 and later), the Reset method for the zstd decoder is used to re-initialize an existing decoder instance to process a new io.Reader stream [1][2]. This pattern allows for the reuse of resources, significantly reducing allocations compared to creating a new decoder via NewReader [1][3]. When you call Decoder.Reset(r io.Reader), the following happens regarding d.current.b: 1. Handling Nil Input: If the provided reader r is nil, the decoder effectively resets its internal state without starting a new stream [1][4]. Specifically, the code performs the following cleanup: if r == nil { d.current.err = ErrDecoderNilInput if len(d.current.b) > 0 { d.current.b = d.current.b[:0] // Resets the slice length to 0 while keeping capacity } d.current.flushed = true return nil } 2. Memory Reuse: By setting d.current.b = d.current.b[:0], the decoder clears the slice of buffered bytes but retains its underlying capacity [1][4]. This allows the decoder to reuse the existing buffer for future operations, consistent with the library's design goal of operating with minimal allocations after a "warmup" period [5][3]. The Reset method should not be called after the Close function has been invoked, as the decoder can no longer be reused once closed [1][3]. If you need to release resources without closing the decoder permanently, you can call Reset with a nil reader [1][3].

Citations:


🌐 Web query:

github.com/klauspost/compress v1.18.0 "decRead" "magic" "zstd"

💡 Result:

In the klauspost/compress library (including version 1.18.0), there is no public function named decRead for checking magic numbers in Zstandard data [1][2][3]. The package identifies Zstandard frames by verifying the magic number (0xFD2FB528) during decoding operations, such as within the DecodeAndStrip method located in zstd/decodeheader.go [4]. When the magic number does not match the expected value, the library returns the error ErrMagicMismatch [4][5]. For applications that need to check for the presence of the Zstandard magic number in a byte slice without initializing a full decoder, the library does not provide a dedicated public HasMagic helper function [6]. Users typically implement this check manually by verifying the first four bytes against the standard Zstd magic sequence [6]: // Example of manually checking for Zstandard magic bytes func HasMagic(src []byte) bool { return len(src) >= 4 && src == 0x28 && src[1] == 0xB5 && src[2] == 0x2F && src[3] == 0xFD }

Citations:


🌐 Web query:

klauspost compress v1.18.0 zstd decoder.go "d.current.err" "Reset(r" "zstd.NewReader"

💡 Result:

In the klauspost/compress library (specifically the zstd package), the expression d.current.err within the Reset(r io.Reader) method is used to manage the decoder's state during stream transitions [1][2]. When you call zstd.NewReader(nil), the decoder is initialized with a nil input, and d.current.err is set to ErrDecoderNilInput [1][2]. The Reset(r) method is then used to provide a new io.Reader to the decoder, allowing it to reuse existing resources and avoid the overhead of reallocating buffers [1][3][2]. Key points regarding the implementation: 1. State Management: The Reset function checks if the decoder has been closed (d.current.err == ErrDecoderClosed) before proceeding [1][2]. If a nil reader is passed to Reset, the decoder updates its state by setting d.current.err to ErrDecoderNilInput and clearing internal buffers, effectively pausing or resetting the input stream without shutting down the decoder [1][2]. 2. Resource Reuse: By using NewReader(nil) followed by one or more Reset(r) calls, you can significantly reduce memory allocations [3][2]. This is the recommended approach for high-performance applications where multiple streams are decompressed sequentially [3][4]. 3. Version 1.18.0: This version maintained established patterns for decoder reuse [5][6]. It included improvements such as freeing decoder resources when Reset is called with a nil io.Reader, which helps manage memory more efficiently across stream transitions [5]. If you are encountering this code while debugging, it is part of the internal state machine that ensures the decoder correctly transitions between different input streams while maintaining high performance [1][2]. Always ensure that you call Close on the decoder when it is no longer needed to release all allocated goroutines and resources [3][4].

Citations:


Probe the zstd stream before wrapping the request body.

zstd.NewReader only initializes/reset state before c.Next(); stream/header validation happens during the first Read(), so malformed zstd payloads still bypass this invalid zstd body 400 path and fail later during JSON binding as raw compressed bytes. Add an immediate probe read after NewReader and prepend the decoded byte if the probe succeeds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@middleware/gzip.go` around lines 107 - 135, Update the zstd branch around
zstdReader initialization to immediately read one decoded byte before assigning
it to c.Request.Body, treating any probe error as invalid zstd input through the
existing 400 response path. When the probe succeeds, preserve that byte by
prepending it to the reader before wrapping, so JSON binding receives the
complete decompressed body.

@xiaoyuyu6420
xiaoyuyu6420 force-pushed the fix/middleware-zstd-decompress branch from 6fc4844 to d4730fe Compare July 29, 2026 08:45
…ddleware

Closes QuantumNous#6313

Adds a zstd decompression branch to DecompressRequestMiddleware, mirroring
the existing gzip/br branches. Also incorporates improvements from PR QuantumNous#4936
by @nerimoe (with permission — see PR comments):

- Magic-byte peek auto-detection when Content-Encoding is missing or
  'identity' (no false-positives: JSON first byte ≠ 0x1f/0x28)
- Content-Length/Content-Length-header cleanup after decompression
- OpenAI-style JSON error responses for invalid gzip/zstd and
  unsupported encodings (415)
- bufio.Reader only created when peek is needed (not on explicit headers)

15 testify tests covering: zstd regression, encoding parity, peek
detection, identity passthrough, 415 unsupported, 400 invalid gzip,
Content-Length cleanup, GET skip, empty body, invalid zstd no-leak,
end-to-end JSON unmarshal.

Co-authored-by: nerimoe <i@neri.moe>
@xiaoyuyu6420
xiaoyuyu6420 force-pushed the fix/middleware-zstd-decompress branch from d4730fe to 4a23215 Compare July 29, 2026 09:19
KNaiFen added a commit to KNaiFen/new-api that referenced this pull request Jul 29, 2026
# Conflicts:
#	middleware/gzip.go
#	middleware/gzip_test.go
@xiaoyuyu6420

Copy link
Copy Markdown
Author

关闭这个 PR,和 @nerimoe#4936 合力。

测试已经提到他的 fork(nerimoe#1),两边凑成一个完整的 PR,不重复占 maintainer 的 review 排期了。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DecompressRequestMiddleware 缺少 zstd 分支:Content-Encoding: zstd 的 /v1/responses 请求体被当作裸 JSON 解析,报 invalid character '('

2 participants