• English
  • Example: HTTP Client Cancel a Request

    A request can take longer than the caller is willing to wait.

    In Go, the standard way to stop an outgoing HTTP request is to attach a context.Context to the request and cancel that context when the operation should stop.

    This example is part of the Go HTTP client examples on gobase.net.

    Go version: This article targets modern Go versions (Go 1.25+).

    Quick Example

    Use a request context with a short timeout:

    package main
    
    import (
    	"context"
    	"fmt"
    	"net/http"
    	"net/http/httptest"
    	"time"
    )
    
    func main() {
    	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    		<-r.Context().Done()
    	}))
    	defer server.Close()
    
    	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    	defer cancel()
    
    	req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
    	if err != nil {
    		panic(err)
    	}
    
    	// The request carries its own 100 ms deadline.
    	_, err = http.DefaultClient.Do(req)
    	fmt.Println(err)
    }

    The request is expected to fail with an error similar to:

    Get "http://127.0.0.1:...": context deadline exceeded

    The exact error text is less important than the behavior: the request carries a context, and the context deadline cancels the operation.

    For reusable production code, prefer an explicit http.Client with appropriate timeout and transport settings rather than relying on http.DefaultClient.


    Core Pattern

    Create a context, attach it to the request, and cancel it when the operation is finished or should stop:

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
    	return err
    }
    
    resp, err := client.Do(req)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()

    For an existing request, WithContext returns a shallow copy with the new context; it does not modify the original request:

    req = req.WithContext(ctx)

    For new requests, http.NewRequestWithContext is usually clearer.

    For a timeout relative to the current time, use context.WithTimeout:

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    When the operation must end at a known absolute time, use context.WithDeadline:

    ctx, cancel := context.WithDeadline(context.Background(), deadline)
    defer cancel()

    What Cancellation Actually Does

    For an outgoing client request, the request context controls the lifetime of the operation, including:

    • obtaining a connection
    • sending the request
    • reading response headers
    • reading the response body

    Canceling the context tells net/http to abort the in-flight operation. If a body read is blocked waiting for more data when cancellation takes effect, that read returns an error.

    Cancellation cannot discard bytes that have already been received and buffered. Those bytes may still be returned by a read before the cancellation error becomes visible.

    This distinction matters when reading response bodies:

    buf := make([]byte, 4096)
    
    n, err := resp.Body.Read(buf)
    if n > 0 {
    	// Process the bytes that were already available.
    }
    
    if err != nil {
    	return err
    }

    Do not assume that cancellation means every pending or future read immediately returns without data.


    Cancellation Is an Error Condition

    A canceled request normally returns an error.

    Use errors.Is when you need to distinguish context cancellation from other failures:

    if err != nil {
    	if errors.Is(err, context.Canceled) {
    		return err // Caller-initiated cancellation; do not retry.
    	}
    
    	return fmt.Errorf("do request: %w", err)
    }

    http.Client returns errors wrapped in *url.Error. On modern Go versions, errors.Is can be used to inspect the underlying context cancellation error.

    If you need to determine whether a failure was a timeout, inspect the *url.Error:

    var urlErr *url.Error
    
    if errors.As(err, &urlErr) && urlErr.Timeout() {
    	// The request timed out.
    }

    This is useful when a timeout may come from either a request context or http.Client.Timeout.

    If your function adds context, wrap the error once:

    return fmt.Errorf("do request: %w", err)

    The caller can still use errors.Is and errors.As on the returned error.

    Do not convert cancellation into nil unless the surrounding API explicitly defines cancellation as a successful outcome.


    Request Context vs Client.Timeout

    Both mechanisms can terminate an HTTP request, but they serve different purposes.

    MechanismBest used for
    Request contextPer-operation cancellation and deadlines
    http.Client.TimeoutA client-wide wall-clock deadline for the entire operation

    Client.Timeout includes connection establishment, redirects, response headers, and response body reading. The timer remains active after Do returns and can interrupt later body reads.

    For example:

    client := &http.Client{
    	Timeout: 30 * time.Second,
    }

    Then add a shorter operation-specific timeout:

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
    	return err
    }
    
    resp, err := client.Do(req)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()

    These are independent limits. Whichever expires first terminates the request.

    A useful production model is:

    http.Client.Timeout
            │
            │  overall safety limit
            ▼
    request context
            │
            │  operation-specific deadline
            ▼
    HTTP request + response body

    Cancel While Reading the Response Body

    A request may succeed and return a response, while reading its body later takes much longer than expected.

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
    	return err
    }
    
    resp, err := client.Do(req)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()
    
    _, err = io.Copy(dst, resp.Body)
    if err != nil {
    	return err
    }

    If the body read is still in progress when the context is canceled, the read is interrupted and returns an error.

    This is one reason a request-scoped context is preferable to treating HTTP cancellation as merely a connection-establishment concern.


    Cancel From Another Goroutine

    Sometimes cancellation comes from an external event: a user action, shutdown signal, another subsystem, or a parent operation.

    A channel is a simple way to represent such an event:

    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    
    stop := make(chan struct{})
    defer close(stop)
    
    go func() {
    	<-stop
    	cancel()
    }()
    
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
    	return err
    }
    
    resp, err := client.Do(req)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()

    The important part is that the goroutine has a defined lifecycle. A cancellation goroutine that waits forever after the request completes leaks.

    In larger systems, prefer propagating an existing parent context rather than creating an independent cancellation channel for every request.


    Cancel a Request From a Parent Context

    The most useful pattern is usually not to create cancellation from scratch.

    Pass the caller's context through:

    func fetch(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
    	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    	if err != nil {
    		return nil, err
    	}
    
    	return client.Do(req)
    }

    The caller controls the lifetime:

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    resp, err := fetch(ctx, client, url)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()

    This makes cancellation composable.

    A service handler can pass its request context to a downstream HTTP call. A worker can pass the job context. A shutdown context can propagate through all active operations.


    Don't Use Transport.CancelRequest

    Older Go code may contain:

    transport.CancelRequest(req)

    Do not use this pattern in new code.

    Transport.CancelRequest is deprecated. Request contexts are the modern cancellation mechanism, and they also work with HTTP/2.

    Use:

    req = req.WithContext(ctx)

    or:

    req, err := http.NewRequestWithContext(ctx, method, url, body)

    Then cancel the context.


    Common Mistakes

    Canceling immediately by accident

    This cancels the request before it starts:

    ctx, cancel := context.WithCancel(context.Background())
    cancel()
    
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
    	return err
    }
    
    _, err = client.Do(req)

    Call cancel() when the operation should stop, not immediately after creating the context.


    Forgetting defer cancel

    Even when a timeout will eventually expire, call the returned cancellation function:

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    Calling cancel releases resources associated with the derived context.


    Using Client.Timeout as only a connection timeout

    Client.Timeout is not merely a connection timeout: it covers the entire HTTP operation, including response body reading.

    client := &http.Client{
    	Timeout: 5 * time.Second,
    }

    If you need a different deadline for one operation, use its request context.


    Swallowing cancellation

    Avoid:

    if errors.Is(err, context.Canceled) {
    	return nil
    }

    Unless cancellation is explicitly part of your API's successful outcome, callers need to know that the operation did not complete.


    Assuming cancellation discards already-received data

    Cancellation does not erase bytes already buffered by the HTTP stack.

    If a read returns both data and an error, process the data before handling the error.


    Adding a timeout but continuing after it expires

    A context timeout is useful only if the operation actually observes the context.

    For HTTP requests, attach the context to the request:

    req = req.WithContext(ctx)

    Do not merely create a context and then use an unrelated request.


    Production Example

    A small helper can make cancellation and response handling explicit:

    package httpclient
    
    import (
    	"context"
    	"fmt"
    	"io"
    	"net/http"
    )
    
    const maxResponseSize = 1 << 20 // 1 MiB
    
    func Get(ctx context.Context, client *http.Client, url string) ([]byte, error) {
    	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    	if err != nil {
    		return nil, fmt.Errorf("create request: %w", err)
    	}
    
    	resp, err := client.Do(req)
    	if err != nil {
    		return nil, fmt.Errorf("do request: %w", err)
    	}
    	defer resp.Body.Close()
    
    	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    		// Drain a small amount to help preserve connection reuse.
    		_, _ = io.CopyN(io.Discard, resp.Body, 4<<10)
    
    		return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status)
    	}
    
    	// -1 means that the response length is unknown.
    	if resp.ContentLength > maxResponseSize {
    		return nil, fmt.Errorf("response too large: %d bytes", resp.ContentLength)
    	}
    
    	r := io.LimitReader(resp.Body, maxResponseSize+1)
    
    	body, err := io.ReadAll(r)
    	if err != nil {
    		return nil, fmt.Errorf("read response body: %w", err)
    	}
    
    	if len(body) > maxResponseSize {
    		return nil, fmt.Errorf("response exceeds %d bytes", maxResponseSize)
    	}
    
    	return body, nil
    }

    The caller owns the operation deadline:

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    body, err := Get(ctx, client, "https://example.com/data")
    if err != nil {
    	return err
    }

    The helper does not create its own unrelated context. That keeps cancellation under the caller's control.


    Cancellation and Resource Cleanup

    Cancellation does not replace normal cleanup.

    If Do succeeds, the caller still owns the response body:

    resp, err := client.Do(req)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()

    If cancellation interrupts a body read, the deferred Close still runs.

    Calling Close more than once on an http.Response.Body will not panic, though a subsequent call may return an error. The better rule is to keep one clear cleanup path and make ownership obvious.

    Do not rely on cancellation as a substitute for closing files, response bodies, pipes, or other resources.


    Cancellation Is Not a Retry Policy

    A canceled request tells you that the operation stopped. It does not tell you whether retrying is safe.

    For example:

    request
       │
       ├── context canceled
       │
       └── operation stopped

    A retry requires a separate decision:

    Should this operation be retried?
            │
            ├── Is the failure temporary?
            │
            ├── Has the server possibly processed it?
            │
            ├── Is the operation idempotent?
            │
            └── Is there enough deadline remaining?

    For side-effecting requests, cancellation can leave the caller uncertain about whether the server received and processed the request.

    Do not automatically retry simply because the client received a cancellation error.


    Rule of Thumb

    SituationPreferred approach
    One request needs a deadlinecontext.WithTimeout
    Operation must end at a known timecontext.WithDeadline
    Caller decides when work stopscontext.WithCancel
    Every request needs a hard client-wide limithttp.Client.Timeout
    Need to cancel while reading the response bodyRequest context
    Need to classify timeout errorserrors.As + url.Error.Timeout()
    Need to detect context cancellationerrors.Is(err, context.Canceled)
    Need cancellation across nested operationsPropagate the parent context.Context
    Legacy code uses Transport.CancelRequestReplace it with request context

    Key Takeaways

    • Attach a context.Context to every HTTP operation whose lifetime matters.
    • Use WithTimeout for relative deadlines and WithDeadline for absolute deadlines.
    • Client.Timeout is a whole-operation safety limit, not merely a connection timeout.
    • Cancellation applies to response body reads as well as Do.
    • Process data returned with an error before handling the error.
    • Use errors.Is for context.Canceled and errors.As with url.Error.Timeout() when classifying timeout failures.
    • Cancellation does not replace resource cleanup.
    • Do not use Transport.CancelRequest in new code.
    • Cancellation and retry policy are separate decisions.

    For more production-oriented Go examples, see gobase.net.