Example: HTTP Client Read a Response Safely
Reading an HTTP response looks simple:
The problem is not reading the bytes. The problem is deciding how many bytes to accept, how long to wait, what to do with errors, and who owns the response body.
This example focuses on safe response handling for production Go clients: bounded memory, status-aware handling, streaming large downloads, compressed responses, and correct body lifecycle management.
The patterns here work with Go 1.16 and later. The examples were verified with Go 1.27.1.
Quick Example
For a small API response with a known practical size limit:
The important ordering is:
A successful http.Client.Do gives you a response body that must be closed. HTTP status is a separate concern from transport success: a 500 response is still a successfully received HTTP response.
The example uses panic only to keep the executable example short. Application and library code should normally return errors.
resp.Body Is a Stream
resp.Body implements io.ReadCloser.
It is normally a one-shot stream:
After that, the bytes have been consumed.
A second read does not return the same data:
If multiple parts of your program need the response, read it once into a bounded buffer and pass the resulting bytes around.
Do not assume that resp.Body can be rewound.
Always Close the Body
Once Do returns a non-nil response, establish the close lifecycle immediately:
This remains correct when later processing returns an error:
The deferred Close still runs.
Closing the body is a resource-lifecycle operation. It is separate from whether the response is a 2xx, whether the body is valid JSON, or whether the body passed your size check.
When practical, consuming the body to EOF can also allow the underlying connection to be reused. If you reject a response immediately because its declared size is too large, you may intentionally sacrifice connection reuse.
io.ReadAll Is Fine When the Limit Is Real
This is reasonable:
The important part is the limit.
This is dangerous for an untrusted or otherwise unbounded response:
A server can return a much larger body than expected. io.ReadAll will keep growing the byte slice until EOF or an error.
The +1 is intentional:
If you read only maxSize bytes, you cannot distinguish:
from:
Reading one extra byte makes the boundary observable.
ContentLength Is an Early Check, Not a Size Limit
If the server declares a useful Content-Length:
This can reject an obviously oversized response before reading it.
But it is not a replacement for a bounded reader.
ContentLength may be unknown:
Chunked responses, streaming responses, and other cases can have no known length.
Therefore the robust pattern is:
The actual stream still needs a limit.
Do Not Treat ContentLength as the Decompressed Size
Compression makes this distinction particularly important.
With transparent gzip handling by net/http, the data flow is approximately:
When the transport transparently decompresses gzip content, resp.Uncompressed is true and resp.ContentLength is set to -1.
That means an early ContentLength check cannot tell you how large the decompressed response will become.
The hard limit must therefore apply to the stream you actually consume.
For example:
This limits the decompressed data exposed through resp.Body.
Manual gzip handling
If you explicitly handle gzip yourself, there are two different sizes:
For example:
A limit on compressed bytes alone does not impose the same limit on decompressed bytes.
Hard Limits and Memory Allocation
A bounded io.ReadAll prevents unbounded growth, but the peak allocation can still be larger than the logical response limit because slices grow geometrically.
For example:
with a 1 MiB limit is usually perfectly reasonable for a small API response.
If the allowed size is hundreds of megabytes, however, io.ReadAll is usually the wrong design. Stream the data instead.
You may also see this optimization:
Pre-allocation can reduce reallocations, but it is only appropriate when the maximum size is small and practical to allocate up front.
Do not blindly preallocate hundreds of megabytes for untrusted or highly concurrent requests. For example, 100 concurrent requests with a 500 MiB preallocation can create enormous memory pressure even when the actual responses are tiny.
A size limit protects you from unbounded input. It does not automatically make an enormous preallocation safe.
Size Does Not Bound Time
A response can be small but extremely slow.
For example:
A size limit controls memory and input volume.
It does not impose a time limit.
Use a request context for request-specific cancellation:
A client-level timeout is another option:
Client.Timeout covers the overall request lifecycle, including connection establishment, redirects, response headers, and body reading.
A request context is preferable when the timeout or cancellation belongs to one particular operation.
For more HTTP client patterns, see the HTTP examples on gobase.net.
Streaming Large Downloads
Do not load a large file into memory just because it arrived through HTTP.
Stream it directly to disk.
A safe download also needs protection against oversized responses.
The important part is:
This means:
If the response ends normally before the limit, io.Copy returns successfully.
If more than maxSize bytes are available, the limited reader allows one extra byte through. written > maxSize then detects the oversized response.
This is also a useful distinction from io.ReadFull: io.CopyN and io.ReadFull have different EOF semantics. Here, io.Copy plus io.LimitReader makes the intended “copy until EOF, but never expose more than N bytes” behavior explicit.
Why a temporary file?
This is unsafe for a production download:
os.Create truncates an existing destination immediately.
If the network fails halfway through, the original file has already been destroyed.
The temporary-file pattern provides:
The temporary file should normally be created in the same directory as the destination so that the final rename has the appropriate filesystem semantics.
os.Rename replacement and atomicity details vary by platform and filesystem. If durability across sudden power loss matters, syncing the file is only part of the durability story; directory synchronization and filesystem-specific behavior may also matter.
Bounded Draining
Sometimes you receive an HTTP error response and want to consume a small amount of the body before returning:
This can help connection reuse for small responses.
But it is not guaranteed to preserve reuse.
If the remaining response body is larger than the drain budget, the body will not reach EOF. The transport may then discard the connection.
So think of bounded draining as:
not:
Also, if the response is intentionally rejected because its declared size is far too large, do not spend significant time reading it merely to preserve connection reuse.
Error Bodies Need Their Own Limit
Error responses deserve a separate limit.
A 500 response might contain:
- stack traces
- internal paths
- database errors
- credentials accidentally included by an upstream service
- user-supplied data
Do not blindly log the entire response body.
A useful pattern is:
The truncated flag matters.
A truncated JSON response, for example, is not necessarily valid JSON and should not be presented as if it were the complete server response.
Status and Body Are Separate
Transport success does not mean application success.
This:
answers:
Did the HTTP exchange produce a response?
It does not answer:
Did the server accept my operation?
You normally need both:
Likewise, a 200 response does not guarantee valid JSON, valid UTF-8, a correct schema, or an acceptable payload size.
HTTP status, body size, body format, and application semantics are separate validation boundaries.
JSON Responses
For small JSON responses, a bounded reader can be passed to json.Decoder:
But there is an important subtlety.
Decoder.Decode can stop after decoding the first complete JSON value. Therefore, decoding one value does not by itself prove that the entire response stayed within your intended size boundary.
If the contract requires the entire response to contain exactly one JSON value and remain within the size limit, you need to account for the remaining input as well.
For example:
For APIs where the complete bounded response must be retained anyway, another simple option is:
The trade-off is memory: retaining raw bytes while also holding the decoded object can increase peak memory substantially.
resp.Body == nil
For responses returned by the standard HTTP client, Response.Body is normally non-nil.
A defensive helper may still protect itself against a manually constructed response:
This is mainly useful when testing or handling hand-built http.Response values.
Do not treat it as normal behavior from http.Client.Do.
A Reusable Helper
If many callers need the same response-size policy, centralize it:
The helper deliberately does not interpret the HTTP status code.
The caller decides whether the response is successful:
In real code, status handling should normally happen before or alongside body-policy decisions so that error responses can use a smaller error-body limit when appropriate.
The important contract is:
ReadResponseBodylimits bytes. It does not define application-level success.
Common Mistakes
Reading an untrusted response without a limit
Use a bounded reader.
Checking only ContentLength
ContentLength == -1 means the length is unknown.
Use a bounded reader as the actual enforcement mechanism.
Assuming a size limit is a timeout
This limits bytes, not time.
Use request context or Client.Timeout for time bounds.
Reading the body and then trying to decode it again
The second operation sees an already-consumed stream.
Decode directly, or decode from the saved body.
Logging the complete error body
Error bodies may contain sensitive information and may be unexpectedly large.
Bound and sanitize them.
Writing directly to the final download path
A failed download can destroy the existing file.
Write to a temporary file and rename only after successful completion.
Preallocating an enormous buffer
Do not turn a logical size limit into a large upfront memory allocation, especially for untrusted and concurrent requests.
Forgetting that compression changes what you measure
A compressed response can be small on the wire and much larger after decompression.
Apply the relevant limit to the stream whose size you actually need to control.
Rule of Thumb
Key Takeaways
resp.Bodyis a one-shot stream and must be closed.- HTTP transport success and HTTP application success are different checks.
ContentLengthis an early hint, not a complete size-enforcement mechanism.io.LimitReader(maxSize+1)lets you detect responses larger than the allowed limit.- A size limit controls memory/input volume; it does not control how long a request can take.
- Transparent gzip means
resp.Bodycan contain decompressed data even when the wire representation was much smaller. - Large downloads should be streamed rather than buffered in memory.
- Download to a temporary file and publish it only after the transfer succeeds.
- Error bodies should have their own smaller limit and should not be logged blindly.
- Avoid large upfront
bytes.Buffer.Growallocations for untrusted or highly concurrent input. - Keep size, time, status, parsing, and ownership as separate boundaries.