@vincent119/go-copilot-rules 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent_agy/INSTALLATION.md +786 -0
- package/.agent_agy/README.md +243 -0
- package/.agent_agy/SKILLS_INDEX.md +516 -0
- package/.agent_agy/rules/go-core.copilot-instructions.md +251 -0
- package/.agent_agy/skills/go-api-design/SKILL.md +535 -0
- package/.agent_agy/skills/go-ci-tooling/SKILL.md +533 -0
- package/.agent_agy/skills/go-configuration/SKILL.md +609 -0
- package/.agent_agy/skills/go-database/SKILL.md +412 -0
- package/.agent_agy/skills/go-ddd/SKILL.md +374 -0
- package/.agent_agy/skills/go-dependency-injection/SKILL.md +546 -0
- package/.agent_agy/skills/go-domain-events/SKILL.md +525 -0
- package/.agent_agy/skills/go-examples/SKILL.md +690 -0
- package/.agent_agy/skills/go-graceful-shutdown/SKILL.md +708 -0
- package/.agent_agy/skills/go-grpc/SKILL.md +484 -0
- package/.agent_agy/skills/go-http-advanced/SKILL.md +494 -0
- package/.agent_agy/skills/go-observability/SKILL.md +684 -0
- package/.agent_agy/skills/go-testing-advanced/SKILL.md +573 -0
- package/LICENSE +21 -0
- package/README.md +176 -0
- package/cli/install.js +344 -0
- package/package.json +47 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: go-http-advanced
|
|
3
|
+
description: |
|
|
4
|
+
Go HTTP 進階實作:Transport 重用與配置、重試策略與指數退避、Body 重播機制、
|
|
5
|
+
Multipart 上傳、逾時控制、HTTP Client 最佳實務、Context 傳遞。
|
|
6
|
+
|
|
7
|
+
**適用場景**:實作 HTTP Client、設計重試策略、處理 Body 重播、Multipart 檔案上傳、
|
|
8
|
+
配置 Connection Pool、逾時管理、Context 取消、HTTP Middleware。
|
|
9
|
+
|
|
10
|
+
**關鍵字**:http client, http transport, retry, backoff, connection pool, timeout,
|
|
11
|
+
context, multipart upload, body replay, middleware, http.Client, httpdo
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Go HTTP 進階實作規範
|
|
15
|
+
|
|
16
|
+
> **相關 Skills**:本規範建議搭配 `go-observability`(Tracing)與 `go-graceful-shutdown`
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Transport 重用與配置
|
|
21
|
+
|
|
22
|
+
### 核心原則
|
|
23
|
+
|
|
24
|
+
- **重用 Transport**:避免每次請求都建立新的 `http.Client`
|
|
25
|
+
- **設定逾時**:Connection、TLS Handshake、Response Header
|
|
26
|
+
- **Connection Pool 管理**:MaxIdleConns、IdleConnTimeout
|
|
27
|
+
|
|
28
|
+
### 標準配置
|
|
29
|
+
|
|
30
|
+
```go
|
|
31
|
+
import (
|
|
32
|
+
"net/http"
|
|
33
|
+
"time"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
// 建立可重用的 HTTP Client
|
|
37
|
+
func NewHTTPClient() *http.Client {
|
|
38
|
+
transport := &http.Transport{
|
|
39
|
+
// Connection Pool 配置
|
|
40
|
+
MaxIdleConns: 100, // 總最大空閒連線數
|
|
41
|
+
MaxIdleConnsPerHost: 10, // 每個 Host 最大空閒連線數
|
|
42
|
+
IdleConnTimeout: 90 * time.Second, // 空閒連線保持時間
|
|
43
|
+
|
|
44
|
+
// 連線逾時
|
|
45
|
+
DialContext: (&net.Dialer{
|
|
46
|
+
Timeout: 5 * time.Second, // TCP 連線逾時
|
|
47
|
+
KeepAlive: 30 * time.Second, // Keep-Alive 探測間隔
|
|
48
|
+
}).DialContext,
|
|
49
|
+
|
|
50
|
+
// TLS 配置
|
|
51
|
+
TLSHandshakeTimeout: 10 * time.Second,
|
|
52
|
+
ExpectContinueTimeout: 1 * time.Second,
|
|
53
|
+
|
|
54
|
+
// HTTP/2 支援
|
|
55
|
+
ForceAttemptHTTP2: true,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return &http.Client{
|
|
59
|
+
Transport: transport,
|
|
60
|
+
Timeout: 30 * time.Second, // 全域請求逾時(包含讀取 Body)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Client 封裝範例
|
|
66
|
+
|
|
67
|
+
```go
|
|
68
|
+
type APIClient struct {
|
|
69
|
+
baseURL string
|
|
70
|
+
httpClient *http.Client
|
|
71
|
+
headers map[string]string // 預設 Headers
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func NewAPIClient(baseURL string, httpClient *http.Client) *APIClient {
|
|
75
|
+
if httpClient == nil {
|
|
76
|
+
httpClient = NewHTTPClient()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return &APIClient{
|
|
80
|
+
baseURL: strings.TrimSuffix(baseURL, "/"),
|
|
81
|
+
httpClient: httpClient,
|
|
82
|
+
headers: map[string]string{
|
|
83
|
+
"User-Agent": "myapp/1.0",
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func (c *APIClient) Get(ctx context.Context, path string) (*http.Response, error) {
|
|
89
|
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
|
90
|
+
if err != nil {
|
|
91
|
+
return nil, fmt.Errorf("new request: %w", err)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 加入預設 Headers
|
|
95
|
+
for k, v := range c.headers {
|
|
96
|
+
req.Header.Set(k, v)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
resp, err := c.httpClient.Do(req)
|
|
100
|
+
if err != nil {
|
|
101
|
+
return nil, fmt.Errorf("do request: %w", err)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return resp, nil
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## 重試策略與指數退避
|
|
111
|
+
|
|
112
|
+
### 可重試的錯誤
|
|
113
|
+
|
|
114
|
+
**原則**:
|
|
115
|
+
- **冪等方法**(GET、PUT、DELETE)可重試
|
|
116
|
+
- **非冪等方法**(POST)謹慎重試(需確保冪等性)
|
|
117
|
+
- 對 5xx/網路錯誤重試,對業務 4xx 不重試
|
|
118
|
+
|
|
119
|
+
### 重試判斷邏輯
|
|
120
|
+
|
|
121
|
+
```go
|
|
122
|
+
func isRetryable(err error, resp *http.Response) bool {
|
|
123
|
+
// 網路錯誤:可重試
|
|
124
|
+
if err != nil {
|
|
125
|
+
// Timeout、DNS、Connection refused 等
|
|
126
|
+
return true
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// HTTP 狀態碼判斷
|
|
130
|
+
switch resp.StatusCode {
|
|
131
|
+
case http.StatusTooManyRequests: // 429
|
|
132
|
+
return true
|
|
133
|
+
case http.StatusInternalServerError: // 500
|
|
134
|
+
return true
|
|
135
|
+
case http.StatusBadGateway: // 502
|
|
136
|
+
return true
|
|
137
|
+
case http.StatusServiceUnavailable: // 503
|
|
138
|
+
return true
|
|
139
|
+
case http.StatusGatewayTimeout: // 504
|
|
140
|
+
return true
|
|
141
|
+
default:
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### 指數退避實作
|
|
148
|
+
|
|
149
|
+
```go
|
|
150
|
+
type RetryConfig struct {
|
|
151
|
+
MaxRetries int
|
|
152
|
+
InitialBackoff time.Duration
|
|
153
|
+
MaxBackoff time.Duration
|
|
154
|
+
Multiplier float64
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
func DefaultRetryConfig() RetryConfig {
|
|
158
|
+
return RetryConfig{
|
|
159
|
+
MaxRetries: 3,
|
|
160
|
+
InitialBackoff: 100 * time.Millisecond,
|
|
161
|
+
MaxBackoff: 5 * time.Second,
|
|
162
|
+
Multiplier: 2.0,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func (c *APIClient) DoWithRetry(ctx context.Context, req *http.Request, cfg RetryConfig) (*http.Response, error) {
|
|
167
|
+
backoff := cfg.InitialBackoff
|
|
168
|
+
|
|
169
|
+
for attempt := 0; attempt <= cfg.MaxRetries; attempt++ {
|
|
170
|
+
// Clone Request(因為 Body 只能讀一次)
|
|
171
|
+
reqClone := req.Clone(ctx)
|
|
172
|
+
|
|
173
|
+
resp, err := c.httpClient.Do(reqClone)
|
|
174
|
+
|
|
175
|
+
// 成功或不可重試:直接返回
|
|
176
|
+
if !isRetryable(err, resp) {
|
|
177
|
+
return resp, err
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 已達最大重試次數
|
|
181
|
+
if attempt == cfg.MaxRetries {
|
|
182
|
+
if resp != nil {
|
|
183
|
+
return resp, fmt.Errorf("max retries exceeded, last status: %d", resp.StatusCode)
|
|
184
|
+
}
|
|
185
|
+
return nil, fmt.Errorf("max retries exceeded: %w", err)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 關閉 Response Body(避免連線洩漏)
|
|
189
|
+
if resp != nil {
|
|
190
|
+
io.Copy(io.Discard, resp.Body)
|
|
191
|
+
resp.Body.Close()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Exponential Backoff
|
|
195
|
+
select {
|
|
196
|
+
case <-time.After(backoff):
|
|
197
|
+
backoff = time.Duration(float64(backoff) * cfg.Multiplier)
|
|
198
|
+
if backoff > cfg.MaxBackoff {
|
|
199
|
+
backoff = cfg.MaxBackoff
|
|
200
|
+
}
|
|
201
|
+
case <-ctx.Done():
|
|
202
|
+
return nil, ctx.Err()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return nil, fmt.Errorf("unexpected retry loop exit")
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Body 重播機制
|
|
213
|
+
|
|
214
|
+
### 問題:Body 只能讀一次
|
|
215
|
+
|
|
216
|
+
```go
|
|
217
|
+
// ❌ 錯誤:Body 已被讀取,無法重試
|
|
218
|
+
req, _ := http.NewRequest("POST", url, body)
|
|
219
|
+
resp, err := client.Do(req)
|
|
220
|
+
// 若重試,body 已為空
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### 解決方案:Clone Body
|
|
224
|
+
|
|
225
|
+
```go
|
|
226
|
+
func CloneBody(src []byte) io.ReadCloser {
|
|
227
|
+
buf := bytes.Clone(src)
|
|
228
|
+
return io.NopCloser(bytes.NewReader(buf))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 可重播的 Request
|
|
232
|
+
func NewReplayableRequest(ctx context.Context, method, url string, body []byte) (*http.Request, error) {
|
|
233
|
+
req, err := http.NewRequestWithContext(ctx, method, url, CloneBody(body))
|
|
234
|
+
if err != nil {
|
|
235
|
+
return nil, err
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 設定 GetBody(支持 Redirect 與重試)
|
|
239
|
+
req.GetBody = func() (io.ReadCloser, error) {
|
|
240
|
+
return CloneBody(body), nil
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return req, nil
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Multipart 上傳
|
|
250
|
+
|
|
251
|
+
### 檔案上傳範例
|
|
252
|
+
|
|
253
|
+
```go
|
|
254
|
+
func UploadFile(ctx context.Context, url string, filePath string) error {
|
|
255
|
+
// 1. 建立 Multipart Writer
|
|
256
|
+
body := &bytes.Buffer{}
|
|
257
|
+
writer := multipart.NewWriter(body)
|
|
258
|
+
|
|
259
|
+
// 2. 新增檔案欄位
|
|
260
|
+
file, err := os.Open(filePath)
|
|
261
|
+
if err != nil {
|
|
262
|
+
return fmt.Errorf("open file: %w", err)
|
|
263
|
+
}
|
|
264
|
+
defer file.Close()
|
|
265
|
+
|
|
266
|
+
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
|
|
267
|
+
if err != nil {
|
|
268
|
+
return fmt.Errorf("create form file: %w", err)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if _, err := io.Copy(part, file); err != nil {
|
|
272
|
+
return fmt.Errorf("copy file: %w", err)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 3. 新增其他欄位
|
|
276
|
+
_ = writer.WriteField("description", "my file")
|
|
277
|
+
|
|
278
|
+
// 4. 關閉 Writer(必要)
|
|
279
|
+
if err := writer.Close(); err != nil {
|
|
280
|
+
return fmt.Errorf("close writer: %w", err)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 5. 建立 Request
|
|
284
|
+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
|
|
285
|
+
if err != nil {
|
|
286
|
+
return fmt.Errorf("new request: %w", err)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 設定 Content-Type(包含 boundary)
|
|
290
|
+
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
291
|
+
|
|
292
|
+
// 6. 發送請求
|
|
293
|
+
client := &http.Client{}
|
|
294
|
+
resp, err := client.Do(req)
|
|
295
|
+
if err != nil {
|
|
296
|
+
return fmt.Errorf("do request: %w", err)
|
|
297
|
+
}
|
|
298
|
+
defer resp.Body.Close()
|
|
299
|
+
|
|
300
|
+
if resp.StatusCode != http.StatusOK {
|
|
301
|
+
body, _ := io.ReadAll(resp.Body)
|
|
302
|
+
return fmt.Errorf("upload failed: %d %s", resp.StatusCode, string(body))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return nil
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### 串流上傳(大檔案)
|
|
310
|
+
|
|
311
|
+
```go
|
|
312
|
+
func UploadLargeFile(ctx context.Context, url string, filePath string) error {
|
|
313
|
+
file, err := os.Open(filePath)
|
|
314
|
+
if err != nil {
|
|
315
|
+
return fmt.Errorf("open file: %w", err)
|
|
316
|
+
}
|
|
317
|
+
defer file.Close()
|
|
318
|
+
|
|
319
|
+
// 使用 io.Pipe 避免載入整個檔案到記憶體
|
|
320
|
+
pr, pw := io.Pipe()
|
|
321
|
+
writer := multipart.NewWriter(pw)
|
|
322
|
+
|
|
323
|
+
// Goroutine 寫入 Pipe
|
|
324
|
+
go func() {
|
|
325
|
+
defer pw.Close()
|
|
326
|
+
defer writer.Close()
|
|
327
|
+
|
|
328
|
+
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
|
|
329
|
+
if err != nil {
|
|
330
|
+
pw.CloseWithError(fmt.Errorf("create form file: %w", err))
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if _, err := io.Copy(part, file); err != nil {
|
|
335
|
+
pw.CloseWithError(fmt.Errorf("copy file: %w", err))
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 必須先關閉 writer,再關閉 pw
|
|
340
|
+
if err := writer.Close(); err != nil {
|
|
341
|
+
pw.CloseWithError(err)
|
|
342
|
+
}
|
|
343
|
+
}()
|
|
344
|
+
|
|
345
|
+
// 建立 Request(使用 Pipe Reader)
|
|
346
|
+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, pr)
|
|
347
|
+
if err != nil {
|
|
348
|
+
return fmt.Errorf("new request: %w", err)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
352
|
+
|
|
353
|
+
client := &http.Client{}
|
|
354
|
+
resp, err := client.Do(req)
|
|
355
|
+
if err != nil {
|
|
356
|
+
return fmt.Errorf("do request: %w", err)
|
|
357
|
+
}
|
|
358
|
+
defer resp.Body.Close()
|
|
359
|
+
|
|
360
|
+
if resp.StatusCode != http.StatusOK {
|
|
361
|
+
return fmt.Errorf("upload failed: %d", resp.StatusCode)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return nil
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
## 逾時控制
|
|
371
|
+
|
|
372
|
+
### 多層逾時管理
|
|
373
|
+
|
|
374
|
+
```go
|
|
375
|
+
// 1. Transport 層逾時(Connection、TLS)
|
|
376
|
+
transport := &http.Transport{
|
|
377
|
+
DialContext: (&net.Dialer{
|
|
378
|
+
Timeout: 5 * time.Second, // TCP 連線逾時
|
|
379
|
+
}).DialContext,
|
|
380
|
+
TLSHandshakeTimeout: 10 * time.Second,
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// 2. Client 層逾時(整個請求,包含讀取 Body)
|
|
384
|
+
client := &http.Client{
|
|
385
|
+
Transport: transport,
|
|
386
|
+
Timeout: 30 * time.Second,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 3. Request 層逾時(Context)
|
|
390
|
+
ctx, cancel := context.WithTimeout(context.Background(), 10 * time.Second)
|
|
391
|
+
defer cancel()
|
|
392
|
+
|
|
393
|
+
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
394
|
+
resp, err := client.Do(req)
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### 優先級
|
|
398
|
+
|
|
399
|
+
**Context Timeout(最高優先級)> Client Timeout > Transport Timeout**
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## HTTP Middleware
|
|
404
|
+
|
|
405
|
+
### Middleware 模式
|
|
406
|
+
|
|
407
|
+
```go
|
|
408
|
+
type Middleware func(http.Handler) http.Handler
|
|
409
|
+
|
|
410
|
+
// Logging Middleware
|
|
411
|
+
func LoggingMiddleware(logger *zap.Logger) Middleware {
|
|
412
|
+
return func(next http.Handler) http.Handler {
|
|
413
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
414
|
+
start := time.Now()
|
|
415
|
+
|
|
416
|
+
// Wrap ResponseWriter
|
|
417
|
+
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
|
418
|
+
|
|
419
|
+
next.ServeHTTP(rw, r)
|
|
420
|
+
|
|
421
|
+
logger.Info("http request",
|
|
422
|
+
zap.String("method", r.Method),
|
|
423
|
+
zap.String("path", r.URL.Path),
|
|
424
|
+
zap.Int("status", rw.statusCode),
|
|
425
|
+
zap.Duration("duration", time.Since(start)),
|
|
426
|
+
)
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
type responseWriter struct {
|
|
432
|
+
http.ResponseWriter
|
|
433
|
+
statusCode int
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
func (rw *responseWriter) WriteHeader(code int) {
|
|
437
|
+
rw.statusCode = code
|
|
438
|
+
rw.ResponseWriter.WriteHeader(code)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// 組合 Middleware
|
|
442
|
+
func Chain(h http.Handler, middlewares ...Middleware) http.Handler {
|
|
443
|
+
for i := len(middlewares) - 1; i >= 0; i-- {
|
|
444
|
+
h = middlewares[i](h)
|
|
445
|
+
}
|
|
446
|
+
return h
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// 使用範例
|
|
450
|
+
func main() {
|
|
451
|
+
mux := http.NewServeMux()
|
|
452
|
+
mux.HandleFunc("/", homeHandler)
|
|
453
|
+
|
|
454
|
+
handler := Chain(mux,
|
|
455
|
+
LoggingMiddleware(logger),
|
|
456
|
+
MetricsMiddleware(),
|
|
457
|
+
AuthMiddleware(),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
http.ListenAndServe(":8080", handler)
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
466
|
+
## 檢查清單
|
|
467
|
+
|
|
468
|
+
**HTTP Client**
|
|
469
|
+
- [ ] 重用 `http.Client` 與 `http.Transport`
|
|
470
|
+
- [ ] 配置 Connection Pool(MaxIdleConns、IdleConnTimeout)
|
|
471
|
+
- [ ] 設定 3 層逾時(Transport、Client、Context)
|
|
472
|
+
- [ ] 使用 `req.WithContext(ctx)` 支持取消
|
|
473
|
+
|
|
474
|
+
**重試策略**
|
|
475
|
+
- [ ] 僅對冪等方法重試(GET、PUT、DELETE)
|
|
476
|
+
- [ ] 實作指數退避(Exponential Backoff)
|
|
477
|
+
- [ ] 設定最大重試次數(建議 3 次)
|
|
478
|
+
- [ ] 重試前關閉 Response Body
|
|
479
|
+
|
|
480
|
+
**Body 處理**
|
|
481
|
+
- [ ] 使用 `bytes.Clone` 實作 Body 重播
|
|
482
|
+
- [ ] 設定 `req.GetBody` 支持 Redirect
|
|
483
|
+
- [ ] 嚴格 `defer resp.Body.Close()`
|
|
484
|
+
|
|
485
|
+
**Multipart 上傳**
|
|
486
|
+
- [ ] 大檔案使用 `io.Pipe` 串流上傳
|
|
487
|
+
- [ ] 先關閉 `writer`,再關閉 `pw`
|
|
488
|
+
- [ ] 失敗時使用 `pw.CloseWithError(err)`
|
|
489
|
+
- [ ] 設定正確的 `Content-Type`(包含 boundary)
|
|
490
|
+
|
|
491
|
+
**Middleware**
|
|
492
|
+
- [ ] Middleware 順序:Recovery → Tracing → Logging → Auth
|
|
493
|
+
- [ ] Wrap ResponseWriter 捕獲狀態碼
|
|
494
|
+
- [ ] 使用 Chain 組合 Middleware
|