Repository: cloudwego/hertz
Pull request #474 (2022-12-10 20:54:51)

feat:  support trailer
#### What type of PR is this?
<!--
Add one of the following kinds:

build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
ci: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
docs: Documentation only changes
feat: A new feature
optimize: A new optimization
fix: A bug fix
perf: A code change that improves performance
refactor: A code change that neither fixes a bug nor adds a feature
style: Changes that do not affect the meaning of the code (white space, formatting, missing semi-colons, etc)
test: Adding missing tests or correcting existing tests
chore: Changes to the build process or auxiliary tools and libraries such as documentation generation
-->
feat
#### Check the PR title.
<!--
The description of the title will be attached in Release Notes, 
so please describe it from user-oriented, what this PR does / why we need it.
Please check your PR title with the below requirements:
-->
- [x] This PR title match the format: \<type\>(optional scope): \<description\>
- [x] The description of this PR title is user-oriented and clear enough for others to understand.


#### (Optional) Translate the PR title into Chinese.
增加 HTTP Trailer 支持

#### (Optional) More detail description for this PR(en: English/zh: Chinese).
<!--
Provide more detailed info for review. If it is a perf type PR, perf data is suggested to give.
-->
en:

- add `Trailer` struct to `RequestHeader` and `ResponseHeader`
- implement trailer read and write for http1.1

zh(optional):

- 为`RequestHeader` 和 `ResponseHeader` 增加 `Trailer` 结构
- 为 http1.1 实现 trailer 的读和写

#### Which issue(s) this PR fixes:
<!--
Automatically closes linked issue when PR is merged.
Eg: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->


CLAassistant: [![CLA assistant check](https://cla-assistant.io/pull/badge/not_signed)](https://cla-assistant.io/cloudwego/hertz?pullRequest=474) <br/>Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our [Contributor License Agreement](https://cla-assistant.io/cloudwego/hertz?pullRequest=474) before we can accept your contribution.<br/><sub>You have signed the CLA already but the status is still pending? Let us [recheck](https://cla-assistant.io/check/cloudwego/hertz?pullRequest=474) it.</sub>
codecov[bot]: # [Codecov](https://codecov.io/gh/cloudwego/hertz/pull/474?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego) Report
Base: **69.16**% // Head: **67.45**% // Decreases project coverage by **`-1.71%`** :warning:
> Coverage data is based on head [(`fc9d7e6`)](https://codecov.io/gh/cloudwego/hertz/pull/474?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego) compared to base [(`3225094`)](https://codecov.io/gh/cloudwego/hertz/commit/3225094b7df4e55e40c4dae54425a38707918c15?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego).
> Patch coverage: 38.12% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           develop     #474      +/-   ##
===========================================
- Coverage    69.16%   67.45%   -1.72%     
===========================================
  Files           92       94       +2     
  Lines         8709     9199     +490     
===========================================
+ Hits          6024     6205     +181     
- Misses        2324     2602     +278     
- Partials       361      392      +31     
```


| [Impacted Files](https://codecov.io/gh/cloudwego/hertz/pull/474?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego) | Coverage Δ | |
|---|---|---|
| [pkg/common/utils/chunk.go](https://codecov.io/gh/cloudwego/hertz/pull/474/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego#diff-cGtnL2NvbW1vbi91dGlscy9jaHVuay5nbw==) | `57.57% <ø> (ø)` | |
| [pkg/protocol/http1/ext/common.go](https://codecov.io/gh/cloudwego/hertz/pull/474/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego#diff-cGtnL3Byb3RvY29sL2h0dHAxL2V4dC9jb21tb24uZ28=) | `8.16% <0.00%> (-1.32%)` | :arrow_down: |
| [pkg/protocol/http1/ext/stream.go](https://codecov.io/gh/cloudwego/hertz/pull/474/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego#diff-cGtnL3Byb3RvY29sL2h0dHAxL2V4dC9zdHJlYW0uZ28=) | `0.00% <ø> (ø)` | |
| [pkg/protocol/http1/resp/stream.go](https://codecov.io/gh/cloudwego/hertz/pull/474/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego#diff-cGtnL3Byb3RvY29sL2h0dHAxL3Jlc3Avc3RyZWFtLmdv) | `0.00% <0.00%> (ø)` | |
| [pkg/protocol/http1/server.go](https://codecov.io/gh/cloudwego/hertz/pull/474/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego#diff-cGtnL3Byb3RvY29sL2h0dHAxL3NlcnZlci5nbw==) | `0.00% <0.00%> (ø)` | |
| [pkg/protocol/header.go](https://codecov.io/gh/cloudwego/hertz/pull/474/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego#diff-cGtnL3Byb3RvY29sL2hlYWRlci5nbw==) | `65.30% <5.40%> (-8.95%)` | :arrow_down: |
| [pkg/protocol/http1/req/header.go](https://codecov.io/gh/cloudwego/hertz/pull/474/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego#diff-cGtnL3Byb3RvY29sL2h0dHAxL3JlcS9oZWFkZXIuZ28=) | `61.97% <52.45%> (-4.44%)` | :arrow_down: |
| [pkg/protocol/args.go](https://codecov.io/gh/cloudwego/hertz/pull/474/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego#diff-cGtnL3Byb3RvY29sL2FyZ3MuZ28=) | `77.72% <56.25%> (-3.59%)` | :arrow_down: |
| [pkg/protocol/http1/resp/response.go](https://codecov.io/gh/cloudwego/hertz/pull/474/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego#diff-cGtnL3Byb3RvY29sL2h0dHAxL3Jlc3AvcmVzcG9uc2UuZ28=) | `47.01% <60.00%> (+2.66%)` | :arrow_up: |
| [pkg/protocol/http1/resp/header.go](https://codecov.io/gh/cloudwego/hertz/pull/474/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego#diff-cGtnL3Byb3RvY29sL2h0dHAxL3Jlc3AvaGVhZGVyLmdv) | `76.53% <73.33%> (-1.80%)` | :arrow_down: |
| ... and [5 more](https://codecov.io/gh/cloudwego/hertz/pull/474/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/cloudwego/hertz/pull/474?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cloudwego).

welkeyever: reviewer + @byene0923 🙏
li-jin-gou: PATL @Duslia @FGYFFFF @welkeyever 

Patch:
diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml
index 4e6b54f..683bd08 100644
--- a/.github/workflows/pr-check.yml
+++ b/.github/workflows/pr-check.yml
@@ -24,13 +24,6 @@ jobs:
         with:
           go-version: 1.19
 
-      - uses: actions/cache@v3
-        with:
-          path: ~/go/pkg/mod
-          key: reviewdog-${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
-          restore-keys: |
-            reviewdog-${{ runner.os }}-go-
-
       - uses: reviewdog/action-staticcheck@v1
         with:
           github_token: ${{ secrets.github_token }}
diff --git a/pkg/protocol/args.go b/pkg/protocol/args.go
index ebf9ae5..f29aefe 100644
--- a/pkg/protocol/args.go
+++ b/pkg/protocol/args.go
@@ -177,6 +177,19 @@ func releaseArg(h []argsKV) []argsKV {
 	return h[:len(h)-1]
 }
 
+func updateArgBytes(h []argsKV, key, value []byte) []argsKV {
+	n := len(h)
+	for i := 0; i < n; i++ {
+		kv := &h[i]
+		if kv.noValue && bytes.Equal(key, kv.key) {
+			kv.value = append(kv.value[:0], value...)
+			kv.noValue = ArgsHasValue
+			return h
+		}
+	}
+	return h
+}
+
 func setArgBytes(h []argsKV, key, value []byte, noValue bool) []argsKV {
 	n := len(h)
 	for i := 0; i < n; i++ {
diff --git a/pkg/protocol/consts/headers.go b/pkg/protocol/consts/headers.go
index 02ae7f3..d01af5a 100644
--- a/pkg/protocol/consts/headers.go
+++ b/pkg/protocol/consts/headers.go
@@ -17,37 +17,69 @@
 package consts
 
 const (
-	HeaderAuthorization = "Authorization"
-	HeaderHost          = "Host"
-	HeaderReferer       = "Referer"
-	HeaderContentType   = "Content-Type"
-	HeaderUserAgent     = "User-Agent"
-	HeaderExpect        = "Expect"
-	HeaderConnection    = "Connection"
-	HeaderContentLength = "Content-Length"
-	HeaderCookie        = "Cookie"
-	HeaderAltSvc        = "Alt-Svc"
-
-	HeaderServer           = "Server"
-	HeaderServerLower      = "server"
-	HeaderSetCookie        = "Set-Cookie"
-	HeaderSetCookieLower   = "set-cookie"
+	HeaderDate = "Date"
+
+	HeaderIfModifiedSince = "If-Modified-Since"
+	HeaderLastModified    = "Last-Modified"
+
+	// Redirects
+	HeaderLocation = "Location"
+
+	// Transfer coding
+	HeaderTE               = "TE"
+	HeaderTrailer          = "Trailer"
+	HeaderTrailerLower     = "trailer"
 	HeaderTransferEncoding = "Transfer-Encoding"
-	HeaderDate             = "Date"
 
-	HeaderRange        = "Range"
+	// Controls
+	HeaderCookie         = "Cookie"
+	HeaderExpect         = "Expect"
+	HeaderMaxForwards    = "Max-Forwards"
+	HeaderSetCookie      = "Set-Cookie"
+	HeaderSetCookieLower = "set-cookie"
+
+	// Connection management
+	HeaderConnection      = "Connection"
+	HeaderKeepAlive       = "Keep-Alive"
+	HeaderProxyConnection = "Proxy-Connection"
+
+	// Authentication
+	HeaderAuthorization      = "Authorization"
+	HeaderProxyAuthenticate  = "Proxy-Authenticate"
+	HeaderProxyAuthorization = "Proxy-Authorization"
+	HeaderWWWAuthenticate    = "WWW-Authenticate"
+
+	// Range requests
 	HeaderAcceptRanges = "Accept-Ranges"
 	HeaderContentRange = "Content-Range"
+	HeaderIfRange      = "If-Range"
+	HeaderRange        = "Range"
 
-	HeaderIfModifiedSince = "If-Modified-Since"
-	HeaderLastModified    = "Last-Modified"
+	// Response context
+	HeaderAllow       = "Allow"
+	HeaderServer      = "Server"
+	HeaderServerLower = "server"
+
+	// Request context
+	HeaderFrom           = "From"
+	HeaderHost           = "Host"
+	HeaderReferer        = "Referer"
+	HeaderReferrerPolicy = "Referrer-Policy"
+	HeaderUserAgent      = "User-Agent"
 
 	// Message body information
 	HeaderContentEncoding = "Content-Encoding"
-	HeaderAcceptEncoding  = "Accept-Encoding"
+	HeaderContentLanguage = "Content-Language"
+	HeaderContentLength   = "Content-Length"
+	HeaderContentLocation = "Content-Location"
+	HeaderContentType     = "Content-Type"
 
-	// Redirects
-	HeaderLocation = "Location"
+	// Content negotiation
+	HeaderAccept         = "Accept"
+	HeaderAcceptCharset  = "Accept-Charset"
+	HeaderAcceptEncoding = "Accept-Encoding"
+	HeaderAcceptLanguage = "Accept-Language"
+	HeaderAltSvc         = "Alt-Svc"
 
 	// Protocol
 	HTTP11 = "HTTP/1.1"
diff --git a/pkg/protocol/header.go b/pkg/protocol/header.go
index dfc5c8d..693ae47 100644
--- a/pkg/protocol/header.go
+++ b/pkg/protocol/header.go
@@ -85,8 +85,9 @@ type RequestHeader struct {
 	userAgent   []byte
 	mulHeader   [][]byte
 
-	h     []argsKV
-	bufKV argsKV
+	h       []argsKV
+	bufKV   argsKV
+	trailer Trailer
 
 	cookies []argsKV
 
@@ -124,8 +125,9 @@ type ResponseHeader struct {
 	server      []byte
 	mulHeader   [][]byte
 
-	h     []argsKV
-	bufKV argsKV
+	h       []argsKV
+	bufKV   argsKV
+	trailer Trailer
 
 	cookies []argsKV
 
@@ -209,6 +211,7 @@ func (h *ResponseHeader) GetHeaders() []argsKV {
 // Reset clears response header.
 func (h *ResponseHeader) Reset() {
 	h.disableNormalizing = false
+	h.Trailer().disableNormalizing = false
 	h.noDefaultContentType = false
 	h.noDefaultDate = false
 	h.ResetSkipNormalize()
@@ -232,6 +235,7 @@ func (h *ResponseHeader) CopyTo(dst *ResponseHeader) {
 	dst.server = append(dst.server[:0], h.server...)
 	dst.h = copyArgs(dst.h, h.h)
 	dst.cookies = copyArgs(dst.cookies, h.cookies)
+	h.Trailer().CopyTo(dst.Trailer())
 }
 
 // Multiple headers with the same key may be added with this function.
@@ -274,6 +278,9 @@ func (h *ResponseHeader) VisitAll(f func(key, value []byte)) {
 			f(bytestr.StrSetCookie, v)
 		})
 	}
+	if !h.Trailer().Empty() {
+		f(bytestr.StrTrailer, h.Trailer().GetBytes())
+	}
 	visitArgs(h.h, f)
 	if h.ConnectionClose() {
 		f(bytestr.StrConnection, bytestr.StrClose)
@@ -392,6 +399,10 @@ func (h *RequestHeader) AppendBytes(dst []byte) []byte {
 		dst = appendHeaderLine(dst, kv.key, kv.value)
 	}
 
+	if !h.Trailer().Empty() {
+		dst = appendHeaderLine(dst, bytestr.StrTrailer, h.Trailer().GetBytes())
+	}
+
 	// there is no need in h.collectCookies() here, since if cookies aren't collected yet,
 	// they all are located in h.h.
 	n := len(h.cookies)
@@ -488,6 +499,7 @@ func (h *ResponseHeader) ResetSkipNormalize() {
 
 	h.h = h.h[:0]
 	h.cookies = h.cookies[:0]
+	h.Trailer().ResetSkipNormalize()
 	h.mulHeader = h.mulHeader[:0]
 }
 
@@ -685,6 +697,8 @@ func (h *ResponseHeader) peek(key []byte) []byte {
 		return h.contentLengthBytes
 	case consts.HeaderSetCookie:
 		return appendResponseCookieBytes(nil, h.cookies)
+	case consts.HeaderTrailer:
+		return h.Trailer().GetBytes()
 	default:
 		return peekArgBytes(h.h, key)
 	}
@@ -875,6 +889,10 @@ func (h *ResponseHeader) AppendBytes(dst []byte) []byte {
 		}
 	}
 
+	if !h.Trailer().Empty() {
+		dst = appendHeaderLine(dst, bytestr.StrTrailer, h.Trailer().GetBytes())
+	}
+
 	n := len(h.cookies)
 	if n > 0 {
 		for i := 0; i < n; i++ {
@@ -961,6 +979,8 @@ func (h *ResponseHeader) del(key []byte) {
 		h.contentLengthBytes = h.contentLengthBytes[:0]
 	case consts.HeaderConnection:
 		h.connectionClose = false
+	case consts.HeaderTrailer:
+		h.Trailer().ResetSkipNormalize()
 	}
 	h.h = delAllArgsBytes(h.h, key)
 }
@@ -992,6 +1012,7 @@ func (h *RequestHeader) Len() int {
 // Reset clears request header.
 func (h *RequestHeader) Reset() {
 	h.disableNormalizing = false
+	h.Trailer().disableNormalizing = false
 	h.ResetSkipNormalize()
 }
 
@@ -1043,6 +1064,8 @@ func (h *RequestHeader) del(key []byte) {
 		h.contentLengthBytes = h.contentLengthBytes[:0]
 	case consts.HeaderConnection:
 		h.connectionClose = false
+	case consts.HeaderTrailer:
+		h.Trailer().ResetSkipNormalize()
 	}
 	h.h = delAllArgsBytes(h.h, key)
 }
@@ -1063,6 +1086,7 @@ func (h *RequestHeader) CopyTo(dst *RequestHeader) {
 	dst.host = append(dst.host[:0], h.host...)
 	dst.contentType = append(dst.contentType[:0], h.contentType...)
 	dst.userAgent = append(dst.userAgent[:0], h.userAgent...)
+	h.Trailer().CopyTo(dst.Trailer())
 	dst.h = copyArgs(dst.h, h.h)
 	dst.cookies = copyArgs(dst.cookies, h.cookies)
 	dst.cookiesCollected = h.cookiesCollected
@@ -1391,6 +1415,7 @@ func (h *RequestHeader) ResetSkipNormalize() {
 
 	h.rawHeaders = h.rawHeaders[:0]
 	h.mulHeader = h.mulHeader[:0]
+	h.Trailer().ResetSkipNormalize()
 }
 
 func peekRawHeader(buf, key []byte) []byte {
@@ -1481,6 +1506,9 @@ func (h *RequestHeader) VisitAll(f func(key, value []byte)) {
 	if len(userAgent) > 0 {
 		f(bytestr.StrUserAgent, userAgent)
 	}
+	if !h.Trailer().Empty() {
+		f(bytestr.StrTrailer, h.Trailer().GetBytes())
+	}
 
 	h.collectCookies()
 	if len(h.cookies) > 0 {
@@ -1561,6 +1589,8 @@ func (h *RequestHeader) peek(key []byte) []byte {
 			return appendRequestCookieBytes(nil, h.cookies)
 		}
 		return peekArgBytes(h.h, key)
+	case consts.HeaderTrailer:
+		return h.Trailer().GetBytes()
 	default:
 		return peekArgBytes(h.h, key)
 	}
@@ -1689,6 +1719,9 @@ func (h *ResponseHeader) setSpecialHeader(key, value []byte) bool {
 		if utils.CaseInsensitiveCompare(bytestr.StrTransferEncoding, key) {
 			// Transfer-Encoding is managed automatically.
 			return true
+		} else if utils.CaseInsensitiveCompare(bytestr.StrTrailer, key) {
+			h.Trailer().SetTrailers(value)
+			return true
 		}
 	case 'd':
 		if utils.CaseInsensitiveCompare(bytestr.StrDate, key) {
@@ -1734,6 +1767,9 @@ func (h *RequestHeader) setSpecialHeader(key, value []byte) bool {
 		if utils.CaseInsensitiveCompare(bytestr.StrTransferEncoding, key) {
 			// Transfer-Encoding is managed automatically.
 			return true
+		} else if utils.CaseInsensitiveCompare(bytestr.StrTrailer, key) {
+			h.Trailer().SetTrailers(value)
+			return true
 		}
 	case 'h':
 		if utils.CaseInsensitiveCompare(bytestr.StrHost, key) {
@@ -1749,3 +1785,13 @@ func (h *RequestHeader) setSpecialHeader(key, value []byte) bool {
 
 	return false
 }
+
+// Trailer returns the Trailer of HTTP Header.
+func (h *ResponseHeader) Trailer() *Trailer {
+	return &h.trailer
+}
+
+// Trailer returns the Trailer of HTTP Header.
+func (h *RequestHeader) Trailer() *Trailer {
+	return &h.trailer
+}
diff --git a/pkg/protocol/http1/ext/common.go b/pkg/protocol/http1/ext/common.go
index 9383174..7d8498c 100644
--- a/pkg/protocol/http1/ext/common.go
+++ b/pkg/protocol/http1/ext/common.go
@@ -43,14 +43,17 @@ package ext
 
 import (
 	"bytes"
+	"errors"
 	"fmt"
 	"io"
+	"strings"
 
 	"github.com/cloudwego/hertz/internal/bytesconv"
 	"github.com/cloudwego/hertz/internal/bytestr"
 	errs "github.com/cloudwego/hertz/pkg/common/errors"
 	"github.com/cloudwego/hertz/pkg/common/utils"
 	"github.com/cloudwego/hertz/pkg/network"
+	"github.com/cloudwego/hertz/pkg/protocol"
 	"github.com/cloudwego/hertz/pkg/protocol/consts"
 )
 
@@ -253,6 +256,10 @@ func readBodyChunked(r network.Reader, maxBodySize int, dst []byte) ([]byte, err
 		if err != nil {
 			return dst, err
 		}
+		// If it is the end of chunk, Read CRLF after reading trailer
+		if chunkSize == 0 {
+			return dst, nil
+		}
 		if maxBodySize > 0 && len(dst)+chunkSize > maxBodySize {
 			return dst, errBodyTooLarge
 		}
@@ -264,9 +271,6 @@ func readBodyChunked(r network.Reader, maxBodySize int, dst []byte) ([]byte, err
 			return dst, errBrokenChunk
 		}
 		dst = dst[:len(dst)-strCRLFLen]
-		if chunkSize == 0 {
-			return dst, nil
-		}
 	}
 }
 
@@ -293,7 +297,11 @@ func writeChunk(w network.Writer, b []byte) (err error) {
 	if _, err = w.WriteBinary(b); err != nil {
 		return err
 	}
-	w.WriteBinary(bytestr.StrCRLF) //nolint:errcheck
+
+	// If it is the end of chunk, write CRLF after writing trailer
+	if n > 0 {
+		w.WriteBinary(bytestr.StrCRLF) //nolint:errcheck
+	}
 
 	err = w.Flush()
 	return
@@ -378,3 +386,88 @@ func stripSpace(b []byte) []byte {
 	}
 	return b
 }
+
+func ReadTrailer(t *protocol.Trailer, r network.Reader) error {
+	n := 1
+	for {
+		err := tryReadTrailer(t, r, n)
+		if err == nil {
+			return nil
+		}
+		if !errors.Is(err, errs.ErrNeedMore) {
+			t.ResetSkipNormalize()
+			return err
+		}
+		// No more data available on the wire, try block peek(by netpoll)
+		if n == r.Len() {
+			n++
+
+			continue
+		}
+		n = r.Len()
+	}
+}
+
+func tryReadTrailer(t *protocol.Trailer, r network.Reader, n int) error {
+	b, err := r.Peek(n)
+	if len(b) == 0 {
+		// Return ErrTimeout on any timeout.
+		if err != nil && strings.Contains(err.Error(), "timeout") {
+			return errs.New(errs.ErrTimeout, errs.ErrorTypePublic, "read response header")
+		}
+
+		if n == 1 || err == io.EOF {
+			return io.EOF
+		}
+
+		return errs.NewPublicf("error when reading request trailer: %w", err)
+	}
+	b = MustPeekBuffered(r)
+	headersLen, errParse := parseTrailer(t, b)
+	if errParse != nil {
+		if err == io.EOF {
+			return err
+		}
+		return HeaderError("response", err, errParse, b)
+	}
+	MustDiscard(r, headersLen)
+	return nil
+}
+
+func parseTrailer(t *protocol.Trailer, buf []byte) (int, error) {
+	// Skip any 0 length chunk.
+	if buf[0] == '0' {
+		skip := len(bytestr.StrCRLF) + 1
+		if len(buf) < skip {
+			return 0, io.EOF
+		}
+		buf = buf[skip:]
+	}
+
+	var s HeaderScanner
+	s.B = buf
+	s.DisableNormalizing = t.IsDisableNormalizing()
+	var err error
+	for s.Next() {
+		if len(s.Key) > 0 {
+			if bytes.IndexByte(s.Key, ' ') != -1 || bytes.IndexByte(s.Key, '\t') != -1 {
+				err = fmt.Errorf("invalid trailer key %q", s.Key)
+				continue
+			}
+			err = t.UpdateArgBytes(s.Key, s.Value)
+		}
+	}
+	if s.Err != nil {
+		return 0, s.Err
+	}
+	if err != nil {
+		return 0, err
+	}
+	return s.HLen, nil
+}
+
+// writeTrailer writes response trailer to w
+func WriteTrailer(t *protocol.Trailer, w network.Writer) error {
+	_, err := w.WriteBinary(t.Header())
+	return err
+}
diff --git a/pkg/protocol/http1/ext/stream.go b/pkg/protocol/http1/ext/stream.go
index d72e995..2dd203d 100644
--- a/pkg/protocol/http1/ext/stream.go
+++ b/pkg/protocol/http1/ext/stream.go
@@ -50,6 +50,7 @@ import (
 	errs "github.com/cloudwego/hertz/pkg/common/errors"
 	"github.com/cloudwego/hertz/pkg/common/utils"
 	"github.com/cloudwego/hertz/pkg/network"
+	"github.com/cloudwego/hertz/pkg/protocol"
 )
 
 var (
@@ -62,25 +63,18 @@ var (
 	}
 )
 
+// Deprecated: Use github.com/cloudwego/hertz/pkg/protocol.NoBody instead.
+var NoBody = protocol.NoBody
+
 type bodyStream struct {
 	prefetchedBytes *bytes.Reader
 	reader          network.Reader
+	trailer         *protocol.Trailer
 	offset          int
 	contentLength   int
 	chunkLeft       int
 }
 
-// NoBody is an io.ReadCloser with no bytes. Read always returns EOF
-// and Close always returns nil. It can be used in an outgoing client
-// request to explicitly signal that a request has zero bytes.
-// An alternative, however, is to simply set Request.Body to nil.
-var NoBody = noBody{}
-
-type noBody struct{}
-
-func (noBody) Read([]byte) (int, error) { return 0, io.EOF }
-func (noBody) Close() error             { return nil }
-
 func ReadBodyWithStreaming(zr network.Reader, contentLength, maxBodySize int, dst []byte) (b []byte, err error) {
 	if contentLength == -1 {
 		// handled in requestStream.Read()
@@ -114,11 +108,12 @@ func ReadBodyWithStreaming(zr network.Reader, contentLength, maxBodySize int, ds
 	return b, nil
 }
 
-func AcquireBodyStream(b *bytebufferpool.ByteBuffer, r network.Reader, contentLength int) io.Reader {
+func AcquireBodyStream(b *bytebufferpool.ByteBuffer, r network.Reader, t *protocol.Trailer, contentLength int) io.Reader {
 	rs := bodyStreamPool.Get().(*bodyStream)
 	rs.prefetchedBytes = bytes.NewReader(b.B)
 	rs.reader = r
 	rs.contentLength = contentLength
+	rs.trailer = t
 
 	return rs
 }
@@ -136,7 +131,7 @@ func (rs *bodyStream) Read(p []byte) (int, error) {
 				return 0, err
 			}
 			if chunkSize == 0 {
-				err = utils.SkipCRLF(rs.reader)
+				err = ReadTrailer(rs.trailer, rs.reader)
 				if err == nil {
 					err = io.EOF
 				}
@@ -274,6 +269,7 @@ func ReleaseBodyStream(requestReader io.Reader) (err error) {
 		rs.prefetchedBytes = nil
 		rs.offset = 0
 		rs.reader = nil
+		rs.trailer = nil
 		bodyStreamPool.Put(rs)
 	}
 	return
diff --git a/pkg/protocol/http1/req/header.go b/pkg/protocol/http1/req/header.go
index b5f35d2..da7e729 100644
--- a/pkg/protocol/http1/req/header.go
+++ b/pkg/protocol/http1/req/header.go
@@ -232,6 +232,14 @@ func parseHeaders(h *protocol.RequestHeader, buf []byte) (int, error) {
 					}
 					continue
 				}
+				if utils.CaseInsensitiveCompare(s.Key, bytestr.StrTrailer) {
+					if nerr := h.Trailer().SetTrailers(s.Value); nerr != nil {
+						if err == nil {
+							err = nerr
+						}
+					}
+					continue
+				}
 			}
 		}
 		h.AddArgBytes(s.Key, s.Value, protocol.ArgsHasValue)
diff --git a/pkg/protocol/http1/req/request.go b/pkg/protocol/http1/req/request.go
index 13b45ad..f010747 100644
--- a/pkg/protocol/http1/req/request.go
+++ b/pkg/protocol/http1/req/request.go
@@ -289,19 +289,19 @@ func ContinueReadBodyStream(req *protocol.Request, zr network.Reader, maxBodySiz
 	if err != nil {
 		if errors.Is(err, errs.ErrBodyTooLarge) {
 			req.Header.SetContentLength(contentLength)
-			req.ConstructBodyStream(bodyBuf, ext.AcquireBodyStream(bodyBuf, zr, contentLength))
+			req.ConstructBodyStream(bodyBuf, ext.AcquireBodyStream(bodyBuf, zr, req.Header.Trailer(), contentLength))
 
 			return nil
 		}
 		if errors.Is(err, errs.ErrChunkedStream) {
-			req.ConstructBodyStream(bodyBuf, ext.AcquireBodyStream(bodyBuf, zr, contentLength))
+			req.ConstructBodyStream(bodyBuf, ext.AcquireBodyStream(bodyBuf, zr, req.Header.Trailer(), contentLength))
 			return nil
 		}
 		req.Reset()
 		return err
 	}
 
-	req.ConstructBodyStream(bodyBuf, ext.AcquireBodyStream(bodyBuf, zr, contentLength))
+	req.ConstructBodyStream(bodyBuf, ext.AcquireBodyStream(bodyBuf, zr, req.Header.Trailer(), contentLength))
 	return nil
 }
 
@@ -358,6 +358,14 @@ func ContinueReadBody(req *protocol.Request, r network.Reader, maxBodySize int,
 		req.Reset()
 		return err
 	}
+
+	if req.Header.ContentLength() == -1 {
+		err = ext.ReadTrailer(req.Header.Trailer(), r)
+		if err != nil && err != io.EOF {
+			return err
+		}
+	}
+
 	req.Header.SetContentLength(len(bodyBuf.B))
 	return nil
 }
@@ -416,9 +424,13 @@ func writeBodyStream(req *protocol.Request, w network.Writer) error {
 		}
 	} else {
 		req.Header.SetContentLength(-1)
-		if err = WriteHeader(&req.Header, w); err == nil {
+		err = WriteHeader(&req.Header, w)
+		if err == nil {
 			err = ext.WriteBodyChunked(w, req.BodyStream())
 		}
+		if err == nil {
+			err = ext.WriteTrailer(req.Header.Trailer(), w)
+		}
 	}
 	err1 := req.CloseBodyStream()
 	if err == nil {
diff --git a/pkg/protocol/http1/resp/header.go b/pkg/protocol/http1/resp/header.go
index 0944ef7..622c72b 100644
--- a/pkg/protocol/http1/resp/header.go
+++ b/pkg/protocol/http1/resp/header.go
@@ -183,6 +183,10 @@ func parseHeaders(h *protocol.ResponseHeader, buf []byte) (int, error) {
 					}
 					continue
 				}
+				if utils.CaseInsensitiveCompare(s.Key, bytestr.StrTrailer) {
+					err = h.Trailer().SetTrailers(s.Value)
+					continue
+				}
 			}
 			h.AddArgBytes(s.Key, s.Value, protocol.ArgsHasValue)
 		}
@@ -205,7 +209,7 @@ func parseHeaders(h *protocol.ResponseHeader, buf []byte) (int, error) {
 		h.SetConnectionClose(!ext.HasHeaderValue(v, bytestr.StrKeepAlive))
 	}
 
-	return len(buf) - len(s.B), nil
+	return len(buf) - len(s.B), err
 }
 
 func parse(h *protocol.ResponseHeader, buf []byte) (int, error) {
diff --git a/pkg/protocol/http1/resp/response.go b/pkg/protocol/http1/resp/response.go
index c23039b..1f909aa 100644
--- a/pkg/protocol/http1/resp/response.go
+++ b/pkg/protocol/http1/resp/response.go
@@ -106,6 +106,8 @@ func ReadHeaderAndLimitBody(resp *protocol.Response, r network.Reader, maxBodySi
 		}
 	}
 
+	var cLen int
+
 	if !resp.MustSkipBody() {
 		bodyBuf := resp.BodyBuffer()
 		bodyBuf.Reset()
@@ -113,8 +115,20 @@ func ReadHeaderAndLimitBody(resp *protocol.Response, r network.Reader, maxBodySi
 		if err != nil {
 			return err
 		}
-		resp.Header.SetContentLength(len(bodyBuf.B))
+		cLen = len(bodyBuf.B)
+	}
+
+	if resp.Header.ContentLength() == -1 {
+		err = ext.ReadTrailer(resp.Header.Trailer(), r)
+		if err != nil && err != io.EOF {
+			return err
+		}
 	}
+
+	if !resp.MustSkipBody() {
+		resp.Header.SetContentLength(cLen)
+	}
+
 	return nil
 }
 
@@ -179,13 +193,13 @@ func ReadBodyStream(resp *protocol.Response, r network.Reader, maxBodySize int,
 	bodyBuf.B, err = ext.ReadBodyWithStreaming(r, resp.Header.ContentLength(), maxBodySize, bodyBuf.B)
 	if err != nil {
 		if errors.Is(err, errs.ErrBodyTooLarge) {
-			bodyStream := ext.AcquireBodyStream(bodyBuf, r, resp.Header.ContentLength())
+			bodyStream := ext.AcquireBodyStream(bodyBuf, r, resp.Header.Trailer(), resp.Header.ContentLength())
 			resp.ConstructBodyStream(bodyBuf, convertClientRespStream(bodyStream, closeCallBack))
 			return nil
 		}
 
 		if errors.Is(err, errs.ErrChunkedStream) {
-			bodyStream := ext.AcquireBodyStream(bodyBuf, r, -1)
+			bodyStream := ext.AcquireBodyStream(bodyBuf, r, resp.Header.Trailer(), -1)
 			resp.ConstructBodyStream(bodyBuf, convertClientRespStream(bodyStream, closeCallBack))
 			return nil
 		}
@@ -194,7 +208,7 @@ func ReadBodyStream(resp *protocol.Response, r network.Reader, maxBodySize int,
 		return err
 	}
 
-	bodyStream := ext.AcquireBodyStream(bodyBuf, r, resp.Header.ContentLength())
+	bodyStream := ext.AcquireBodyStream(bodyBuf, r, resp.Header.Trailer(), resp.Header.ContentLength())
 	resp.ConstructBodyStream(bodyBuf, convertClientRespStream(bodyStream, closeCallBack))
 	return nil
 }
@@ -277,6 +291,9 @@ func writeBodyStream(resp *protocol.Response, w network.Writer, sendBody bool) (
 			if err == nil {
 				err = ext.WriteBodyChunked(w, resp.BodyStream())
 			}
+			if err == nil {
+				err = ext.WriteTrailer(resp.Header.Trailer(), w)
+			}
 		}
 	}
 	err1 := resp.CloseBodyStream()
diff --git a/pkg/protocol/request.go b/pkg/protocol/request.go
index 8b86067..f6c138a 100644
--- a/pkg/protocol/request.go
+++ b/pkg/protocol/request.go
@@ -62,7 +62,6 @@ import (
 	"github.com/cloudwego/hertz/pkg/common/utils"
 	"github.com/cloudwego/hertz/pkg/network"
 	"github.com/cloudwego/hertz/pkg/protocol/consts"
-	"github.com/cloudwego/hertz/pkg/protocol/http1/ext"
 )
 
 var (
@@ -75,6 +74,17 @@ var (
 	requestPool sync.Pool
 )
 
+// NoBody is an io.ReadCloser with no bytes. Read always returns EOF
+// and Close always returns nil. It can be used in an outgoing client
+// request to explicitly signal that a request has zero bytes.
+// An alternative, however, is to simply set Request.Body to nil.
+var NoBody = noBody{}
+
+type noBody struct{}
+
+func (noBody) Read([]byte) (int, error) { return 0, io.EOF }
+func (noBody) Close() error             { return nil }
+
 type Request struct {
 	noCopy nocopy.NoCopy //lint:ignore U1000 until noCopy is used
 
@@ -615,12 +625,12 @@ func (req *Request) HasMultipartForm() bool {
 
 // IsBodyStream returns true if body is set via SetBodyStream*
 func (req *Request) IsBodyStream() bool {
-	return req.bodyStream != nil && req.bodyStream != ext.NoBody
+	return req.bodyStream != nil && req.bodyStream != NoBody
 }
 
 func (req *Request) BodyStream() io.Reader {
 	if req.bodyStream == nil {
-		req.bodyStream = ext.NoBody
+		req.bodyStream = NoBody
 	}
 	return req.bodyStream
 }
diff --git a/pkg/protocol/trailer.go b/pkg/protocol/trailer.go
new file mode 100644
index 0000000..6820f87
--- /dev/null
+++ b/pkg/protocol/trailer.go
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2023 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package protocol
+
+import (
+	"bytes"
+
+	"github.com/cloudwego/hertz/internal/bytestr"
+	errs "github.com/cloudwego/hertz/pkg/common/errors"
+	"github.com/cloudwego/hertz/pkg/common/utils"
+	"github.com/cloudwego/hertz/pkg/protocol/consts"
+)
+
+type Trailer struct {
+	h                  []argsKV
+	bufKV              argsKV
+	disableNormalizing bool
+}
+
+// Get returns trailer value for the given key.
+func (t *Trailer) Get(key string) string {
+	return string(t.Peek(key))
+}
+
+// Peek returns trailer value for the given key.
+//
+// Returned value is valid until the next call to Trailer.
+// Do not store references to returned value. Make copies instead.
+func (t *Trailer) Peek(key string) []byte {
+	k := getHeaderKeyBytes(&t.bufKV, key, t.disableNormalizing)
+	return peekArgBytes(t.h, k)
+}
+
+// Del deletes trailer with the given key.
+func (t *Trailer) Del(key string) {
+	k := getHeaderKeyBytes(&t.bufKV, key, t.disableNormalizing)
+	t.h = delAllArgsBytes(t.h, k)
+}
+
+// VisitAll calls f for each header.
+func (t *Trailer) VisitAll(f func(key, value []byte)) {
+	visitArgs(t.h, f)
+}
+
+// Set sets the given 'key: value' trailer.
+//
+// If the key is forbidden by RFC 7230, section 4.1.2, Set will return error
+func (t *Trailer) Set(key, value string) error {
+	initHeaderKV(&t.bufKV, key, value, t.disableNormalizing)
+	return t.setArgBytes(t.bufKV.key, t.bufKV.value, ArgsHasValue)
+}
+
+// Add adds the given 'key: value' trailer.
+//
+// Multiple headers with the same key may be added with this function.
+// Use Set for setting a single header for the given key.
+//
+// If the key is forbidden by RFC 7230, section 4.1.2, Add will return error
+func (t *Trailer) Add(key, value string) error {
+	initHeaderKV(&t.bufKV, key, value, t.disableNormalizing)
+	return t.addArgBytes(t.bufKV.key, t.bufKV.value, ArgsHasValue)
+}
+
+func (t *Trailer) addArgBytes(key, value []byte, noValue bool) error {
+	if IsBadTrailer(key) {
+		return errs.NewPublicf("forbidden trailer key: %q", key)
+	}
+	t.h = appendArgBytes(t.h, key, value, noValue)
+	return nil
+}
+
+func (t *Trailer) setArgBytes(key, value []byte, noValue bool) error {
+	if IsBadTrailer(key) {
+		return errs.NewPublicf("forbidden trailer key: %q", key)
+	}
+	t.h = setArgBytes(t.h, key, value, noValue)
+	return nil
+}
+
+func (t *Trailer) UpdateArgBytes(key, value []byte) error {
+	if IsBadTrailer(key) {
+		return errs.NewPublicf("forbidden trailer key: %q", key)
+	}
+
+	t.h = updateArgBytes(t.h, key, value)
+	return nil
+}
+
+func (t *Trailer) GetTrailers() []argsKV {
+	return t.h
+}
+
+func (t *Trailer) Empty() bool {
+	return len(t.h) == 0
+}
+
+// GetBytes return the 'Trailer' Header which is composed by the Trailer key
+func (t *Trailer) GetBytes() []byte {
+	var dst []byte
+	for i, n := 0, len(t.h); i < n; i++ {
+		kv := &t.h[i]
+		dst = append(dst, kv.key...)
+		if i+1 < n {
+			dst = append(dst, bytestr.StrCommaSpace...)
+		}
+	}
+	return dst
+}
+
+func (t *Trailer) ResetSkipNormalize() {
+	t.h = t.h[:0]
+}
+
+func (t *Trailer) Reset() {
+	t.disableNormalizing = false
+	t.ResetSkipNormalize()
+}
+
+func (t *Trailer) DisableNormalizing() {
+	t.disableNormalizing = true
+}
+
+func (t *Trailer) IsDisableNormalizing() bool {
+	return t.disableNormalizing
+}
+
+// CopyTo copies all the trailer to dst.
+func (t *Trailer) CopyTo(dst *Trailer) {
+	dst.Reset()
+
+	dst.disableNormalizing = t.disableNormalizing
+	dst.h = copyArgs(dst.h, t.h)
+}
+
+func (t *Trailer) SetTrailers(trailers []byte) (err error) {
+	t.ResetSkipNormalize()
+	for i := -1; i+1 < len(trailers); {
+		trailers = trailers[i+1:]
+		i = bytes.IndexByte(trailers, ',')
+		if i < 0 {
+			i = len(trailers)
+		}
+		trailerKey := trailers[:i]
+		for len(trailerKey) > 0 && trailerKey[0] == ' ' {
+			trailerKey = trailerKey[1:]
+		}
+		for len(trailerKey) > 0 && trailerKey[len(trailerKey)-1] == ' ' {
+			trailerKey = trailerKey[:len(trailerKey)-1]
+		}
+
+		utils.NormalizeHeaderKey(trailerKey, t.disableNormalizing)
+		err = t.addArgBytes(trailerKey, nilByteSlice, argsNoValue)
+	}
+	return
+}
+
+func (t *Trailer) Header() []byte {
+	t.bufKV.value = t.AppendBytes(t.bufKV.value[:0])
+	return t.bufKV.value
+}
+
+func (t *Trailer) AppendBytes(dst []byte) []byte {
+	for i, n := 0, len(t.h); i < n; i++ {
+		kv := &t.h[i]
+		dst = appendHeaderLine(dst, kv.key, kv.value)
+	}
+
+	dst = append(dst, bytestr.StrCRLF...)
+	return dst
+}
+
+func IsBadTrailer(key []byte) bool {
+	switch key[0] | 0x20 {
+	case 'a':
+		return utils.CaseInsensitiveCompare(key, bytestr.StrAuthorization)
+	case 'c':
+		if len(key) >= len(consts.HeaderContentType) && utils.CaseInsensitiveCompare(key[:8], bytestr.StrContentType[:8]) {
+			// skip compare prefix 'Content-'
+			return utils.CaseInsensitiveCompare(key[8:], bytestr.StrContentEncoding[8:]) ||
+				utils.CaseInsensitiveCompare(key[8:], bytestr.StrContentLength[8:]) ||
+				utils.CaseInsensitiveCompare(key[8:], bytestr.StrContentType[8:]) ||
+				utils.CaseInsensitiveCompare(key[8:], bytestr.StrContentRange[8:])
+		}
+		return utils.CaseInsensitiveCompare(key, bytestr.StrConnection)
+	case 'e':
+		return utils.CaseInsensitiveCompare(key, bytestr.StrExpect)
+	case 'h':
+		return utils.CaseInsensitiveCompare(key, bytestr.StrHost)
+	case 'k':
+		return utils.CaseInsensitiveCompare(key, bytestr.StrKeepAlive)
+	case 'm':
+		return utils.CaseInsensitiveCompare(key, bytestr.StrMaxForwards)
+	case 'p':
+		if len(key) >= len(consts.HeaderProxyConnection) && utils.CaseInsensitiveCompare(key[:6], bytestr.StrProxyConnection[:6]) {
+			// skip compare prefix 'Proxy-'
+			return utils.CaseInsensitiveCompare(key[6:], bytestr.StrProxyConnection[6:]) ||
+				utils.CaseInsensitiveCompare(key[6:], bytestr.StrProxyAuthenticate[6:]) ||
+				utils.CaseInsensitiveCompare(key[6:], bytestr.StrProxyAuthorization[6:])
+		}
+	case 'r':
+		return utils.CaseInsensitiveCompare(key, bytestr.StrRange)
+	case 't':
+		return utils.CaseInsensitiveCompare(key, bytestr.StrTE) ||
+			utils.CaseInsensitiveCompare(key, bytestr.StrTrailer) ||
+			utils.CaseInsensitiveCompare(key, bytestr.StrTransferEncoding)
+	case 'w':
+		return utils.CaseInsensitiveCompare(key, bytestr.StrWWWAuthenticate)
+	}
+	return false
+}


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 1adafe4..311c0bd 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -16,17 +16,12 @@ jobs:
         with:
           go-version: ${{ matrix.version }}
 
-      - uses: actions/cache@v3
-        with:
-          path: ~/go/pkg/mod
-          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
-          restore-keys: |
-            ${{ runner.os }}-go-
       - name: Golangci Lint
         # https://golangci-lint.run/
         uses: golangci/golangci-lint-action@v3
         with:
           version: latest
+          skip-cache: true
       - name: Unit Test
         run: go test -race -covermode=atomic -coverprofile=coverage.txt ./...
 
@@ -46,11 +41,5 @@ jobs:
         with:
           go-version: ${{ matrix.version }}
 
-      - uses: actions/cache@v3
-        with:
-          path: $HOME/go/pkg/mod
-          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
-          restore-keys: |
-            ${{ runner.os }}-go-
       - name: Unit Test
         run: go test -race -covermode=atomic  ./...
diff --git a/internal/bytestr/bytes.go b/internal/bytestr/bytes.go
index 85e488b..a93bd2d 100644
--- a/internal/bytestr/bytes.go
+++ b/internal/bytestr/bytes.go
@@ -42,6 +42,7 @@ var (
 	StrStar             = []byte("*")
 	StrColonSlashSlash  = []byte("://")
 	StrColonSpace       = []byte(": ")
+	StrCommaSpace       = []byte(", ")
 	StrAt               = []byte("@")
 	StrSD               = []byte("sd")
 
@@ -66,18 +67,25 @@ var (
 	StrServer           = []byte(consts.HeaderServer)
 	StrTransferEncoding = []byte(consts.HeaderTransferEncoding)
 
-	StrUserAgent       = []byte(consts.HeaderUserAgent)
-	StrCookie          = []byte(consts.HeaderCookie)
-	StrLocation        = []byte(consts.HeaderLocation)
-	StrContentRange    = []byte(consts.HeaderContentRange)
-	StrContentEncoding = []byte(consts.HeaderContentEncoding)
-	StrAcceptEncoding  = []byte(consts.HeaderAcceptEncoding)
-	StrSetCookie       = []byte(consts.HeaderSetCookie)
-	StrAuthorization   = []byte(consts.HeaderAuthorization)
-	StrRange           = []byte(consts.HeaderRange)
-	StrLastModified    = []byte(consts.HeaderLastModified)
-	StrAcceptRanges    = []byte(consts.HeaderAcceptRanges)
-	StrIfModifiedSince = []byte(consts.HeaderIfModifiedSince)
+	StrUserAgent          = []byte(consts.HeaderUserAgent)
+	StrCookie             = []byte(consts.HeaderCookie)
+	StrLocation           = []byte(consts.HeaderLocation)
+	StrContentRange       = []byte(consts.HeaderContentRange)
+	StrContentEncoding    = []byte(consts.HeaderContentEncoding)
+	StrAcceptEncoding     = []byte(consts.HeaderAcceptEncoding)
+	StrSetCookie          = []byte(consts.HeaderSetCookie)
+	StrAuthorization      = []byte(consts.HeaderAuthorization)
+	StrRange              = []byte(consts.HeaderRange)
+	StrLastModified       = []byte(consts.HeaderLastModified)
+	StrAcceptRanges       = []byte(consts.HeaderAcceptRanges)
+	StrIfModifiedSince    = []byte(consts.HeaderIfModifiedSince)
+	StrTE                 = []byte(consts.HeaderTE)
+	StrTrailer            = []byte(consts.HeaderTrailer)
+	StrMaxForwards        = []byte(consts.HeaderMaxForwards)
+	StrProxyConnection    = []byte(consts.HeaderProxyConnection)
+	StrProxyAuthenticate  = []byte(consts.HeaderProxyAuthenticate)
+	StrProxyAuthorization = []byte(consts.HeaderProxyAuthorization)
+	StrWWWAuthenticate    = []byte(consts.HeaderWWWAuthenticate)
 
 	StrCookieExpires        = []byte("expires")
 	StrCookieDomain         = []byte("domain")
diff --git a/pkg/common/test/assert/assert.go b/pkg/common/test/assert/assert.go
index 44ed32e..4d52201 100644
--- a/pkg/common/test/assert/assert.go
+++ b/pkg/common/test/assert/assert.go
@@ -17,8 +17,6 @@
 package assert
 
 import (
-	"bytes"
-	"io"
 	"reflect"
 	"testing"
 )
@@ -114,13 +112,3 @@ func NotPanic(t testing.TB, fn func()) {
 	}()
 	fn()
 }
-
-func VerifyTrailer(t *testing.T, r io.Reader, expectedTrailer string) {
-	trailer, err := io.ReadAll(r)
-	if err != nil {
-		t.Fatalf("Cannot read trailer: %s", err)
-	}
-	if !bytes.Equal(trailer, []byte(expectedTrailer)) {
-		t.Fatalf("Unexpected trailer %q. Expected %q", trailer, expectedTrailer)
-	}
-}
diff --git a/pkg/protocol/header_test.go b/pkg/protocol/header_test.go
index fc58a29..a48e663 100644
--- a/pkg/protocol/header_test.go
+++ b/pkg/protocol/header_test.go
@@ -134,6 +134,7 @@ func TestSetCanonical(t *testing.T) {
 	h.SetCanonical([]byte(consts.HeaderContentLength), []byte("3"))
 	h.SetCanonical([]byte(consts.HeaderConnection), []byte("foo4"))
 	h.SetCanonical([]byte(consts.HeaderTransferEncoding), []byte("foo5"))
+	h.SetCanonical([]byte(consts.HeaderTrailer), []byte("foo7"))
 	h.SetCanonical([]byte("bar"), []byte("foo6"))
 
 	assert.DeepEqual(t, []byte("foo"), h.ContentType())
@@ -142,6 +143,7 @@ func TestSetCanonical(t *testing.T) {
 	assert.DeepEqual(t, 3, h.ContentLength())
 	assert.DeepEqual(t, false, h.ConnectionClose())
 	assert.DeepEqual(t, false, strings.Contains(string(h.ContentType()), "foo5"))
+	assert.DeepEqual(t, true, strings.Contains(string(h.Header()), "Trailer: Foo7"))
 	assert.DeepEqual(t, true, strings.Contains(string(h.Header()), "bar: foo6"))
 }
 
@@ -211,6 +213,7 @@ func TestRequestHeaderDel(t *testing.T) {
 	h.Set(consts.HeaderContentType, "aaa")
 	h.Set(consts.HeaderServer, "aaabbb")
 	h.Set(consts.HeaderContentLength, "1123")
+	h.Set(consts.HeaderTrailer, "foo, bar")
 	h.SetHost("foobar")
 	h.SetCookie("foo", "bar")
 
@@ -221,6 +224,7 @@ func TestRequestHeaderDel(t *testing.T) {
 	h.del([]byte("Content-Length"))
 	h.del([]byte("Set-Cookie"))
 	h.del([]byte("Host"))
+	h.del([]byte(consts.HeaderTrailer))
 	h.DelCookie("foo")
 
 	hv := h.Peek("aaa")
@@ -255,6 +259,10 @@ func TestRequestHeaderDel(t *testing.T) {
 	if len(hv) > 0 {
 		t.Fatalf("non-zero value: %q", hv)
 	}
+	hv = h.Peek(consts.HeaderTrailer)
+	if len(hv) > 0 {
+		t.Fatalf("non-zero value: %q", hv)
+	}
 	if h.ContentLength() != 0 {
 		t.Fatalf("unexpected content-length: %d. Expecting 0", h.ContentLength())
 	}
@@ -271,6 +279,7 @@ func TestResponseHeaderDel(t *testing.T) {
 	h.Set(consts.HeaderContentEncoding, "gzip")
 	h.Set(consts.HeaderServer, "aaabbb")
 	h.Set(consts.HeaderContentLength, "1123")
+	h.Set(consts.HeaderTrailer, "foo, bar")
 
 	var c Cookie
 	c.SetKey("foo")
@@ -284,6 +293,7 @@ func TestResponseHeaderDel(t *testing.T) {
 	h.Del("content-length")
 	h.Del("set-cookie")
 	h.Del("content-encoding")
+	h.Del(consts.HeaderTrailer)
 
 	hv := h.Peek("aaa")
 	if string(hv) != "bbb" {
@@ -314,6 +324,11 @@ func TestResponseHeaderDel(t *testing.T) {
 		t.Fatalf("non-zero value: %q", hv)
 	}
 
+	hv = h.Peek(consts.HeaderTrailer)
+	if len(hv) > 0 {
+		t.Fatalf("non-zero value: %q", hv)
+	}
+
 	if h.Cookie(&c) {
 		t.Fatalf("unexpected cookie obtained: %v", &c)
 	}
diff --git a/pkg/protocol/http1/ext/common_test.go b/pkg/protocol/http1/ext/common_test.go
index 0f3fda6..5ad9c00 100644
--- a/pkg/protocol/http1/ext/common_test.go
+++ b/pkg/protocol/http1/ext/common_test.go
@@ -17,10 +17,13 @@
 package ext
 
 import (
+	"bytes"
 	"strings"
 	"testing"
 
 	"github.com/cloudwego/hertz/pkg/common/test/assert"
+	"github.com/cloudwego/hertz/pkg/common/test/mock"
+	"github.com/cloudwego/hertz/pkg/protocol"
 )
 
 func Test_stripSpace(t *testing.T) {
@@ -43,3 +46,52 @@ func Test_isOnlyCRLF(t *testing.T) {
 	assert.True(t, isOnlyCRLF([]byte("\r\n")))
 	assert.True(t, isOnlyCRLF([]byte("\n")))
 }
+
+func TestReadTrailer(t *testing.T) {
+	exceptedTrailers := map[string]string{"Hertz": "test"}
+	zr := mock.NewZeroCopyReader("0\r\nHertz: test\r\n\r\n")
+	trailer := protocol.Trailer{}
+	keys := make([]string, 0, len(exceptedTrailers))
+	for k := range exceptedTrailers {
+		keys = append(keys, k)
+	}
+	trailer.SetTrailers([]byte(strings.Join(keys, ", ")))
+	err := ReadTrailer(&trailer, zr)
+	if err != nil {
+		t.Fatalf("Cannot read trailer: %v", err)
+	}
+
+	for k, v := range exceptedTrailers {
+		got := trailer.Peek(k)
+		if !bytes.Equal(got, []byte(v)) {
+			t.Fatalf("Unexpected trailer %q. Expected %q. Got %q", k, v, got)
+		}
+	}
+}
+
+func TestReadTrailerError(t *testing.T) {
+	// with bad trailer
+	zr := mock.NewZeroCopyReader("0\r\nHertz: test\r\nContent-Type: aaa\r\n\r\n")
+	trailer := protocol.Trailer{}
+	err := ReadTrailer(&trailer, zr)
+	if err == nil {
+		t.Fatalf("expecting error.")
+	}
+}
+
+func TestReadTrailer1(t *testing.T) {
+	exceptedTrailers := map[string]string{}
+	zr := mock.NewZeroCopyReader("0\r\n\r\n")
+	trailer := protocol.Trailer{}
+	err := ReadTrailer(&trailer, zr)
+	if err != nil {
+		t.Fatalf("Cannot read trailer: %v", err)
+	}
+
+	for k, v := range exceptedTrailers {
+		got := trailer.Peek(k)
+		if !bytes.Equal(got, []byte(v)) {
+			t.Fatalf("Unexpected trailer %q. Expected %q. Got %q", k, v, got)
+		}
+	}
+}
diff --git a/pkg/protocol/http1/req/request_test.go b/pkg/protocol/http1/req/request_test.go
index 01e3f17..9f6a537 100644
--- a/pkg/protocol/http1/req/request_test.go
+++ b/pkg/protocol/http1/req/request_test.go
@@ -58,6 +58,8 @@ import (
 	errs "github.com/cloudwego/hertz/pkg/common/errors"
 	"github.com/cloudwego/hertz/pkg/common/test/assert"
 	"github.com/cloudwego/hertz/pkg/common/test/mock"
+	"github.com/cloudwego/hertz/pkg/common/utils"
+	"github.com/cloudwego/hertz/pkg/network"
 	"github.com/cloudwego/hertz/pkg/protocol"
 	"github.com/cloudwego/hertz/pkg/protocol/consts"
 	"github.com/cloudwego/hertz/pkg/protocol/http1/ext"
@@ -397,7 +399,8 @@ func TestChunkedUnexpectedEOF(t *testing.T) {
 	}
 
 	var pool bytebufferpool.Pool
-	bs := ext.AcquireBodyStream(pool.Get(), reader, -1)
+	var req1 protocol.Request
+	bs := ext.AcquireBodyStream(pool.Get(), reader, req1.Header.Trailer(), -1)
 	byteSlice := make([]byte, 4096)
 	_, err = bs.Read(byteSlice)
 	if err != io.ErrUnexpectedEOF {
@@ -483,21 +486,21 @@ func TestRequestWriteRequestURINoHost(t *testing.T) {
 func TestSetRequestBodyStreamChunked(t *testing.T) {
 	t.Parallel()
 
-	testSetRequestBodyStream(t, "", true)
+	testSetRequestBodyStreamChunked(t, "", map[string]string{"Foo": "bar"})
 
 	body := "foobar baz aaa bbb ccc"
-	testSetRequestBodyStream(t, body, true)
+	testSetRequestBodyStreamChunked(t, body, nil)
 
 	body = string(mock.CreateFixedBody(10001))
-	testSetRequestBodyStream(t, body, true)
+	testSetRequestBodyStreamChunked(t, body, map[string]string{"Foo": "test", "Bar": "test"})
 }
 
 func TestSetRequestBodyStreamFixedSize(t *testing.T) {
 	t.Parallel()
 
-	testSetRequestBodyStream(t, "a", false)
-	testSetRequestBodyStream(t, string(mock.CreateFixedBody(4097)), false)
-	testSetRequestBodyStream(t, string(mock.CreateFixedBody(100500)), false)
+	testSetRequestBodyStream(t, "a")
+	testSetRequestBodyStream(t, string(mock.CreateFixedBody(4097)))
+	testSetRequestBodyStream(t, string(mock.CreateFixedBody(100500)))
 }
 
 func TestRequestHostFromRequestURI(t *testing.T) {
@@ -748,8 +751,8 @@ func TestRequestReadChunked(t *testing.T) {
 
 	var req protocol.Request
 
-	s := "POST /foo HTTP/1.1\r\nHost: google.com\r\nTransfer-Encoding: chunked\r\nContent-Type: aa/bb\r\n\r\n3\r\nabc\r\n5\r\n12345\r\n0\r\n\r\ntrail"
-	zr := mock.NewZeroCopyReader(s)
+	s := "POST /foo HTTP/1.1\r\nHost: google.com\r\nTransfer-Encoding: chunked\r\nContent-Type: aa/bb\r\n\r\n3\r\nabc\r\n5\r\n12345\r\n0\r\n\r\nTrail: test\r\n\r\n"
+	zr := netpoll.NewReader(bytes.NewBufferString(s))
 	err := Read(&req, zr)
 	if err != nil {
 		t.Fatalf("Unexpected error when reading chunked request: %s", err)
@@ -759,7 +762,30 @@ func TestRequestReadChunked(t *testing.T) {
 		t.Fatalf("Unexpected body %q. Expected %q", req.Body(), expectedBody)
 	}
 	verifyRequestHeader(t, &req.Header, 8, "/foo", "google.com", "", "aa/bb")
-	assert.VerifyTrailer(t, zr, "trail")
+	verifyTrailer(t, zr, map[string]string{"Trail": "test"})
+}
+
+func verifyTrailer(t *testing.T, r network.Reader, exceptedTrailers map[string]string) {
+	trailer := protocol.Trailer{}
+	keys := make([]string, 0, len(exceptedTrailers))
+	for k := range exceptedTrailers {
+		keys = append(keys, k)
+	}
+	trailer.SetTrailers([]byte(strings.Join(keys, ", ")))
+	err := ext.ReadTrailer(&trailer, r)
+	if err == io.EOF && exceptedTrailers == nil {
+		return
+	}
+	if err != nil {
+		t.Fatalf("Cannot read trailer: %v", err)
+	}
+
+	for k, v := range exceptedTrailers {
+		got := trailer.Peek(k)
+		if !bytes.Equal(got, []byte(v)) {
+			t.Fatalf("Unexpected trailer %q. Expected %q. Got %q", k, v, got)
+		}
+	}
 }
 
 func TestRequestChunkedWhitespace(t *testing.T) {
@@ -801,15 +827,12 @@ func testRequestReadLimitBodySuccess(t *testing.T, s string, maxBodySize int) {
 	}
 }
 
-func testSetRequestBodyStream(t *testing.T, body string, chunked bool) {
+func testSetRequestBodyStream(t *testing.T, body string) {
 	var req protocol.Request
 	req.Header.SetHost("foobar.com")
 	req.Header.SetMethod(consts.MethodPost)
 
 	bodySize := len(body)
-	if chunked {
-		bodySize = -1
-	}
 	if req.IsBodyStream() {
 		t.Fatalf("IsBodyStream must return false")
 	}
@@ -841,6 +864,51 @@ func testSetRequestBodyStream(t *testing.T, body string, chunked bool) {
 	}
 }
 
+func testSetRequestBodyStreamChunked(t *testing.T, body string, trailer map[string]string) {
+	var req protocol.Request
+	req.Header.SetHost("foobar.com")
+	req.Header.SetMethod(consts.MethodPost)
+
+	if req.IsBodyStream() {
+		t.Fatalf("IsBodyStream must return false")
+	}
+	req.SetBodyStream(bytes.NewBufferString(body), -1)
+	if !req.IsBodyStream() {
+		t.Fatalf("IsBodyStream must return true")
+	}
+
+	var w bytes.Buffer
+	zw := netpoll.NewWriter(&w)
+	for k, v := range trailer {
+		err := req.Header.Trailer().Add(k, v)
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+	}
+	if err := Write(&req, zw); err != nil {
+		t.Fatalf("unexpected error when writing request: %v, body=%q", err, body)
+	}
+	if err := zw.Flush(); err != nil {
+		t.Fatalf("unexpected error when flushing request: %v, body=%q", err, body)
+	}
+
+	var req1 protocol.Request
+	br := bufio.NewReader(&w)
+	zr := netpoll.NewReader(br)
+	if err := Read(&req1, zr); err != nil {
+		t.Fatalf("unexpected error when reading request: %v. body=%q", err, body)
+	}
+	if string(req1.Body()) != body {
+		t.Fatalf("unexpected body %q. Expecting %q", req1.Body(), body)
+	}
+	for k, v := range trailer {
+		r := req.Header.Trailer().Peek(k)
+		if string(r) != v {
+			t.Fatalf("unexpected trailer %q. Expecting %q. Got %q", k, v, r)
+		}
+	}
+}
+
 func TestRequestMultipartForm(t *testing.T) {
 	t.Parallel()
 
@@ -967,9 +1035,8 @@ func testRequestMultipartFormNotPreParse(t *testing.T, boundary string, formData
 
 func testReadBodyChunked(t *testing.T, bodySize int) {
 	body := mock.CreateFixedBody(bodySize)
-	chunkedBody := createChunkedBody(body)
-	expectedTrailer := []byte("chunked shit")
-	chunkedBody = append(chunkedBody, expectedTrailer...)
+	expectedTrailer := map[string]string{"Foo": "chunked shit"}
+	chunkedBody := createChunkedBody(body, expectedTrailer, true)
 
 	zr := mock.NewZeroCopyReader(string(chunkedBody))
 
@@ -981,10 +1048,10 @@ func testReadBodyChunked(t *testing.T, bodySize int) {
 	if !bytes.Equal(b, body) {
 		t.Fatalf("Unexpected response read for bodySize=%d: %q. Expected %q. chunkedBody=%q", bodySize, b, body, chunkedBody)
 	}
-	assert.VerifyTrailer(t, zr, string(expectedTrailer))
+	verifyTrailer(t, zr, expectedTrailer)
 }
 
-func createChunkedBody(body []byte) []byte {
+func createChunkedBody(body []byte, trailer map[string]string, hasTrailer bool) []byte {
 	var b []byte
 	chunkSize := 1
 	for len(body) > 0 {
@@ -997,15 +1064,23 @@ func createChunkedBody(body []byte) []byte {
 		body = body[chunkSize:]
 		chunkSize++
 	}
-	return append(b, []byte("0\r\n\r\n")...)
+	if hasTrailer {
+		b = append(b, "0\r\n"...)
+		for k, v := range trailer {
+			b = append(b, k...)
+			b = append(b, ": "...)
+			b = append(b, v...)
+			b = append(b, "\r\n"...)
+		}
+		b = append(b, "\r\n"...)
+	}
+	return b
 }
 
 func testReadBodyFixedSize(t *testing.T, bodySize int) {
 	body := mock.CreateFixedBody(bodySize)
-	expectedTrailer := []byte("traler aaaa")
-	bodyWithTrailer := append(body, expectedTrailer...)
 
-	zr := mock.NewZeroCopyReader(string(bodyWithTrailer))
+	zr := mock.NewZeroCopyReader(string(body))
 	b, err := ext.ReadBody(zr, bodySize, 0, nil)
 	if err != nil {
 		t.Fatalf("Unexpected error in ReadResponseBody(%d): %s", bodySize, err)
@@ -1013,7 +1088,7 @@ func testReadBodyFixedSize(t *testing.T, bodySize int) {
 	if !bytes.Equal(b, body) {
 		t.Fatalf("Unexpected response read for bodySize=%d: %q. Expected %q", bodySize, b, body)
 	}
-	assert.VerifyTrailer(t, zr, string(expectedTrailer))
+	verifyTrailer(t, zr, nil)
 }
 
 func TestRequestFormFile(t *testing.T) {
@@ -1332,3 +1407,64 @@ func TestStreamNotEnoughData(t *testing.T) {
 	assert.Nil(t, err)
 	assert.DeepEqual(t, 0, len(conn.Data))
 }
+
+func TestRequestBodyStreamWithTrailer(t *testing.T) {
+	t.Parallel()
+
+	testRequestBodyStreamWithTrailer(t, []byte("test"), false)
+	testRequestBodyStreamWithTrailer(t, mock.CreateFixedBody(4097), false)
+	testRequestBodyStreamWithTrailer(t, mock.CreateFixedBody(105000), false)
+}
+
+func testRequestBodyStreamWithTrailer(t *testing.T, body []byte, disableNormalizing bool) {
+	expectedTrailer := map[string]string{
+		"foo": "testfoo",
+		"bar": "testbar",
+	}
+
+	var req1 protocol.Request
+	if disableNormalizing {
+		req1.Header.DisableNormalizing()
+	}
+	req1.SetHost("google.com")
+	req1.SetBodyStream(bytes.NewBuffer(body), -1)
+	for k, v := range expectedTrailer {
+		err := req1.Header.Trailer().Set(k, v)
+		if err != nil {
+			t.Fatalf("unexpected error: %s", err)
+		}
+	}
+
+	w := &bytes.Buffer{}
+	zw := netpoll.NewWriter(w)
+	if err := Write(&req1, zw); err != nil {
+		t.Fatalf("unexpected error: %s", err)
+	}
+	if err := zw.Flush(); err != nil {
+		t.Fatalf("unexpected error: %s", err)
+	}
+
+	var req2 protocol.Request
+	if disableNormalizing {
+		req2.Header.DisableNormalizing()
+	}
+
+	br := netpoll.NewReader(w)
+	if err := Read(&req2, br); err != nil {
+		t.Fatalf("unexpected error: %s", err)
+	}
+
+	reqBody := req2.Body()
+	if !bytes.Equal(reqBody, body) {
+		t.Fatalf("unexpected body: %q. Excepting %q", reqBody, body)
+	}
+
+	for k, v := range expectedTrailer {
+		kBytes := []byte(k)
+		utils.NormalizeHeaderKey(kBytes, disableNormalizing)
+		r := req2.Header.Trailer().Peek(k)
+		if string(r) != v {
+			t.Fatalf("unexpected trailer header %q: %q. Expecting %s", kBytes, r, v)
+		}
+	}
+}
diff --git a/pkg/protocol/http1/resp/response_test.go b/pkg/protocol/http1/resp/response_test.go
index cf45f65..d1abfe3 100644
--- a/pkg/protocol/http1/resp/response_test.go
+++ b/pkg/protocol/http1/resp/response_test.go
@@ -53,6 +53,7 @@ import (
 	errs "github.com/cloudwego/hertz/pkg/common/errors"
 	"github.com/cloudwego/hertz/pkg/common/test/assert"
 	"github.com/cloudwego/hertz/pkg/common/test/mock"
+	"github.com/cloudwego/hertz/pkg/common/utils"
 	"github.com/cloudwego/hertz/pkg/protocol"
 	"github.com/cloudwego/hertz/pkg/protocol/consts"
 	"github.com/cloudwego/netpoll"
@@ -155,11 +156,11 @@ func testResponseReadError(t *testing.T, resp *protocol.Response, response strin
 	}
 
 	testResponseReadSuccess(t, resp, "HTTP/1.1 303 Redisred sedfs sdf\r\nContent-Type: aaa\r\nContent-Length: 5\r\n\r\nHELLOaaa",
-		consts.StatusSeeOther, 5, "aaa", "HELLO", "aaa")
+		consts.StatusSeeOther, 5, "aaa", "HELLO", nil)
 }
 
 func testResponseReadSuccess(t *testing.T, resp *protocol.Response, response string, expectedStatusCode, expectedContentLength int,
-	expectedContentType, expectedBody, expectedTrailer string,
+	expectedContentType, expectedBody string, expectedTrailer map[string]string,
 ) {
 	zr := mock.NewZeroCopyReader(response)
 	err := Read(resp, zr)
@@ -171,7 +172,7 @@ func testResponseReadSuccess(t *testing.T, resp *protocol.Response, response str
 	if !bytes.Equal(resp.Body(), []byte(expectedBody)) {
 		t.Fatalf("Unexpected body %q. Expected %q", resp.Body(), []byte(expectedBody))
 	}
-	assert.VerifyTrailer(t, zr, expectedTrailer)
+	verifyResponseTrailer(t, &resp.Header, expectedTrailer)
 }
 
 func TestResponseReadSuccess(t *testing.T) {
@@ -181,40 +182,46 @@ func TestResponseReadSuccess(t *testing.T) {
 
 	// usual response
 	testResponseReadSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Length: 10\r\nContent-Type: foo/bar\r\n\r\n0123456789",
-		consts.StatusOK, 10, "foo/bar", "0123456789", "")
+		consts.StatusOK, 10, "foo/bar", "0123456789", nil)
 
 	// zero response
 	testResponseReadSuccess(t, resp, "HTTP/1.1 500 OK\r\nContent-Length: 0\r\nContent-Type: foo/bar\r\n\r\n",
-		consts.StatusInternalServerError, 0, "foo/bar", "", "")
+		consts.StatusInternalServerError, 0, "foo/bar", "", nil)
 
 	// response with trailer
-	testResponseReadSuccess(t, resp, "HTTP/1.1 300 OK\r\nContent-Length: 5\r\nContent-Type: bar\r\n\r\n56789aaa",
-		consts.StatusMultipleChoices, 5, "bar", "56789", "aaa")
+	testResponseReadSuccess(t, resp, "HTTP/1.1 300 OK\r\nTransfer-Encoding: chunked\r\nTrailer: foo\r\nContent-Type: bar\r\n\r\n5\r\n56789\r\n0\r\nfoo: bar\r\n\r\n",
+		consts.StatusMultipleChoices, 5, "bar", "56789", map[string]string{"Foo": "bar"})
+
+	// response with trailer disableNormalizing
+	resp.Header.DisableNormalizing()
+	resp.Header.Trailer().DisableNormalizing()
+	testResponseReadSuccess(t, resp, "HTTP/1.1 300 OK\r\nTransfer-Encoding: chunked\r\nTrailer: foo\r\nContent-Type: bar\r\n\r\n5\r\n56789\r\n0\r\nfoo: bar\r\n\r\n",
+		consts.StatusMultipleChoices, 5, "bar", "56789", map[string]string{"foo": "bar"})
 
 	// no content-length ('identity' transfer-encoding)
-	testResponseReadSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: foobar\r\n\r\nzxxc",
-		consts.StatusOK, 4, "foobar", "zxxc", "")
+	testResponseReadSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: foobar\r\n\r\nzxxxx",
+		consts.StatusOK, 5, "foobar", "zxxxx", nil)
 
 	// explicitly stated 'Transfer-Encoding: identity'
 	testResponseReadSuccess(t, resp, "HTTP/1.1 234 ss\r\nContent-Type: xxx\r\n\r\nxag",
-		234, 3, "xxx", "xag", "")
+		234, 3, "xxx", "xag", nil)
 
 	// big 'identity' response
 	body := string(mock.CreateFixedBody(100500))
 	testResponseReadSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: aa\r\n\r\n"+body,
-		consts.StatusOK, 100500, "aa", body, "")
+		consts.StatusOK, 100500, "aa", body, nil)
 
 	// chunked response
-	testResponseReadSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nqwer\r\n2\r\nty\r\n0\r\n\r\nzzzzz",
-		consts.StatusOK, 6, "text/html", "qwerty", "zzzzz")
+	testResponseReadSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nTrailer: Foo2\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nqwer\r\n2\r\nty\r\n0\r\nFoo2: bar2\r\n\r\n",
+		200, 6, "text/html", "qwerty", map[string]string{"Foo2": "bar2"})
 
 	// chunked response with non-chunked Transfer-Encoding.
-	testResponseReadSuccess(t, resp, "HTTP/1.1 230 OK\r\nContent-Type: text\r\nTransfer-Encoding: aaabbb\r\n\r\n2\r\ner\r\n2\r\nty\r\n0\r\n\r\nwe",
-		230, 4, "text", "erty", "we")
+	testResponseReadSuccess(t, resp, "HTTP/1.1 230 OK\r\nContent-Type: text\r\nTrailer: Foo3\r\nTransfer-Encoding: aaabbb\r\n\r\n2\r\ner\r\n2\r\nty\r\n0\r\nFoo3: bar3\r\n\r\n",
+		230, 4, "text", "erty", map[string]string{"Foo3": "bar3"})
 
-	// zero chunked response
-	testResponseReadSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nzzz",
-		consts.StatusOK, 0, "text/html", "", "zzz")
+	// chunked response with empty body
+	testResponseReadSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nTrailer: Foo5\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nFoo5: bar5\r\n\r\n",
+		consts.StatusOK, 0, "text/html", "", map[string]string{"Foo5": "bar5"})
 }
 
 func TestResponseReadError(t *testing.T) {
@@ -391,10 +398,12 @@ func TestResponseReadLimitBody(t *testing.T) {
 	testResponseReadLimitBodySuccess(t, "HTTP/1.1 200 OK\r\nContent-Type: aa\r\nContent-Encoding: gzip\r\n\r\n9876543210", 10)
 	// chunked response
 	testResponseReadLimitBodySuccess(t, "HTTP/1.1 200 OK\r\nContent-Type: aa\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nfoobar\r\n3\r\nbaz\r\n0\r\n\r\n", 9)
+	testResponseReadLimitBodySuccess(t, "HTTP/1.1 200 OK\r\nContent-Type: aa\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nfoobar\r\n3\r\nbaz\r\n0\r\nFoo: bar\r\n\r\n", 9)
 	testResponseReadLimitBodySuccess(t, "HTTP/1.1 200 OK\r\nContent-Type: aa\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nfoobar\r\n3\r\nbaz\r\n0\r\n\r\n", 100)
+	testResponseReadLimitBodySuccess(t, "HTTP/1.1 200 OK\r\nContent-Type: aa\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nfoobar\r\n3\r\nbaz\r\n0\r\nfoobar\r\n\r\n", 100)
 	testResponseReadLimitBodyError(t, "HTTP/1.1 200 OK\r\nContent-Type: aa\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nfoobar\r\n3\r\nbaz\r\n0\r\n\r\n", 2)
-	//
-	//// identity response
+
+	// identity response
 	testResponseReadLimitBodySuccess(t, "HTTP/1.1 400 OK\r\nContent-Type: aa\r\n\r\n123456", 6)
 	testResponseReadLimitBodySuccess(t, "HTTP/1.1 400 OK\r\nContent-Type: aa\r\n\r\n123456", 106)
 	testResponseReadLimitBodyError(t, "HTTP/1.1 400 OK\r\nContent-Type: aa\r\n\r\n123456", 5)
@@ -405,21 +414,21 @@ func TestResponseReadWithoutBody(t *testing.T) {
 
 	var resp protocol.Response
 
-	testResponseReadWithoutBody(t, &resp, "HTTP/1.1 304 Not Modified\r\nContent-Type: aa\r\nContent-Encoding: gzip\r\nContent-Length: 1235\r\n\r\nfoobar", false,
-		consts.StatusNotModified, 1235, "aa", "foobar", "gzip")
+	testResponseReadWithoutBody(t, &resp, "HTTP/1.1 304 Not Modified\r\nContent-Type: aa\r\nContent-Encoding: gzip\r\nContent-Length: 1235\r\n\r\n", false,
+		consts.StatusNotModified, 1235, "aa", nil, "gzip")
 
-	testResponseReadWithoutBody(t, &resp, "HTTP/1.1 204 Foo Bar\r\nContent-Type: aab\r\nContent-Encoding: deflate\r\nTransfer-Encoding: chunked\r\n\r\n123\r\nss", false,
-		consts.StatusNoContent, -1, "aab", "123\r\nss", "deflate")
+	testResponseReadWithoutBody(t, &resp, "HTTP/1.1 204 Foo Bar\r\nContent-Type: aab\r\nTrailer: Foo\r\nContent-Encoding: deflate\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nFoo: bar\r\n\r\n", false,
+		consts.StatusNoContent, -1, "aab", map[string]string{"Foo": "bar"}, "deflate")
 
-	testResponseReadWithoutBody(t, &resp, "HTTP/1.1 123 AAA\r\nContent-Type: xxx\r\nContent-Encoding: gzip\r\nContent-Length: 3434\r\n\r\naaaa", false,
-		123, 3434, "xxx", "aaaa", "gzip")
+	testResponseReadWithoutBody(t, &resp, "HTTP/1.1 123 AAA\r\nContent-Type: xxx\r\nContent-Encoding: gzip\r\nContent-Length: 3434\r\n\r\n", false,
+		123, 3434, "xxx", nil, "gzip")
 
-	testResponseReadWithoutBody(t, &resp, "HTTP 200 OK\r\nContent-Type: text/xml\r\nContent-Encoding: deflate\r\nContent-Length: 123\r\n\r\nxxxx", true,
-		consts.StatusOK, 123, "text/xml", "xxxx", "deflate")
+	testResponseReadWithoutBody(t, &resp, "HTTP 200 OK\r\nContent-Type: text/xml\r\nContent-Encoding: deflate\r\nContent-Length: 123\r\n\r\nfoobar\r\n", true,
+		consts.StatusOK, 123, "text/xml", nil, "deflate")
 
 	// '100 Continue' must be skipped.
-	testResponseReadWithoutBody(t, &resp, "HTTP/1.1 100 Continue\r\nFoo-bar: baz\r\n\r\nHTTP/1.1 329 aaa\r\nContent-Type: qwe\r\nContent-Encoding: gzip\r\nContent-Length: 894\r\n\r\nfoobar", true,
-		329, 894, "qwe", "foobar", "gzip")
+	testResponseReadWithoutBody(t, &resp, "HTTP/1.1 100 Continue\r\nFoo-bar: baz\r\n\r\nHTTP/1.1 329 aaa\r\nContent-Type: qwe\r\nContent-Encoding: gzip\r\nContent-Length: 894\r\n\r\n", true,
+		329, 894, "qwe", nil, "gzip")
 }
 
 func verifyResponseHeader(t *testing.T, h *protocol.ResponseHeader, expectedStatusCode, expectedContentLength int, expectedContentType, expectedContentEncoding string) {
@@ -482,7 +491,7 @@ func testResponseSuccess(t *testing.T, statusCode int, contentType, serverName,
 }
 
 func testResponseReadWithoutBody(t *testing.T, resp *protocol.Response, s string, skipBody bool,
-	expectedStatusCode, expectedContentLength int, expectedContentType, expectedTrailer, expectedContentEncoding string,
+	expectedStatusCode, expectedContentLength int, expectedContentType string, expectedTrailer map[string]string, expectedContentEncoding string,
 ) {
 	zr := mock.NewZeroCopyReader(s)
 	resp.SkipBody = skipBody
@@ -493,13 +502,23 @@ func testResponseReadWithoutBody(t *testing.T, resp *protocol.Response, s string
 	if len(resp.Body()) != 0 {
 		t.Fatalf("Unexpected response body %q. Expected %q. response=%q", resp.Body(), "", s)
 	}
+
 	verifyResponseHeader(t, &resp.Header, expectedStatusCode, expectedContentLength, expectedContentType, expectedContentEncoding)
-	assert.VerifyTrailer(t, zr, expectedTrailer)
+	verifyResponseTrailer(t, &resp.Header, expectedTrailer)
 
 	// verify that ordinal response is read after null-body response
 	resp.SkipBody = false
-	testResponseReadSuccess(t, resp, "HTTP/1.1 300 OK\r\nContent-Length: 5\r\nContent-Type: bar\r\n\r\n56789aaa",
-		consts.StatusMultipleChoices, 5, "bar", "56789", "aaa")
+	testResponseReadSuccess(t, resp, "HTTP/1.1 300 OK\r\nContent-Length: 5\r\nContent-Type: bar\r\n\r\n56789",
+		consts.StatusMultipleChoices, 5, "bar", "56789", nil)
+}
+
+func verifyResponseTrailer(t *testing.T, h *protocol.ResponseHeader, expectedTrailers map[string]string) {
+	for k, v := range expectedTrailers {
+		got := h.Trailer().Peek(k)
+		if !bytes.Equal(got, []byte(v)) {
+			t.Fatalf("Unexpected trailer %q. Expected %q. Got %q", k, v, got)
+		}
+	}
 }
 
 func testResponseReadLimitBodyError(t *testing.T, s string, maxBodySize int) {
@@ -522,8 +541,161 @@ func testResponseReadLimitBodySuccess(t *testing.T, s string, maxBodySize int) {
 	}
 }
 
+func TestResponseBodyStreamWithTrailer(t *testing.T) {
+	t.Parallel()
+
+	testResponseBodyStreamWithTrailer(t, nil, false)
+
+	body := mock.CreateFixedBody(1e5)
+	testResponseBodyStreamWithTrailer(t, body, false)
+	testResponseBodyStreamWithTrailer(t, body, true)
+}
+
+func testResponseBodyStreamWithTrailer(t *testing.T, body []byte, disableNormalizing bool) {
+	expectedTrailer := map[string]string{
+		"foo": "testfoo",
+		"bar": "testbar",
+	}
+	var resp1 protocol.Response
+	if disableNormalizing {
+		resp1.Header.DisableNormalizing()
+	}
+	resp1.SetBodyStream(bytes.NewReader(body), -1)
+	for k, v := range expectedTrailer {
+		err := resp1.Header.Trailer().Add(k, v)
+		if err != nil {
+			t.Fatalf("unexpected error: %s", err)
+		}
+	}
+
+	var w bytes.Buffer
+	zw := netpoll.NewWriter(&w)
+	if err := Write(&resp1, zw); err != nil {
+		t.Fatalf("unexpected error: %s", err)
+	}
+	if err := zw.Flush(); err != nil {
+		t.Fatalf("unexpected error: %s", err)
+	}
+
+	var resp2 protocol.Response
+	if disableNormalizing {
+		resp2.Header.DisableNormalizing()
+	}
+	br := bufio.NewReader(&w)
+	zr := netpoll.NewReader(br)
+	if err := Read(&resp2, zr); err != nil {
+		t.Fatalf("unexpected error: %s", err)
+	}
+
+	respBody := resp2.Body()
+	if !bytes.Equal(respBody, body) {
+		t.Fatalf("unexpected body: %q. Expecting %q", respBody, body)
+	}
+
+	for k, v := range expectedTrailer {
+		kBytes := []byte(k)
+		utils.NormalizeHeaderKey(kBytes, disableNormalizing)
+		r := resp2.Header.Trailer().Peek(k)
+		if string(r) != v {
+			t.Fatalf("unexpected trailer header %q: %q. Expecting %s", kBytes, r, v)
+		}
+	}
+}
+
+func TestSetResponseBodyStreamFixedSize(t *testing.T) {
+	t.Parallel()
+
+	testSetResponseBodyStream(t, "a")
+	testSetResponseBodyStream(t, string(mock.CreateFixedBody(4097)))
+	testSetResponseBodyStream(t, string(mock.CreateFixedBody(100500)))
+}
+
+func TestSetResponseBodyStreamChunked(t *testing.T) {
+	t.Parallel()
+
+	testSetResponseBodyStreamChunked(t, "", map[string]string{"Foo": "bar"})
+
+	body := "foobar baz aaa bbb ccc"
+	testSetResponseBodyStreamChunked(t, body, nil)
+
+	body = string(mock.CreateFixedBody(10001))
+	testSetResponseBodyStreamChunked(t, body, map[string]string{"Foo": "test", "Bar": "test"})
+}
+
+func testSetResponseBodyStream(t *testing.T, body string) {
+	var resp protocol.Response
+	bodySize := len(body)
+	if resp.IsBodyStream() {
+		t.Fatalf("IsBodyStream must return false")
+	}
+	resp.SetBodyStream(bytes.NewBufferString(body), bodySize)
+	if !resp.IsBodyStream() {
+		t.Fatalf("IsBodyStream must return true")
+	}
+
+	var w bytes.Buffer
+	zw := netpoll.NewWriter(&w)
+	if err := Write(&resp, zw); err != nil {
+		t.Fatalf("unexpected error when writing response: %s. body=%q", err, body)
+	}
+	if err := zw.Flush(); err != nil {
+		t.Fatalf("unexpected error when flushing response: %s. body=%q", err, body)
+	}
+
+	var resp1 protocol.Response
+	br := bufio.NewReader(&w)
+	zr := netpoll.NewReader(br)
+	if err := Read(&resp1, zr); err != nil {
+		t.Fatalf("unexpected error when reading response: %s. body=%q", err, body)
+	}
+	if string(resp1.Body()) != body {
+		t.Fatalf("unexpected body %q. Expecting %q", resp1.Body(), body)
+	}
+}
+
+func testSetResponseBodyStreamChunked(t *testing.T, body string, trailer map[string]string) {
+	var resp protocol.Response
+	if resp.IsBodyStream() {
+		t.Fatalf("IsBodyStream must return false")
+	}
+	resp.SetBodyStream(bytes.NewBufferString(body), -1)
+	if !resp.IsBodyStream() {
+		t.Fatalf("IsBodyStream must return true")
+	}
+
+	var w bytes.Buffer
+	zw := netpoll.NewWriter(&w)
+	for k, v := range trailer {
+		err := resp.Header.Trailer().Add(k, v)
+		if err != nil {
+			t.Fatalf("unexpected error: %s", err)
+		}
+	}
+	if err := Write(&resp, zw); err != nil {
+		t.Fatalf("unexpected error when writing response: %s. body=%q", err, body)
+	}
+	if err := zw.Flush(); err != nil {
+		t.Fatalf("unexpected error when flushing response: %s. body=%q", err, body)
+	}
+	var resp1 protocol.Response
+	br := bufio.NewReader(&w)
+	zr := netpoll.NewReader(br)
+	if err := Read(&resp1, zr); err != nil {
+		t.Fatalf("unexpected error when reading response: %s. body=%q", err, body)
+	}
+	if string(resp1.Body()) != body {
+		t.Fatalf("unexpected body %q. Expecting %q", resp1.Body(), body)
+	}
+	for k, v := range trailer {
+		r := resp.Header.Trailer().Peek(k)
+		if string(r) != v {
+			t.Fatalf("unexpected trailer %s. Expecting %s. Got %q", k, v, r)
+		}
+	}
+}
+
 func testResponseReadBodyStreamSuccess(t *testing.T, resp *protocol.Response, response string, expectedStatusCode, expectedContentLength int,
-	expectedContentType, expectedBody, expectedTrailer string,
+	expectedContentType, expectedBody string, expectedTrailer map[string]string,
 ) {
 	zr := mock.NewZeroCopyReader(response)
 	err := ReadBodyStream(resp, zr, 0, nil)
@@ -540,7 +712,21 @@ func testResponseReadBodyStreamSuccess(t *testing.T, resp *protocol.Response, re
 	if !bytes.Equal(body, []byte(expectedBody)) {
 		t.Fatalf("Unexpected body %q. Expected %q", resp.Body(), []byte(expectedBody))
 	}
-	assert.VerifyTrailer(t, zr, expectedTrailer)
+	verifyResponseTrailer(t, &resp.Header, expectedTrailer)
+}
+
+func testResponseReadBodyStreamBadTrailer(t *testing.T, resp *protocol.Response, response string) {
+	zr := mock.NewZeroCopyReader(response)
+	err := ReadBodyStream(resp, zr, 0, nil)
+	if err != nil {
+		t.Fatalf("Unexpected error: %s", err)
+	}
+	assert.True(t, resp.IsBodyStream())
+
+	_, err = io.ReadAll(resp.BodyStream())
+	if err == nil || err == io.EOF {
+		t.Fatalf("expected error when reading response.")
+	}
 }
 
 func TestResponseReadBodyStream(t *testing.T) {
@@ -550,38 +736,53 @@ func TestResponseReadBodyStream(t *testing.T) {
 
 	// usual response
 	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Length: 10\r\nContent-Type: foo/bar\r\n\r\n0123456789",
-		consts.StatusOK, 10, "foo/bar", "0123456789", "")
+		consts.StatusOK, 10, "foo/bar", "0123456789", nil)
 
 	// zero response
 	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 500 OK\r\nContent-Length: 0\r\nContent-Type: foo/bar\r\n\r\n",
-		consts.StatusInternalServerError, 0, "foo/bar", "", "")
+		consts.StatusInternalServerError, 0, "foo/bar", "", nil)
 
 	// response with trailer
-	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 300 OK\r\nContent-Length: 5\r\nContent-Type: bar\r\n\r\n56789aaa",
-		consts.StatusMultipleChoices, 5, "bar", "56789", "aaa")
+	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 300 OK\r\nTransfer-Encoding: chunked\r\nTrailer: Foo\r\nContent-Type: bar\r\n\r\n5\r\n56789\r\n0\r\nfoo: bar\r\n\r\n",
+		consts.StatusMultipleChoices, -1, "bar", "56789", map[string]string{"Foo": "bar"})
+
+	// response with trailer disableNormalizing
+	resp.Header.DisableNormalizing()
+	resp.Header.Trailer().DisableNormalizing()
+	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 300 OK\r\nTransfer-Encoding: chunked\r\nTrailer: foo\r\nContent-Type: bar\r\n\r\n5\r\n56789\r\n0\r\nfoo: bar\r\n\r\n",
+		consts.StatusMultipleChoices, -1, "bar", "56789", map[string]string{"foo": "bar"})
 
 	// no content-length ('identity' transfer-encoding)
-	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: foobar\r\n\r\nzxxc",
-		consts.StatusOK, -2, "foobar", "zxxc", "")
+	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: foobar\r\n\r\nzxxxx",
+		consts.StatusOK, -2, "foobar", "zxxxx", nil)
 
 	// explicitly stated 'Transfer-Encoding: identity'
 	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 234 ss\r\nContent-Type: xxx\r\n\r\nxag",
-		234, -2, "xxx", "xag", "")
+		234, -2, "xxx", "xag", nil)
 
 	// big 'identity' response
 	body := string(mock.CreateFixedBody(100500))
 	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: aa\r\n\r\n"+body,
-		consts.StatusOK, -2, "aa", body, "")
+		consts.StatusOK, -2, "aa", body, nil)
 
 	// chunked response
-	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nqwer\r\n2\r\nty\r\n0\r\n\r\nzzzzz",
-		consts.StatusOK, -1, "text/html", "qwerty", "zzzzz")
+	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nTrailer: Foo2\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nqwer\r\n2\r\nty\r\n0\r\nFoo2: bar2\r\n\r\n",
+		200, -1, "text/html", "qwerty", map[string]string{"Foo2": "bar2"})
 
 	// chunked response with non-chunked Transfer-Encoding.
-	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 230 OK\r\nContent-Type: text\r\nTransfer-Encoding: aaabbb\r\n\r\n2\r\ner\r\n2\r\nty\r\n0\r\n\r\nwe",
-		230, -1, "text", "erty", "we")
+	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 230 OK\r\nContent-Type: text\r\nTrailer: Foo3\r\nTransfer-Encoding: aaabbb\r\n\r\n2\r\ner\r\n2\r\nty\r\n0\r\nFoo3: bar3\r\n\r\n",
+		230, -1, "text", "erty", map[string]string{"Foo3": "bar3"})
+
+	// chunked response with empty body
+	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nTrailer: Foo5\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nFoo5: bar5\r\n\r\n",
+		consts.StatusOK, -1, "text/html", "", map[string]string{"Foo5": "bar5"})
+}
+
+func TestResponseReadBodyStreamBadTrailer(t *testing.T) {
+	t.Parallel()
+
+	resp := &protocol.Response{}
 
-	// zero chunked response
-	testResponseReadBodyStreamSuccess(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nzzz",
-		consts.StatusOK, -1, "text/html", "", "zzz")
+	testResponseReadBodyStreamBadTrailer(t, resp, "HTTP/1.1 300 OK\r\nTransfer-Encoding: chunked\r\nContent-Type: bar\r\n\r\n5\r\n56789\r\n0\r\ncontent-type: bar\r\n\r\n")
+	testResponseReadBodyStreamBadTrailer(t, resp, "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nqwer\r\n2\r\nty\r\n0\r\nproxy-connection: bar2\r\n\r\n")
 }
diff --git a/pkg/protocol/trailer_test.go b/pkg/protocol/trailer_test.go
new file mode 100644
index 0000000..ac928d0
--- /dev/null
+++ b/pkg/protocol/trailer_test.go
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2023 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package protocol
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/cloudwego/hertz/internal/bytestr"
+
+	"github.com/cloudwego/hertz/pkg/common/test/assert"
+	"github.com/cloudwego/hertz/pkg/protocol/consts"
+)
+
+func TestTrailerAdd(t *testing.T) {
+	var tr Trailer
+	assert.Nil(t, tr.Add("foo", "value1"))
+	assert.Nil(t, tr.Add("foo", "value2"))
+	assert.Nil(t, tr.Add("bar", "value3"))
+	assert.True(t, strings.Contains(string(tr.Header()), "Foo: value1"))
+	assert.True(t, strings.Contains(string(tr.Header()), "Foo: value2"))
+	assert.True(t, strings.Contains(string(tr.Header()), "Bar: value3"))
+}
+
+func TestTrailerAddError(t *testing.T) {
+	var tr Trailer
+	assert.NotNil(t, tr.Add(consts.HeaderContentType, ""))
+	assert.NotNil(t, tr.Set(consts.HeaderProxyConnection, ""))
+}
+
+func TestTrailerDel(t *testing.T) {
+	var tr Trailer
+	assert.Nil(t, tr.Add("foo", "value1"))
+	assert.Nil(t, tr.Add("foo", "value2"))
+	assert.Nil(t, tr.Add("bar", "value3"))
+	tr.Del("foo")
+	assert.False(t, strings.Contains(string(tr.Header()), "Foo: value1"))
+	assert.False(t, strings.Contains(string(tr.Header()), "Foo: value2"))
+	assert.True(t, strings.Contains(string(tr.Header()), "Bar: value3"))
+}
+
+func TestTrailerSet(t *testing.T) {
+	var tr Trailer
+	assert.Nil(t, tr.Set("foo", "value1"))
+	assert.Nil(t, tr.Set("foo", "value2"))
+	assert.Nil(t, tr.Set("bar", "value3"))
+	assert.False(t, strings.Contains(string(tr.Header()), "Foo: value1"))
+	assert.True(t, strings.Contains(string(tr.Header()), "Foo: value2"))
+	assert.True(t, strings.Contains(string(tr.Header()), "Bar: value3"))
+}
+
+func TestTrailerGet(t *testing.T) {
+	var tr Trailer
+	assert.Nil(t, tr.Add("foo", "value1"))
+	assert.Nil(t, tr.Add("bar", "value3"))
+	assert.DeepEqual(t, tr.Get("foo"), "value1")
+	assert.DeepEqual(t, tr.Get("bar"), "value3")
+}
+
+func TestTrailerUpdateArgBytes(t *testing.T) {
+	var tr Trailer
+	assert.Nil(t, tr.addArgBytes([]byte("Foo"), []byte("value0"), argsNoValue))
+	assert.Nil(t, tr.UpdateArgBytes([]byte("Foo"), []byte("value1")))
+	assert.Nil(t, tr.UpdateArgBytes([]byte("Foo"), []byte("value2")))
+	assert.Nil(t, tr.UpdateArgBytes([]byte("Bar"), []byte("value3")))
+	assert.True(t, strings.Contains(string(tr.Header()), "Foo: value1"))
+	assert.False(t, strings.Contains(string(tr.Header()), "Foo: value2"))
+	assert.False(t, strings.Contains(string(tr.Header()), "Bar: value3"))
+}
+
+func TestTrailerEmpty(t *testing.T) {
+	var tr Trailer
+	assert.DeepEqual(t, tr.Empty(), true)
+	assert.Nil(t, tr.Set("foo", ""))
+	assert.DeepEqual(t, tr.Empty(), false)
+}
+
+func TestTrailerVisitAll(t *testing.T) {
+	var tr Trailer
+	assert.Nil(t, tr.Add("foo", "value1"))
+	assert.Nil(t, tr.Add("bar", "value2"))
+	tr.VisitAll(
+		func(k, v []byte) {
+			key := string(k)
+			value := string(v)
+			if (key != "Foo" || value != "value1") && (key != "Bar" || value != "value2") {
+				t.Fatalf("Unexpected (%v, %v). Expected %v", key, value, "(foo, value1) or (bar, value2)")
+			}
+		})
+}
+
+func TestIsBadTrailer(t *testing.T) {
+	assert.True(t, IsBadTrailer(bytestr.StrAuthorization))
+	assert.True(t, IsBadTrailer(bytestr.StrContentEncoding))
+	assert.True(t, IsBadTrailer(bytestr.StrContentLength))
+	assert.True(t, IsBadTrailer(bytestr.StrContentType))
+	assert.True(t, IsBadTrailer(bytestr.StrContentRange))
+	assert.True(t, IsBadTrailer(bytestr.StrConnection))
+	assert.True(t, IsBadTrailer(bytestr.StrExpect))
+	assert.True(t, IsBadTrailer(bytestr.StrHost))
+	assert.True(t, IsBadTrailer(bytestr.StrKeepAlive))
+	assert.True(t, IsBadTrailer(bytestr.StrMaxForwards))
+	assert.True(t, IsBadTrailer(bytestr.StrProxyConnection))
+	assert.True(t, IsBadTrailer(bytestr.StrProxyAuthenticate))
+	assert.True(t, IsBadTrailer(bytestr.StrProxyAuthorization))
+	assert.True(t, IsBadTrailer(bytestr.StrRange))
+	assert.True(t, IsBadTrailer(bytestr.StrTE))
+	assert.True(t, IsBadTrailer(bytestr.StrTrailer))
+	assert.True(t, IsBadTrailer(bytestr.StrTransferEncoding))
+	assert.True(t, IsBadTrailer(bytestr.StrWWWAuthenticate))
+}
diff --git a/pkg/route/routes_timing_test.go b/pkg/route/routes_timing_test.go
index d6de0b4..4beb71d 100644
--- a/pkg/route/routes_timing_test.go
+++ b/pkg/route/routes_timing_test.go
@@ -50,171 +50,170 @@ import (
 )
 
 type Route struct {
-	method string
-	path   string
+	path string
 }
 
 func BenchmarkTree_FindStatic(b *testing.B) {
 	tree := &router{method: "GET", root: &node{}, hasTsrHandler: make(map[string]bool)}
 
 	static := []*Route{
-		{"GET", "/"},
-		{"GET", "/cmd.html"},
-		{"GET", "/code.html"},
-		{"GET", "/contrib.html"},
-		{"GET", "/contribute.html"},
-		{"GET", "/debugging_with_gdb.html"},
-		{"GET", "/docs.html"},
-		{"GET", "/effective_go.html"},
-		{"GET", "/files.log"},
-		{"GET", "/gccgo_contribute.html"},
-		{"GET", "/gccgo_install.html"},
-		{"GET", "/go-logo-black.png"},
-		{"GET", "/go-logo-blue.png"},
-		{"GET", "/go-logo-white.png"},
-		{"GET", "/go1.1.html"},
-		{"GET", "/go1.2.html"},
-		{"GET", "/go1.html"},
-		{"GET", "/go1compat.html"},
-		{"GET", "/go_faq.html"},
-		{"GET", "/go_mem.html"},
-		{"GET", "/go_spec.html"},
-		{"GET", "/help.html"},
-		{"GET", "/ie.css"},
-		{"GET", "/install-source.html"},
-		{"GET", "/install.html"},
-		{"GET", "/logo-153x55.png"},
-		{"GET", "/Makefile"},
-		{"GET", "/root.html"},
-		{"GET", "/share.png"},
-		{"GET", "/sieve.gif"},
-		{"GET", "/tos.html"},
-		{"GET", "/articles/"},
-		{"GET", "/articles/go_command.html"},
-		{"GET", "/articles/index.html"},
-		{"GET", "/articles/wiki/"},
-		{"GET", "/articles/wiki/edit.html"},
-		{"GET", "/articles/wiki/final-noclosure.go"},
-		{"GET", "/articles/wiki/final-noerror.go"},
-		{"GET", "/articles/wiki/final-parsetemplate.go"},
-		{"GET", "/articles/wiki/final-template.go"},
-		{"GET", "/articles/wiki/final.go"},
-		{"GET", "/articles/wiki/get.go"},
-		{"GET", "/articles/wiki/http-sample.go"},
-		{"GET", "/articles/wiki/index.html"},
-		{"GET", "/articles/wiki/Makefile"},
-		{"GET", "/articles/wiki/notemplate.go"},
-		{"GET", "/articles/wiki/part1-noerror.go"},
-		{"GET", "/articles/wiki/part1.go"},
-		{"GET", "/articles/wiki/part2.go"},
-		{"GET", "/articles/wiki/part3-errorhandling.go"},
-		{"GET", "/articles/wiki/part3.go"},
-		{"GET", "/articles/wiki/test.bash"},
-		{"GET", "/articles/wiki/test_edit.good"},
-		{"GET", "/articles/wiki/test_Test.txt.good"},
-		{"GET", "/articles/wiki/test_view.good"},
-		{"GET", "/articles/wiki/view.html"},
-		{"GET", "/codewalk/"},
-		{"GET", "/codewalk/codewalk.css"},
-		{"GET", "/codewalk/codewalk.js"},
-		{"GET", "/codewalk/codewalk.xml"},
-		{"GET", "/codewalk/functions.xml"},
-		{"GET", "/codewalk/markov.go"},
-		{"GET", "/codewalk/markov.xml"},
-		{"GET", "/codewalk/pig.go"},
-		{"GET", "/codewalk/popout.png"},
-		{"GET", "/codewalk/run"},
-		{"GET", "/codewalk/sharemem.xml"},
-		{"GET", "/codewalk/urlpoll.go"},
-		{"GET", "/devel/"},
-		{"GET", "/devel/release.html"},
-		{"GET", "/devel/weekly.html"},
-		{"GET", "/gopher/"},
-		{"GET", "/gopher/appenginegopher.jpg"},
-		{"GET", "/gopher/appenginegophercolor.jpg"},
-		{"GET", "/gopher/appenginelogo.gif"},
-		{"GET", "/gopher/bumper.png"},
-		{"GET", "/gopher/bumper192x108.png"},
-		{"GET", "/gopher/bumper320x180.png"},
-		{"GET", "/gopher/bumper480x270.png"},
-		{"GET", "/gopher/bumper640x360.png"},
-		{"GET", "/gopher/doc.png"},
-		{"GET", "/gopher/frontpage.png"},
-		{"GET", "/gopher/gopherbw.png"},
-		{"GET", "/gopher/gophercolor.png"},
-		{"GET", "/gopher/gophercolor16x16.png"},
-		{"GET", "/gopher/help.png"},
-		{"GET", "/gopher/pkg.png"},
-		{"GET", "/gopher/project.png"},
-		{"GET", "/gopher/ref.png"},
-		{"GET", "/gopher/run.png"},
-		{"GET", "/gopher/talks.png"},
-		{"GET", "/gopher/pencil/"},
-		{"GET", "/gopher/pencil/gopherhat.jpg"},
-		{"GET", "/gopher/pencil/gopherhelmet.jpg"},
-		{"GET", "/gopher/pencil/gophermega.jpg"},
-		{"GET", "/gopher/pencil/gopherrunning.jpg"},
-		{"GET", "/gopher/pencil/gopherswim.jpg"},
-		{"GET", "/gopher/pencil/gopherswrench.jpg"},
-		{"GET", "/play/"},
-		{"GET", "/play/fib.go"},
-		{"GET", "/play/hello.go"},
-		{"GET", "/play/life.go"},
-		{"GET", "/play/peano.go"},
-		{"GET", "/play/pi.go"},
-		{"GET", "/play/sieve.go"},
-		{"GET", "/play/solitaire.go"},
-		{"GET", "/play/tree.go"},
-		{"GET", "/progs/"},
-		{"GET", "/progs/cgo1.go"},
-		{"GET", "/progs/cgo2.go"},
-		{"GET", "/progs/cgo3.go"},
-		{"GET", "/progs/cgo4.go"},
-		{"GET", "/progs/defer.go"},
-		{"GET", "/progs/defer.out"},
-		{"GET", "/progs/defer2.go"},
-		{"GET", "/progs/defer2.out"},
-		{"GET", "/progs/eff_bytesize.go"},
-		{"GET", "/progs/eff_bytesize.out"},
-		{"GET", "/progs/eff_qr.go"},
-		{"GET", "/progs/eff_sequence.go"},
-		{"GET", "/progs/eff_sequence.out"},
-		{"GET", "/progs/eff_unused1.go"},
-		{"GET", "/progs/eff_unused2.go"},
-		{"GET", "/progs/error.go"},
-		{"GET", "/progs/error2.go"},
-		{"GET", "/progs/error3.go"},
-		{"GET", "/progs/error4.go"},
-		{"GET", "/progs/go1.go"},
-		{"GET", "/progs/gobs1.go"},
-		{"GET", "/progs/gobs2.go"},
-		{"GET", "/progs/image_draw.go"},
-		{"GET", "/progs/image_package1.go"},
-		{"GET", "/progs/image_package1.out"},
-		{"GET", "/progs/image_package2.go"},
-		{"GET", "/progs/image_package2.out"},
-		{"GET", "/progs/image_package3.go"},
-		{"GET", "/progs/image_package3.out"},
-		{"GET", "/progs/image_package4.go"},
-		{"GET", "/progs/image_package4.out"},
-		{"GET", "/progs/image_package5.go"},
-		{"GET", "/progs/image_package5.out"},
-		{"GET", "/progs/image_package6.go"},
-		{"GET", "/progs/image_package6.out"},
-		{"GET", "/progs/interface.go"},
-		{"GET", "/progs/interface2.go"},
-		{"GET", "/progs/interface2.out"},
-		{"GET", "/progs/json1.go"},
-		{"GET", "/progs/json2.go"},
-		{"GET", "/progs/json2.out"},
-		{"GET", "/progs/json3.go"},
-		{"GET", "/progs/json4.go"},
-		{"GET", "/progs/json5.go"},
-		{"GET", "/progs/run"},
-		{"GET", "/progs/slices.go"},
-		{"GET", "/progs/timeout1.go"},
-		{"GET", "/progs/timeout2.go"},
-		{"GET", "/progs/update.bash"},
+		{"/"},
+		{"/cmd.html"},
+		{"/code.html"},
+		{"/contrib.html"},
+		{"/contribute.html"},
+		{"/debugging_with_gdb.html"},
+		{"/docs.html"},
+		{"/effective_go.html"},
+		{"/files.log"},
+		{"/gccgo_contribute.html"},
+		{"/gccgo_install.html"},
+		{"/go-logo-black.png"},
+		{"/go-logo-blue.png"},
+		{"/go-logo-white.png"},
+		{"/go1.1.html"},
+		{"/go1.2.html"},
+		{"/go1.html"},
+		{"/go1compat.html"},
+		{"/go_faq.html"},
+		{"/go_mem.html"},
+		{"/go_spec.html"},
+		{"/help.html"},
+		{"/ie.css"},
+		{"/install-source.html"},
+		{"/install.html"},
+		{"/logo-153x55.png"},
+		{"/Makefile"},
+		{"/root.html"},
+		{"/share.png"},
+		{"/sieve.gif"},
+		{"/tos.html"},
+		{"/articles/"},
+		{"/articles/go_command.html"},
+		{"/articles/index.html"},
+		{"/articles/wiki/"},
+		{"/articles/wiki/edit.html"},
+		{"/articles/wiki/final-noclosure.go"},
+		{"/articles/wiki/final-noerror.go"},
+		{"/articles/wiki/final-parsetemplate.go"},
+		{"/articles/wiki/final-template.go"},
+		{"/articles/wiki/final.go"},
+		{"/articles/wiki/get.go"},
+		{"/articles/wiki/http-sample.go"},
+		{"/articles/wiki/index.html"},
+		{"/articles/wiki/Makefile"},
+		{"/articles/wiki/notemplate.go"},
+		{"/articles/wiki/part1-noerror.go"},
+		{"/articles/wiki/part1.go"},
+		{"/articles/wiki/part2.go"},
+		{"/articles/wiki/part3-errorhandling.go"},
+		{"/articles/wiki/part3.go"},
+		{"/articles/wiki/test.bash"},
+		{"/articles/wiki/test_edit.good"},
+		{"/articles/wiki/test_Test.txt.good"},
+		{"/articles/wiki/test_view.good"},
+		{"/articles/wiki/view.html"},
+		{"/codewalk/"},
+		{"/codewalk/codewalk.css"},
+		{"/codewalk/codewalk.js"},
+		{"/codewalk/codewalk.xml"},
+		{"/codewalk/functions.xml"},
+		{"/codewalk/markov.go"},
+		{"/codewalk/markov.xml"},
+		{"/codewalk/pig.go"},
+		{"/codewalk/popout.png"},
+		{"/codewalk/run"},
+		{"/codewalk/sharemem.xml"},
+		{"/codewalk/urlpoll.go"},
+		{"/devel/"},
+		{"/devel/release.html"},
+		{"/devel/weekly.html"},
+		{"/gopher/"},
+		{"/gopher/appenginegopher.jpg"},
+		{"/gopher/appenginegophercolor.jpg"},
+		{"/gopher/appenginelogo.gif"},
+		{"/gopher/bumper.png"},
+		{"/gopher/bumper192x108.png"},
+		{"/gopher/bumper320x180.png"},
+		{"/gopher/bumper480x270.png"},
+		{"/gopher/bumper640x360.png"},
+		{"/gopher/doc.png"},
+		{"/gopher/frontpage.png"},
+		{"/gopher/gopherbw.png"},
+		{"/gopher/gophercolor.png"},
+		{"/gopher/gophercolor16x16.png"},
+		{"/gopher/help.png"},
+		{"/gopher/pkg.png"},
+		{"/gopher/project.png"},
+		{"/gopher/ref.png"},
+		{"/gopher/run.png"},
+		{"/gopher/talks.png"},
+		{"/gopher/pencil/"},
+		{"/gopher/pencil/gopherhat.jpg"},
+		{"/gopher/pencil/gopherhelmet.jpg"},
+		{"/gopher/pencil/gophermega.jpg"},
+		{"/gopher/pencil/gopherrunning.jpg"},
+		{"/gopher/pencil/gopherswim.jpg"},
+		{"/gopher/pencil/gopherswrench.jpg"},
+		{"/play/"},
+		{"/play/fib.go"},
+		{"/play/hello.go"},
+		{"/play/life.go"},
+		{"/play/peano.go"},
+		{"/play/pi.go"},
+		{"/play/sieve.go"},
+		{"/play/solitaire.go"},
+		{"/play/tree.go"},
+		{"/progs/"},
+		{"/progs/cgo1.go"},
+		{"/progs/cgo2.go"},
+		{"/progs/cgo3.go"},
+		{"/progs/cgo4.go"},
+		{"/progs/defer.go"},
+		{"/progs/defer.out"},
+		{"/progs/defer2.go"},
+		{"/progs/defer2.out"},
+		{"/progs/eff_bytesize.go"},
+		{"/progs/eff_bytesize.out"},
+		{"/progs/eff_qr.go"},
+		{"/progs/eff_sequence.go"},
+		{"/progs/eff_sequence.out"},
+		{"/progs/eff_unused1.go"},
+		{"/progs/eff_unused2.go"},
+		{"/progs/error.go"},
+		{"/progs/error2.go"},
+		{"/progs/error3.go"},
+		{"/progs/error4.go"},
+		{"/progs/go1.go"},
+		{"/progs/gobs1.go"},
+		{"/progs/gobs2.go"},
+		{"/progs/image_draw.go"},
+		{"/progs/image_package1.go"},
+		{"/progs/image_package1.out"},
+		{"/progs/image_package2.go"},
+		{"/progs/image_package2.out"},
+		{"/progs/image_package3.go"},
+		{"/progs/image_package3.out"},
+		{"/progs/image_package4.go"},
+		{"/progs/image_package4.out"},
+		{"/progs/image_package5.go"},
+		{"/progs/image_package5.out"},
+		{"/progs/image_package6.go"},
+		{"/progs/image_package6.out"},
+		{"/progs/interface.go"},
+		{"/progs/interface2.go"},
+		{"/progs/interface2.out"},
+		{"/progs/json1.go"},
+		{"/progs/json2.go"},
+		{"/progs/json2.out"},
+		{"/progs/json3.go"},
+		{"/progs/json4.go"},
+		{"/progs/json5.go"},
+		{"/progs/run"},
+		{"/progs/slices.go"},
+		{"/progs/timeout1.go"},
+		{"/progs/timeout2.go"},
+		{"/progs/update.bash"},
 	}
 
 	for _, route := range static {
@@ -235,162 +234,162 @@ func BenchmarkTree_FindGithub(b *testing.B) {
 
 	static := []*Route{
 		// OAuth Authorizations
-		{"GET", "/authorizations"},
-		{"GET", "/authorizations/:id"},
-		//{"POST", "/authorizations"},
-		//{"PUT", "/authorizations/clients/:client_id"},
-		//{"PATCH", "/authorizations/:id"},
-		//{"DELETE", "/authorizations/:id"},
-		{"GET", "/applications/:client_id/tokens/:access_token"},
-		{"DELETE", "/applications/:client_id/tokens"},
-		//{"DELETE", "/applications/:client_id/tokens/:access_token"},
+		{"/authorizations"},
+		{"/authorizations/:id"},
+		//{"/authorizations"},
+		//{"/authorizations/clients/:client_id"},
+		//{"/authorizations/:id"},
+		//{"/authorizations/:id"},
+		{"/applications/:client_id/tokens/:access_token"},
+		{"/applications/:client_id/tokens"},
+		//{"/applications/:client_id/tokens/:access_token"},
 
 		// Activity
-		{"GET", "/events"},
-		{"GET", "/repos/:owner/:repo/events"},
-		{"GET", "/networks/:owner/:repo/events"},
-		{"GET", "/orgs/:org/events"},
-		{"GET", "/users/:user/received_events"},
-		{"GET", "/users/:user/received_events/public"},
-		{"GET", "/users/:user/events"},
-		{"GET", "/users/:user/events/public"},
-		{"GET", "/users/:user/events/orgs/:org"},
-		{"GET", "/feeds"},
-		//{"GET", "/notifications"},
-		{"GET", "/repos/:owner/:repo/notifications"},
-		{"PUT", "/notifications"},
-		//{"PUT", "/repos/:owner/:repo/notifications"},
-		{"GET", "/notifications/threads/:id"},
-		//{"PATCH", "/notifications/threads/:id"},
-		{"GET", "/notifications/threads/:id/subscription"},
-		//{"PUT", "/notifications/threads/:id/subscription"},
-		//{"DELETE", "/notifications/threads/:id/subscription"},
-		{"GET", "/repos/:owner/:repo/stargazers"},
-		{"GET", "/users/:user/starred"},
-		{"GET", "/user/starred"},
-		{"GET", "/user/starred/:owner/:repo"},
-		//{"PUT", "/user/starred/:owner/:repo"},
-		//{"DELETE", "/user/starred/:owner/:repo"},
-		{"GET", "/repos/:owner/:repo/subscribers"},
-		{"GET", "/users/:user/subscriptions"},
-		{"GET", "/user/subscriptions"},
-		{"GET", "/repos/:owner/:repo/subscription"},
-		//{"PUT", "/repos/:owner/:repo/subscription"},
-		//{"DELETE", "/repos/:owner/:repo/subscription"},
-		{"GET", "/user/subscriptions/:owner/:repo"},
+		{"/events"},
+		{"/repos/:owner/:repo/events"},
+		{"/networks/:owner/:repo/events"},
+		{"/orgs/:org/events"},
+		{"/users/:user/received_events"},
+		{"/users/:user/received_events/public"},
+		{"/users/:user/events"},
+		{"/users/:user/events/public"},
+		{"/users/:user/events/orgs/:org"},
+		{"/feeds"},
+		//{"/notifications"},
+		{"/repos/:owner/:repo/notifications"},
+		{"/notifications"},
+		//{"/repos/:owner/:repo/notifications"},
+		{"/notifications/threads/:id"},
+		//{"/notifications/threads/:id"},
+		{"/notifications/threads/:id/subscription"},
+		//{"/notifications/threads/:id/subscription"},
+		//{"/notifications/threads/:id/subscription"},
+		{"/repos/:owner/:repo/stargazers"},
+		{"/users/:user/starred"},
+		{"/user/starred"},
+		{"/user/starred/:owner/:repo"},
+		//{"/user/starred/:owner/:repo"},
+		//{"/user/starred/:owner/:repo"},
+		{"/repos/:owner/:repo/subscribers"},
+		{"/users/:user/subscriptions"},
+		{"/user/subscriptions"},
+		{"/repos/:owner/:repo/subscription"},
+		//{"/repos/:owner/:repo/subscription"},
+		//{"/repos/:owner/:repo/subscription"},
+		{"/user/subscriptions/:owner/:repo"},
 		//{"PUT", "/user/subscriptions/:owner/:repo"},
 		//{"DELETE", "/user/subscriptions/:owner/:repo"},
 
 		// Gists
-		{"GET", "/users/:user/gists"},
-		{"GET", "/gists"},
+		{"/users/:user/gists"},
+		{"/gists"},
 		//{"GET", "/gists/public"},
 		//{"GET", "/gists/starred"},
-		{"GET", "/gists/:id"},
+		{"/gists/:id"},
 		//{"POST", "/gists"},
 		//{"PATCH", "/gists/:id"},
-		{"PUT", "/gists/:id/star"},
+		{"/gists/:id/star"},
 		//{"DELETE", "/gists/:id/star"},
 		//{"GET", "/gists/:id/star"},
-		{"POST", "/gists/:id/forks"},
+		{"/gists/:id/forks"},
 		//{"DELETE", "/gists/:id"},
 
 		// Git Data
-		{"GET", "/repos/:owner/:repo/git/blobs/:sha"},
-		{"POST", "/repos/:owner/:repo/git/blobs"},
-		{"GET", "/repos/:owner/:repo/git/commits/:sha"},
-		{"POST", "/repos/:owner/:repo/git/commits"},
+		{"/repos/:owner/:repo/git/blobs/:sha"},
+		{"/repos/:owner/:repo/git/blobs"},
+		{"/repos/:owner/:repo/git/commits/:sha"},
+		{"/repos/:owner/:repo/git/commits"},
 		//{"GET", "/repos/:owner/:repo/git/refs/*ref"},
-		{"GET", "/repos/:owner/:repo/git/refs"},
+		{"/repos/:owner/:repo/git/refs"},
 		//{"POST", "/repos/:owner/:repo/git/refs"},
 		//{"PATCH", "/repos/:owner/:repo/git/refs/*ref"},
 		//{"DELETE", "/repos/:owner/:repo/git/refs/*ref"},
-		{"GET", "/repos/:owner/:repo/git/tags/:sha"},
-		{"POST", "/repos/:owner/:repo/git/tags"},
-		{"GET", "/repos/:owner/:repo/git/trees/:sha"},
-		{"POST", "/repos/:owner/:repo/git/trees"},
-
-		{"GET", "/issues"},
-		{"GET", "/user/issues"},
-		{"GET", "/orgs/:org/issues"},
-		{"GET", "/repos/:owner/:repo/issues"},
-		{"GET", "/repos/:owner/:repo/issues/:number"},
+		{"/repos/:owner/:repo/git/tags/:sha"},
+		{"/repos/:owner/:repo/git/tags"},
+		{"/repos/:owner/:repo/git/trees/:sha"},
+		{"/repos/:owner/:repo/git/trees"},
+
+		{"/issues"},
+		{"/user/issues"},
+		{"/orgs/:org/issues"},
+		{"/repos/:owner/:repo/issues"},
+		{"/repos/:owner/:repo/issues/:number"},
 		//{"POST", "/repos/:owner/:repo/issues"},
 		//{"PATCH", "/repos/:owner/:repo/issues/:number"},
-		{"GET", "/repos/:owner/:repo/assignees"},
-		{"GET", "/repos/:owner/:repo/assignees/:assignee"},
-		{"GET", "/repos/:owner/:repo/issues/:number/comments"},
+		{"/repos/:owner/:repo/assignees"},
+		{"/repos/:owner/:repo/assignees/:assignee"},
+		{"/repos/:owner/:repo/issues/:number/comments"},
 		//{"GET", "/repos/:owner/:repo/issues/comments"},
 		//{"GET", "/repos/:owner/:repo/issues/comments/:id"},
 		//{"POST", "/repos/:owner/:repo/issues/:number/comments"},
 		//{"PATCH", "/repos/:owner/:repo/issues/comments/:id"},
 		//{"DELETE", "/repos/:owner/:repo/issues/comments/:id"},
-		{"GET", "/repos/:owner/:repo/issues/:number/events"},
+		{"/repos/:owner/:repo/issues/:number/events"},
 		//{"GET", "/repos/:owner/:repo/issues/events"},
 		//{"GET", "/repos/:owner/:repo/issues/events/:id"},
-		{"GET", "/repos/:owner/:repo/labels"},
-		{"GET", "/repos/:owner/:repo/labels/:name"},
+		{"/repos/:owner/:repo/labels"},
+		{"/repos/:owner/:repo/labels/:name"},
 		//{"POST", "/repos/:owner/:repo/labels"},
 		//{"PATCH", "/repos/:owner/:repo/labels/:name"},
 		//{"DELETE", "/repos/:owner/:repo/labels/:name"},
-		{"GET", "/repos/:owner/:repo/issues/:number/labels"},
+		{"/repos/:owner/:repo/issues/:number/labels"},
 		//{"POST", "/repos/:owner/:repo/issues/:number/labels"},
 		//{"DELETE", "/repos/:owner/:repo/issues/:number/labels/:name"},
 		//{"PUT", "/repos/:owner/:repo/issues/:number/labels"},
 		//{"DELETE", "/repos/:owner/:repo/issues/:number/labels"},
-		{"GET", "/repos/:owner/:repo/milestones/:number/labels"},
-		{"GET", "/repos/:owner/:repo/milestones"},
-		{"GET", "/repos/:owner/:repo/milestones/:number"},
+		{"/repos/:owner/:repo/milestones/:number/labels"},
+		{"/repos/:owner/:repo/milestones"},
+		{"/repos/:owner/:repo/milestones/:number"},
 		//{"POST", "/repos/:owner/:repo/milestones"},
 		//{"PATCH", "/repos/:owner/:repo/milestones/:number"},
 		//{"DELETE", "/repos/:owner/:repo/milestones/:number"},
 
 		// Miscellaneous
-		{"GET", "/emojis"},
-		{"GET", "/gitignore/templates"},
-		{"GET", "/gitignore/templates/:name"},
-		{"POST", "/markdown"},
-		{"POST", "/markdown/raw"},
-		{"GET", "/meta"},
-		{"GET", "/rate_limit"},
+		{"/emojis"},
+		{"/gitignore/templates"},
+		{"/gitignore/templates/:name"},
+		{"/markdown"},
+		{"/markdown/raw"},
+		{"/meta"},
+		{"/rate_limit"},
 
 		// Organizations
-		{"GET", "/users/:user/orgs"},
-		{"GET", "/user/orgs"},
-		{"GET", "/orgs/:org"},
+		{"/users/:user/orgs"},
+		{"/user/orgs"},
+		{"/orgs/:org"},
 		//{"PATCH", "/orgs/:org"},
-		{"GET", "/orgs/:org/members"},
-		{"GET", "/orgs/:org/members/:user"},
+		{"/orgs/:org/members"},
+		{"/orgs/:org/members/:user"},
 		//{"DELETE", "/orgs/:org/members/:user"},
-		{"GET", "/orgs/:org/public_members"},
-		{"GET", "/orgs/:org/public_members/:user"},
+		{"/orgs/:org/public_members"},
+		{"/orgs/:org/public_members/:user"},
 		//{"PUT", "/orgs/:org/public_members/:user"},
 		//{"DELETE", "/orgs/:org/public_members/:user"},
-		{"GET", "/orgs/:org/teams"},
-		{"GET", "/teams/:id"},
+		{"/orgs/:org/teams"},
+		{"/teams/:id"},
 		//{"POST", "/orgs/:org/teams"},
 		//{"PATCH", "/teams/:id"},
 		//{"DELETE", "/teams/:id"},
-		{"GET", "/teams/:id/members"},
-		{"GET", "/teams/:id/members/:user"},
+		{"/teams/:id/members"},
+		{"/teams/:id/members/:user"},
 		//{"PUT", "/teams/:id/members/:user"},
 		//{"DELETE", "/teams/:id/members/:user"},
-		{"GET", "/teams/:id/repos"},
-		{"GET", "/teams/:id/repos/:owner/:repo"},
+		{"/teams/:id/repos"},
+		{"/teams/:id/repos/:owner/:repo"},
 		//{"PUT", "/teams/:id/repos/:owner/:repo"},
 		//{"DELETE", "/teams/:id/repos/:owner/:repo"},
-		{"GET", "/user/teams"},
+		{"/user/teams"},
 
 		// Pull Requests
-		{"GET", "/repos/:owner/:repo/pulls"},
-		{"GET", "/repos/:owner/:repo/pulls/:number"},
+		{"/repos/:owner/:repo/pulls"},
+		{"/repos/:owner/:repo/pulls/:number"},
 		//{"POST", "/repos/:owner/:repo/pulls"},
 		//{"PATCH", "/repos/:owner/:repo/pulls/:number"},
-		{"GET", "/repos/:owner/:repo/pulls/:number/commits"},
-		{"GET", "/repos/:owner/:repo/pulls/:number/files"},
-		{"GET", "/repos/:owner/:repo/pulls/:number/merge"},
+		{"/repos/:owner/:repo/pulls/:number/commits"},
+		{"/repos/:owner/:repo/pulls/:number/files"},
+		{"/repos/:owner/:repo/pulls/:number/merge"},
 		//{"PUT", "/repos/:owner/:repo/pulls/:number/merge"},
-		{"GET", "/repos/:owner/:repo/pulls/:number/comments"},
+		{"/repos/:owner/:repo/pulls/:number/comments"},
 		//{"GET", "/repos/:owner/:repo/pulls/comments"},
 		//{"GET", "/repos/:owner/:repo/pulls/comments/:number"},
 		//{"PUT", "/repos/:owner/:repo/pulls/:number/comments"},
@@ -398,98 +397,98 @@ func BenchmarkTree_FindGithub(b *testing.B) {
 		//{"DELETE", "/repos/:owner/:repo/pulls/comments/:number"},
 
 		// Repositories
-		{"GET", "/user/repos"},
-		{"GET", "/users/:user/repos"},
-		{"GET", "/orgs/:org/repos"},
-		{"GET", "/repositories"},
+		{"/user/repos"},
+		{"/users/:user/repos"},
+		{"/orgs/:org/repos"},
+		{"/repositories"},
 		//{"POST", "/user/repos"},
 		//{"POST", "/orgs/:org/repos"},
-		{"GET", "/repos/:owner/:repo"},
+		{"/repos/:owner/:repo"},
 		//{"PATCH", "/repos/:owner/:repo"},
-		{"GET", "/repos/:owner/:repo/contributors"},
-		{"GET", "/repos/:owner/:repo/languages"},
-		{"GET", "/repos/:owner/:repo/teams"},
-		{"GET", "/repos/:owner/:repo/tags"},
-		{"GET", "/repos/:owner/:repo/branches"},
-		{"GET", "/repos/:owner/:repo/branches/:branch"},
+		{"/repos/:owner/:repo/contributors"},
+		{"/repos/:owner/:repo/languages"},
+		{"/repos/:owner/:repo/teams"},
+		{"/repos/:owner/:repo/tags"},
+		{"/repos/:owner/:repo/branches"},
+		{"/repos/:owner/:repo/branches/:branch"},
 		//{"DELETE", "/repos/:owner/:repo"},
-		{"GET", "/repos/:owner/:repo/collaborators"},
-		{"GET", "/repos/:owner/:repo/collaborators/:user"},
+		{"/repos/:owner/:repo/collaborators"},
+		{"/repos/:owner/:repo/collaborators/:user"},
 		//{"PUT", "/repos/:owner/:repo/collaborators/:user"},
 		//{"DELETE", "/repos/:owner/:repo/collaborators/:user"},
-		{"GET", "/repos/:owner/:repo/comments"},
-		{"GET", "/repos/:owner/:repo/commits/:sha/comments"},
+		{"/repos/:owner/:repo/comments"},
+		{"/repos/:owner/:repo/commits/:sha/comments"},
 		//{"POST", "/repos/:owner/:repo/commits/:sha/comments"},
-		{"GET", "/repos/:owner/:repo/comments/:id"},
+		{"/repos/:owner/:repo/comments/:id"},
 		//{"PATCH", "/repos/:owner/:repo/comments/:id"},
 		//{"DELETE", "/repos/:owner/:repo/comments/:id"},
-		{"GET", "/repos/:owner/:repo/commits"},
-		{"GET", "/repos/:owner/:repo/commits/:sha"},
-		{"GET", "/repos/:owner/:repo/readme"},
+		{"/repos/:owner/:repo/commits"},
+		{"/repos/:owner/:repo/commits/:sha"},
+		{"/repos/:owner/:repo/readme"},
 		//{"GET", "/repos/:owner/:repo/contents/*path"},
 		//{"PUT", "/repos/:owner/:repo/contents/*path"},
 		//{"DELETE", "/repos/:owner/:repo/contents/*path"},
 		//{"GET", "/repos/:owner/:repo/:archive_format/:ref"},
-		{"GET", "/repos/:owner/:repo/keys"},
-		{"GET", "/repos/:owner/:repo/keys/:id"},
+		{"/repos/:owner/:repo/keys"},
+		{"/repos/:owner/:repo/keys/:id"},
 		//{"POST", "/repos/:owner/:repo/keys"},
 		//{"PATCH", "/repos/:owner/:repo/keys/:id"},
 		//{"DELETE", "/repos/:owner/:repo/keys/:id"},
-		{"GET", "/repos/:owner/:repo/downloads"},
-		{"GET", "/repos/:owner/:repo/downloads/:id"},
+		{"/repos/:owner/:repo/downloads"},
+		{"/repos/:owner/:repo/downloads/:id"},
 		//{"DELETE", "/repos/:owner/:repo/downloads/:id"},
-		{"GET", "/repos/:owner/:repo/forks"},
+		{"/repos/:owner/:repo/forks"},
 		//{"POST", "/repos/:owner/:repo/forks"},
-		{"GET", "/repos/:owner/:repo/hooks"},
-		{"GET", "/repos/:owner/:repo/hooks/:id"},
+		{"/repos/:owner/:repo/hooks"},
+		{"/repos/:owner/:repo/hooks/:id"},
 		//{"POST", "/repos/:owner/:repo/hooks"},
 		//{"PATCH", "/repos/:owner/:repo/hooks/:id"},
 		//{"POST", "/repos/:owner/:repo/hooks/:id/tests"},
 		//{"DELETE", "/repos/:owner/:repo/hooks/:id"},
 		//{"POST", "/repos/:owner/:repo/merges"},
-		{"GET", "/repos/:owner/:repo/releases"},
-		{"GET", "/repos/:owner/:repo/releases/:id"},
+		{"/repos/:owner/:repo/releases"},
+		{"/repos/:owner/:repo/releases/:id"},
 		//{"POST", "/repos/:owner/:repo/releases"},
 		//{"PATCH", "/repos/:owner/:repo/releases/:id"},
 		//{"DELETE", "/repos/:owner/:repo/releases/:id"},
-		{"GET", "/repos/:owner/:repo/releases/:id/assets"},
-		{"GET", "/repos/:owner/:repo/stats/contributors"},
-		{"GET", "/repos/:owner/:repo/stats/commit_activity"},
-		{"GET", "/repos/:owner/:repo/stats/code_frequency"},
-		{"GET", "/repos/:owner/:repo/stats/participation"},
-		{"GET", "/repos/:owner/:repo/stats/punch_card"},
-		{"GET", "/repos/:owner/:repo/statuses/:ref"},
+		{"/repos/:owner/:repo/releases/:id/assets"},
+		{"/repos/:owner/:repo/stats/contributors"},
+		{"/repos/:owner/:repo/stats/commit_activity"},
+		{"/repos/:owner/:repo/stats/code_frequency"},
+		{"/repos/:owner/:repo/stats/participation"},
+		{"/repos/:owner/:repo/stats/punch_card"},
+		{"/repos/:owner/:repo/statuses/:ref"},
 		//{"POST", "/repos/:owner/:repo/statuses/:ref"},
 
 		// Search
-		{"GET", "/search/repositories"},
-		{"GET", "/search/code"},
-		{"GET", "/search/issues"},
-		{"GET", "/search/users"},
-		{"GET", "/legacy/issues/search/:owner/:repository/:state/:keyword"},
-		{"GET", "/legacy/repos/search/:keyword"},
-		{"GET", "/legacy/user/search/:keyword"},
-		{"GET", "/legacy/user/email/:email"},
+		{"/search/repositories"},
+		{"/search/code"},
+		{"/search/issues"},
+		{"/search/users"},
+		{"/legacy/issues/search/:owner/:repository/:state/:keyword"},
+		{"/legacy/repos/search/:keyword"},
+		{"/legacy/user/search/:keyword"},
+		{"/legacy/user/email/:email"},
 
 		// Users
-		{"GET", "/users/:user"},
-		{"GET", "/user"},
+		{"/users/:user"},
+		{"/user"},
 		//{"PATCH", "/user"},
-		{"GET", "/users"},
-		{"GET", "/user/emails"},
+		{"/users"},
+		{"/user/emails"},
 		//{"POST", "/user/emails"},
 		//{"DELETE", "/user/emails"},
-		{"GET", "/users/:user/followers"},
-		{"GET", "/user/followers"},
-		{"GET", "/users/:user/following"},
-		{"GET", "/user/following"},
-		{"GET", "/user/following/:user"},
-		{"GET", "/users/:user/following/:target_user"},
+		{"/users/:user/followers"},
+		{"/user/followers"},
+		{"/users/:user/following"},
+		{"/user/following"},
+		{"/user/following/:user"},
+		{"/users/:user/following/:target_user"},
 		//{"PUT", "/user/following/:user"},
 		//{"DELETE", "/user/following/:user"},
-		{"GET", "/users/:user/keys"},
-		{"GET", "/user/keys"},
-		{"GET", "/user/keys/:id"},
+		{"/users/:user/keys"},
+		{"/user/keys"},
+		{"/user/keys/:id"},
 		//{"POST", "/user/keys"},
 		//{"PATCH", "/user/keys/:id"},
 		//{"DELETE", "/user/keys/:id"},