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.

By Niko Minadze5 min read

Editorial illustration for Download a URL to a File in Go: Check HTTP Errors and Avoid Partial Output

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.

Leave a comment

Your comment appears after approval. Plain text only; links stay as text. Line breaks and indentation are kept.

Up to 5,000 characters. No account or email needed.

All articles

Type at least two characters.

Press <kbd>Esc</kbd> to closeOpen the search page