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.

5 min readSume
All posts

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:

From the Go net/http package docs, read 2026-09-29.
ConditionWhat it means for a POST
A network error occurredA 429 or 5xx response is not a network error, so the Transport returns it
The connection was already used successfullyA failure on a fresh connection is returned to you
The request is idempotentSet an Idempotency-Key or X-Idempotency-Key header
No body, or Request.GetBody is definedNewRequestWithContext 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

All Developers posts

Written by Sume