• English
  • Example: HTTP Client Handle HTTP Client Errors

    An HTTP client request can fail in two different ways:

    • the request fails before an HTTP response is received;
    • the server returns an HTTP response with an error status.

    These cases are handled differently in Go.

    Quick Example

    Check the error from client.Do first, then inspect the HTTP status:

    package main
    
    import (
    	"fmt"
    	"io"
    	"log"
    	"net/http"
    )
    
    var client = &http.Client{}
    
    func main() {
    	if err := run(); err != nil {
    		log.Fatal(err)
    	}
    }
    
    func run() error {
    	req, err := http.NewRequest(
    		http.MethodGet,
    		"https://api.example.com/users/123",
    		nil,
    	)
    	if err != nil {
    		return fmt.Errorf("create request: %w", err)
    	}
    
    	resp, err := client.Do(req)
    	if err != nil {
    		return fmt.Errorf("send request: %w", err)
    	}
    	defer resp.Body.Close()
    
    	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    		body, err := io.ReadAll(resp.Body)
    		if err != nil {
    			return fmt.Errorf("read error response: %w", err)
    		}
    
    		return fmt.Errorf(
    			"server returned %s: %s",
    			resp.Status,
    			body,
    		)
    	}
    
    	// Process the successful response.
    	return nil
    }

    The important distinction is:

    resp, err := client.Do(req)
    if err != nil {
    	// No usable HTTP response was received.
    }
    
    if resp.StatusCode >= 400 {
    	// The server returned an HTTP error response.
    }

    A 404 Not Found or 500 Internal Server Error normally produces a valid *http.Response with err == nil.

    HTTP Status Is Not a Go Error

    This does not automatically return an error:

    resp, err := client.Get(url)

    if the server responds:

    HTTP/1.1 404 Not Found

    You get:

    err == nil
    resp.StatusCode == http.StatusNotFound

    net/http does not treat HTTP status codes such as 400, 404, or 500 as Go errors.

    Your application decides which status codes are acceptable.

    For example:

    if resp.StatusCode != http.StatusOK {
    	return fmt.Errorf("unexpected status: %s", resp.Status)
    }

    For an API that accepts any successful 2xx response, checking the whole 2xx range is usually more appropriate:

    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    	return fmt.Errorf("unexpected status: %s", resp.Status)
    }

    Transport Errors

    client.Do returns an error when the request cannot successfully obtain an HTTP response.

    Common examples include:

    • DNS lookup failure
    • connection refused
    • connection timeout
    • TLS handshake failure
    • request cancellation
    • failure while following a redirect

    For example:

    resp, err := client.Do(req)
    if err != nil {
    	return fmt.Errorf("request failed: %w", err)
    }

    There is no HTTP status code to inspect in this case.

    Keep the original error with %w so callers can inspect it with errors.Is or errors.As.

    Inspect Specific Errors

    Don't parse error strings when the standard library provides a typed error.

    For example, a timeout can be detected with:

    if err != nil {
    	var netErr net.Error
    	if errors.As(err, &netErr) && netErr.Timeout() {
    		// Handle timeout.
    	}
    
    	return err
    }

    A request canceled through its context can be checked with:

    if errors.Is(err, context.Canceled) {
    	// The caller canceled the request.
    }

    A deadline can be checked with:

    if errors.Is(err, context.DeadlineExceeded) {
    	// The request exceeded its deadline.
    }

    This is more reliable than checking whether the error message contains "timeout" or "context canceled".

    Check the Response Before Reading It

    Once client.Do succeeds, always check the response before assuming it contains the data you expect:

    resp, err := client.Do(req)
    if err != nil {
    	return err
    }
    defer resp.Body.Close()
    
    if resp.StatusCode != http.StatusOK {
    	return fmt.Errorf("unexpected status: %s", resp.Status)
    }
    
    body, err := io.ReadAll(resp.Body)
    if err != nil {
    	return fmt.Errorf("read response: %w", err)
    }

    Don't decode a known error response as if it were a successful API response.

    For APIs that return structured error bodies, you may want to decode the error response separately.

    Don't Lose the Response Body

    An HTTP error status still gives you a response body.

    For example, an API might return:

    {
    	"error": "user not found"
    }

    with:

    404 Not Found

    If that information is useful, read it before returning:

    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    	body, err := io.ReadAll(resp.Body)
    	if err != nil {
    		return fmt.Errorf("read error response: %w", err)
    	}
    
    	return fmt.Errorf("HTTP %s: %s", resp.Status, body)
    }

    For untrusted or potentially large responses, don't use an unlimited io.ReadAll. Apply an appropriate size limit.

    Preserve the Original Error

    Wrap errors with context:

    resp, err := client.Do(req)
    if err != nil {
    	return fmt.Errorf("GET %s: %w", req.URL, err)
    }

    The %w keeps the original error available:

    if errors.Is(err, context.DeadlineExceeded) {
    	// Still detectable after wrapping.
    }

    Avoid converting every error into a plain string:

    return fmt.Errorf("request failed: %v", err)

    %v formats the error but does not preserve it for errors.Is and errors.As.

    Use %w when the caller may need to inspect the underlying error.

    Be Careful With Retries

    Not every error should be retried.

    A timeout while contacting a service may be transient. A 400 Bad Request usually indicates that retrying the identical request will not fix the problem.

    Also consider whether the request can safely be repeated.

    A GET is normally easier to retry than a request that creates a resource or triggers an external side effect.

    Retry policy belongs above the basic HTTP error check. This example only determines what happened; it does not automatically retry the request.

    A Useful Error Boundary

    For application code, it is often useful to keep transport failures and HTTP status failures distinguishable:

    type HTTPError struct {
    	StatusCode int
    	Status     string
    }
    
    func (e *HTTPError) Error() string {
    	return e.Status
    }

    Then:

    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    	return &HTTPError{
    		StatusCode: resp.StatusCode,
    		Status:     resp.Status,
    	}
    }

    Callers can inspect it without parsing an error string:

    var httpErr *HTTPError
    if errors.As(err, &httpErr) {
    	fmt.Println(httpErr.StatusCode)
    }

    Whether you need a custom error type depends on the application's API. Don't introduce one just to wrap a single call site.

    Production Notes

    Always distinguish:

    request failed

    from:

    server returned an error status

    The first is represented by a Go error. The second is represented by an HTTP response.

    Close every response body.

    Limit the amount of an error response that you read.

    Wrap errors with %w when callers may need to inspect their causes.

    Don't retry automatically just because client.Do returned an error. Check the error, request semantics, and retry policy first.

    And don't turn every non-2xx response into a generic string too early. The status code and response body often contain information the caller needs.