Golang HTTP client retry: what net/http retries on POST
Go's http.Transport retries only network errors on reused connections, and a POST only with an Idempotency-Key header. Write the 429 and 5xx loop yourself.

Go's http.Client does not retry failed responses. Its Transport retries a request only after a network error on a connection that was already used successfully, and only if the request is idempotent: GET, HEAD, OPTIONS or TRACE, or any method whose headers carry "Idempotency-Key" or "X-Idempotency-Key", with a body it can replay. A 429 or a 5xx comes back to your code, so you write the retry loop yourself or use a wrapper such as hashicorp's go-retryablehttp.
The Go facts come from the net/http and context package docs and the go-retryablehttp README and package docs. The paid-API example is Sume's video API, from Errors and rate limits, Video Generation and Jobs and results. All were read on 2026-09-29.
When does Go's Transport retry a POST?
All of these must hold. The last two are in your control:
| Condition | What it means for a POST |
|---|---|
| A network error occurred | A 429 or 5xx response is not a network error, so the Transport returns it |
| The connection was already used successfully | A failure on a fresh connection is returned to you |
| The request is idempotent | Set an Idempotency-Key or X-Idempotency-Key header |
No body, or Request.GetBody is defined | NewRequestWithContext fills GetBody for *bytes.Buffer, *bytes.Reader and *strings.Reader |
How do I write a retry loop around http.Client?
Loop, build a fresh request each attempt (a body reader is spent after one send), keep the same key, and honor Retry-After. This version creates a Sume video job and retries only transport errors and 429 rate_limited until the context you pass expires, so call it with a context.WithTimeout context:
var client = &http.Client{Timeout: 30 * time.Second}
func createVideo(ctx context.Context, body []byte, key string) (*http.Response, error) {
for attempt := 0; ; attempt++ { // ends when ctx expires
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.sume.com/v1/videos", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("SUME_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key) // same key, same body, every attempt
wait := time.Duration(1<<attempt) * time.Second
resp, err := client.Do(req)
if err == nil && resp.StatusCode != http.StatusTooManyRequests {
return resp, nil // 202, or an error to read and stop on
}
if err == nil {
var e struct{ Error struct{ Code string } }
json.NewDecoder(resp.Body).Decode(&e)
resp.Body.Close()
if e.Error.Code != "rate_limited" {
return nil, fmt.Errorf("429 %s", e.Error.Code) // queue_full: stop
}
if s, perr := strconv.Atoi(resp.Header.Get("Retry-After")); perr == nil {
wait = time.Duration(s) * time.Second
}
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
}Should I use go-retryablehttp?
It saves the loop. Its README says it retries when the client returns an error, such as a connection error, or when a 500-range code other than 501 comes back, with exponential backoff, and that it can rewind a POST body so the full request is sent again. StandardClient() turns its client into a plain *http.Client. The README doesn't list 429, but the package docs say DefaultBackoff reads Retry-After on a 429, and the CheckRetry field lets you set your own retry policy in place of DefaultRetryPolicy.
For a paid create, a blanket 5xx retry needs care. Sume's docs say a 503 provider_not_configured should not be retried aggressively, and 429 queue_full means no new paid job until an existing one finishes or is canceled. Write a CheckRetry that stops on those codes, and whatever retries, set the Idempotency-Key header on the request so every attempt carries it.
How do I stop a retry from paying twice?
Send an Idempotency-Key built from the thing being made, such as an order id, and keep the body byte for byte. On /v1/videos, a replay with the key returns the original job, and Sume's docs say not to retry unsafe submit requests without one. Reuse a key only for the same operation and payload.
Bound the whole loop with context.WithTimeout, and call cancel when done, as the context docs say. For an outgoing request, the context controls its entire lifetime. A client-side timeout doesn't cancel a job that was created: it keeps running and still bills. Once you hold a job id, poll GET /v1/videos/{id} rather than submitting again; if you never got one, resend later with the same key. Golang HTTP POST JSON with headers covers the request itself.
Sources
Related posts
More in Developers
- HeyGen API avatar ID and voice ID: where to find them
In HeyGen's v3 API, avatar_id is a look id from GET /v3/avatars/looks, and voice_id comes from GET /v3/voices or the look's default voice.
- HeyGen API key: get one and make your first video
Generate a HeyGen API key in its API dashboard, send it as X-Api-Key to api.heygen.com, check it with GET /v3/users/me, then create a video.
- HeyGen API v2 retirement: the date and the v3 endpoints
HeyGen's API v1 and v2 stay operational until October 31, 2026 and retire on November 1. The warning headers, and the v3 call for each v2 call.
- How does the Higgsfield API work? Requests, limits, billing
Higgsfield's API is asynchronous: submit to a model endpoint, keep the request_id, poll or take a webhook, download. Billing and limits explained.
Written by Sume