Build HTTP services and handle external dependencies
Download a URL to a File in Go: Check HTTP Errors and Avoid Partial Output
Download a URL to a file in Go with status checks, cancellation, a client timeout, and temporary-file cleanup.

To download one HTTP resource, issue a request, check its status, and stream the body with io.Copy. The important ordering is to check the status before creating output and to finish writing a temporary file before moving it to the requested path. A caller should choose that path explicitly; a URL path is not a reliable local filename.
Simple http.Get examples often copy the response body directly into the destination without checking the status, as in this standard-library example. That can leave an HTTP error page where the caller expected a download. io.Copy is still the right building block for the transfer: it streams the body rather than first holding the whole response in memory.
Download into a temporary file
This function accepts both a request context and an HTTP client. Configure a timeout on the client at the call site so a request also has an application-chosen time limit. For example, use &http.Client{Timeout: 30 * time.Second} and pass it to Download; the caller remains responsible for canceling ctx when appropriate.
package download
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
)
func Download(ctx context.Context, client *http.Client, url, dst string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("build download request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request %q: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("download %q: unexpected HTTP status %s", url, resp.Status)
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".download-*")
if err != nil {
return fmt.Errorf("create temporary file for %q: %w", dst, err)
}
defer os.Remove(tmp.Name()) // Also runs after a failed copy, close, or rename.
_, copyErr := io.Copy(tmp, resp.Body)
closeErr := tmp.Close()
if copyErr != nil {
return fmt.Errorf("copy response to %q: %w", dst, copyErr)
}
if closeErr != nil {
return fmt.Errorf("close temporary file for %q: %w", dst, closeErr)
}
if err := os.Rename(tmp.Name(), dst); err != nil {
return fmt.Errorf("move download to %q: %w", dst, err)
}
return nil
}
The status check happens before os.CreateTemp, so a non-2xx body is never written as the requested file. The response body is closed on every path after client.Do succeeds. After a temporary file is created, the deferred removal cleans up its original path if copying, closing, or renaming fails; after a successful rename, that original path no longer names the file.
Creating the temporary file in dst’s directory keeps the staging path near the destination. This is not a promise of atomic replacement or identical rename behavior on every platform, particularly when dst already exists. Decide whether an existing destination may be replaced before adopting this function; retries and validation of the downloaded content are separate application policies. A completed copy establishes that the response body was written without a reported copy error, not that the remote content is the content you intended.
Check success and failure paths
A local HTTP server makes the expected bytes and status explicit. The test below checks a successful download, an error response, cancellation, and an invalid destination. It also checks that these failures leave no temporary file in the test directory.
package download
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestDownload(t *testing.T) {
want := []byte("known response bytes\n")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/error" {
http.Error(w, "not the file", http.StatusNotFound)
return
}
_, _ = w.Write(want)
}))
defer server.Close()
client := &http.Client{Timeout: 5 * time.Second}
dir := t.TempDir()
dst := filepath.Join(dir, "result.bin")
if err := Download(context.Background(), client, server.URL+"/file", dst); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(dst)
if err != nil || !bytes.Equal(got, want) {
t.Fatalf("read result: bytes %q, error %v", got, err)
}
if err := os.Remove(dst); err != nil {
t.Fatal(err)
}
if err := Download(context.Background(), client, server.URL+"/error", dst); err == nil {
t.Fatal("expected an HTTP status error")
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Fatalf("error response installed a destination: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := Download(ctx, client, server.URL+"/file", dst); err == nil {
t.Fatal("expected a canceled-request error")
}
badDst := filepath.Join(dir, "missing-directory", "result.bin")
if err := Download(context.Background(), client, server.URL+"/file", badDst); err == nil {
t.Fatal("expected a file-creation error")
}
entries, err := os.ReadDir(dir)
if err != nil || len(entries) != 0 {
t.Fatalf("unexpected files after failures: %v, error %v", entries, err)
}
}
The pre-canceled request in this test fails before it can create a temporary file. To exercise cleanup during a transfer, use a test server that flushes an initial chunk and then waits; cancel the context while the response is still open, or give the client a short timeout. Check that Download returns an error and that no .download-* file remains. A failed file creation cannot leave a temporary file either; a write failure after creation is the case that tests the deferred removal.
If the URL comes from an untrusted user, download correctness is only part of the decision. The go-getter documentation warns that fetching user-supplied URLs can expose an application to server-side request forgery and denial of service. Decide which destinations and remote addresses your application permits before accepting such requests.

Comments
No comments yet.