# go-retryablehttp: retries that do not change how you call HTTP

> HashiCorp's retryable client wraps net/http so the API looks almost identical, with two additions that matter: exponential backoff by default, and request body rewinding so a POST can be replayed after a failure.

**hashicorp/go-retryablehttp** — Retryable HTTP client in Go

- Repository: https://github.com/hashicorp/go-retryablehttp
- Stars: 2,352 · Forks: 301
- Language: Go
- License: MPL-2.0
- Published: 2026-10-06 · Updated: 2026-10-06 · Language: en
- Canonical page: https://hysenlabs.com/projects/hashicorp-go-retryablehttp

## A wrapper, deliberately

The design goal is stated in the first paragraph of the README and it is worth restating because it explains every other decision. The `retryablehttp` package provides a familiar HTTP client interface with automatic retries and exponential backoff, and it is a thin wrapper over the standard `net/http` client library that exposes nearly the same public API. The stated consequence is that it is very easy to drop into existing programs.

That is a smaller claim than it sounds, because most retry libraries work by either exposing a completely new client type that shares no surface with `net/http`, or by hiding behind a decorator you configure once and then stop thinking about. This one aims at the middle: same method names, same response type, same error handling, with retry behaviour arriving as configuration rather than as a new mental model.

The repository reflects that narrowness. At the root there are `client.go`, `roundtripper.go`, a `Makefile`, `go.mod` and the usual repository metadata, plus two build-tagged files, `cert_error_go119.go` and `cert_error_go120.go`, that exist to give certificate errors different behaviour across Go versions. There is no `examples/` directory and no release tags in the repository data. What you are adding is one transport and one client type, and everything else stays where it was.

## What triggers a retry

The retry conditions are narrow on purpose, and the README states them plainly. Retries happen mainly when the client returns an error, which covers connection errors and similar failures, or when a 500-range response code is received. The exception is 501, which is not retried. Otherwise the response is returned and left to the caller to interpret.

That last clause is the important design decision. A 404 is not retried because retrying a missing resource only wastes time. A 429 is not in the 500 range, so by the default policy it is returned rather than retried, which means if you are dealing with a service that rate limits you, you need to decide explicitly whether to change that. A 500 is retried because it is usually a transient server-side condition.

The retryable status set is therefore a default, not a law, and the client exposes it as configuration. The same is true of the wait between attempts: exponential backoff is the default because retries that arrive on a fixed schedule are exactly the pattern that turns a partial outage into a full one.

The retry count matters as much as the status set. A client that retries indefinitely turns a slow dependency into a hung request, which in a service mesh is how one unavailable upstream becomes a saturated caller. `RetryMax` is the field that bounds it, and the README's stdlib example sets it to 10.

## The part that is not in net/http: rewinding the body

The README names this as the main difference from `net/http`, and it is the feature that makes retries safe for writes. Requests which take a request body, meaning POST, PUT and similar, can have the body provided in a number of ways, some more or less efficient, that allow the body to be rewound if the initial request fails so that the full request can be attempted again.

Think about what `net/http` gives you. An `io.Reader` is consumed as it is sent. If the first attempt fails halfway through, the reader is now partially drained and there is nothing left to send on the second attempt. Most hand-written retry code deals with this by either buffering the whole body in memory, or by accepting that retries only work for GET requests and logging a warning when someone tries it on a POST.

The `retryablehttp` answer is to accept the body in several forms and normalise it internally, so that the client can reconstruct the reader for each attempt. From an API standpoint this shows up as functions that construct a request from a string, from a byte slice, or from a reader, rather than a single generic entry point. The efficiency comment in the README is the hint: some of those forms cost an allocation and a copy, others do not, and the package lets you pick.

This is the reason to prefer this library over a bare retry loop rather than a nicety. Retrying a write that silently sends an empty body on the second attempt produces duplicate-create bugs and missing records, and those are the class of bug that survives a long time in production before anyone connects it to a transient network fault.

## Two ways in, one way out

The simplest entry point is a package-level GET, which is the entire basic example in the README:

```go
resp, err := retryablehttp.Get("/foo")
if err != nil {
    panic(err)
}
```

The returned object is an `*http.Response`, the same thing you would get from `net/http`. The note that matters is what happens when the request failed one or more times: the call blocks and retries with exponential backoff before returning at all. There is no error returned for the intermediate failures, and no way to observe them through this API. If you need that, you need the client.

The configured client is the second entry point, and it converts cleanly to a standard client, which the README presents as the way to make the package broadly applicable:

```go
retryClient := retryablehttp.NewClient()
retryClient.RetryMax = 10

standardClient := retryClient.StandardClient() // *http.Client
```

That conversion is the highest-leverage line in the whole package. Once you have a `*http.Client`, it drops into anything that already accepts one, including code you did not write and cannot change, and the retry logic keeps working because it travels inside the client's transport. The file layout says the same thing: `client.go` holds the client, `roundtripper.go` holds the transport that carries the retry behaviour, and the two files sit next to each other at the root of the repository.

The cost of that convenience is observability. When the retry logic lives inside a `RoundTripper`, anything wrapping the transport for logging or metrics sees one logical request rather than several attempts, unless it is deliberately placed inside the retry loop.

## Version floors, licensing and dependency weight

The README carries a short compatibility history that answers most of the questions people have when dropping a library into an existing service. Version 0.6.0 and before are compatible with Go prior to 1.12. From 0.6.1 onward, Go 1.12 or later is required. From 0.6.7 onward, Go 1.13 or later is required.

The `go.mod` states the current floor directly, with `go 1.23`, so a recent toolchain is assumed today and those older notes are mostly historical context for anyone on a long-lived release branch.

The dependency list is short enough to review in a minute. The module requires `github.com/hashicorp/go-cleanhttp` at `v0.5.2`, which is HashiCorp's fork of the standard library's clean transport with defaults that avoid reusing connections across unrelated hosts, and `github.com/hashicorp/go-hclog` at `v1.6.3`, which is the logging interface used by several HashiCorp tools. Four indirect dependencies follow, all of them colour and terminal-detection libraries that come along with the logger. That is a much easier dependency conversation than most HTTP clients trigger.

Licensing is MPL-2.0, which is file-level copyleft. For most services that means the library stays MPL and your own code is unaffected, but it is a different licence from MIT or Apache and worth checking against your organisation's policy if that is not already settled.

The repository is not archived and the last push was on 2026-05-11.

## How the project is tested

The `Makefile` is three targets and the test target is the default, which tells you the project's own priority is the retry behaviour rather than anything else:

```makefile
default: test

test:
	go vet ./...
	go test -v -race ./... -coverprofile=coverage.out
```

Two things stand out. The race detector is on for every test run, which is the right call for a package whose entire job is concurrent request and response handling. And coverage is written to `coverage.out`, so a coverage report can be produced without extra flags.

The second target, `updatedeps`, is a three-line dependency bump:

```makefile
updatedeps:
	go get -f -t -u ./...
	go get -f -u ./...
```

It runs a test-and-transitive update pass followed by a direct-dependency update pass, then the default target runs the tests. The presence of `-f` on the first line forces the module graph to be re-resolved even when Go thinks it already knows the answers, which matters for a module whose upstream dependencies are themselves versioned tightly.

There is no `examples/` directory and no tagged release data in the repository, so the README and the package documentation on pkg.go.dev are the reference material. The README itself points there rather than duplicating examples, which is a reasonable choice for a library this small.

## Conclusion

go-retryablehttp is worth reaching for when the service you call returns 5xx responses under load and you want backoff without hand-writing a retry loop, because it keeps the `net/http` shape and adds only configuration fields on top. The dependency list is three modules deep, the whole client is a handful of files at the repository root, and the last push was on 2026-05-11, so adopting it costs almost nothing in review effort. The decision to watch is `StandardClient()`, which hands back a plain `*http.Client` and therefore a `RoundTripper` where the retry logic lives, so log and metrics middleware attached to that transport sees every attempt rather than only the last one.

## FAQ

### What are some examples of retryable HTTP status codes?

go-retryablehttp retries on 500-range responses except 501, and on errors returned by the client such as connection failures. Everything else, including 404 and other non-5xx codes, is returned to the caller unchanged. The retried status set is configuration, so it can be widened.

### What does "retry with backoff" mean?

It means the wait between attempts grows rather than staying fixed. A fixed short interval keeps hammering a struggling service, while exponential backoff spreads attempts out and gives it room to recover. go-retryablehttp applies exponential backoff by default.

### What is the retry mechanism?

The client checks whether the outcome is retryable, waits for the backoff period, then reconstructs and sends the request again up to `RetryMax` attempts. The logic lives in `roundtripper.go` and travels inside the transport returned by `StandardClient()`.

### How does go-retryablehttp rewind a request body on retry?

Because a reader is consumed as it is sent, a plain `net/http` request has nothing left after a mid-body failure. The client accepts bodies in several forms so it can rebuild the reader for each attempt, which is the main difference from `net/http`.

## Sources

- [hashicorp/go-retryablehttp on GitHub](https://github.com/hashicorp/go-retryablehttp)
- [Issues](https://github.com/hashicorp/go-retryablehttp/issues)
- [License: MPL-2.0](https://github.com/hashicorp/go-retryablehttp/blob/main/LICENSE)
- [README](https://github.com/hashicorp/go-retryablehttp/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/hashicorp-go-retryablehttp
