The network wall
Your Go service works perfectly on your laptop. You push it to a corporate server or a restricted cloud VPC, and suddenly every outbound API call hangs. The network team tells you all traffic must route through an internal proxy. You do not want to hardcode proxy addresses into your binary. You want the service to pick up the network configuration automatically, just like curl or wget do.
How Go routes traffic
Go handles this through the net/http package, but the magic does not live in http.Get. The http.Client is a thin wrapper around an http.Transport. The transport manages the actual network work: DNS lookups, TCP handshakes, TLS negotiation, and connection pooling. When a request needs to go through a proxy, the transport asks a Proxy function for the target URL. If the function returns a valid URL, the transport dials the proxy instead of the destination server and forwards the request.
http.ProxyFromEnvironment is a ready-made function that reads HTTP_PROXY, HTTPS_PROXY, and NO_PROXY from the process environment. It parses the values, applies standard URL matching rules, and returns the correct proxy address for each request. You wire it into the transport, and Go handles the rest.
The transport is the engine. The client is just the steering wheel.
Minimal example
Here is the smallest working configuration. It creates a client that automatically respects proxy environment variables.
package main
import (
"net/http"
)
func main() {
// Transport holds connection pooling and proxy configuration.
// It is safe for concurrent use by multiple goroutines.
t := &http.Transport{
// ProxyFromEnvironment reads HTTP_PROXY, HTTPS_PROXY, and NO_PROXY.
// It returns nil for requests that should bypass the proxy entirely.
Proxy: http.ProxyFromEnvironment,
}
// Client wraps the transport. Reuse this instance across your application.
// Creating a new client per request destroys connection pooling.
client := &http.Client{
Transport: t,
}
// Make a request through the configured proxy.
resp, err := client.Get("https://api.example.com/data")
if err != nil {
panic(err)
}
defer resp.Body.Close()
}
Never create a new client inside a request handler. Build it once, share it everywhere.
What happens under the hood
When you call client.Get, the request travels down to the transport layer. The transport checks the Proxy field. Because you assigned http.ProxyFromEnvironment, Go calls that function with your request object. The function inspects the environment variables. If HTTPS_PROXY is set and the request uses HTTPS, it returns that URL. If the destination matches a pattern in NO_PROXY, it returns nil, which tells the transport to connect directly.
Once the proxy URL is resolved, the transport opens a TCP connection to the proxy server. For HTTPS requests, it sends a CONNECT method to establish a tunnel. The proxy forwards the tunnel to the destination, and the TLS handshake happens between your client and the final server. The response flows back through the same tunnel. The transport caches the connection in its pool so the next request to the same host reuses it instead of dialing again.
Go follows a simple convention here: context.Context always goes as the first parameter in HTTP methods. If you use http.NewRequestWithContext, the context travels through the transport and cancels the dial if the deadline passes. The if err != nil { return err } pattern stays verbose by design. It forces you to acknowledge network failures instead of swallowing them.
Connection reuse is the real performance win. Proxies are just another hop in the pool.
Realistic example
Production services need more than a proxy. They need timeouts, retry logic, and clean error handling. Here is a realistic initialization pattern that combines proxy configuration with sensible defaults.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
// NewProxiedClient returns an HTTP client configured for production use.
// It respects environment proxies and enforces request timeouts.
func NewProxiedClient() *http.Client {
// Transport manages the underlying TCP connections.
// Dial timeouts prevent hanging on unreachable proxy servers.
t := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
}
// Client sets the deadline for the entire request lifecycle.
// This includes DNS, proxy connection, TLS, and server response.
return &http.Client{
Transport: t,
Timeout: 10 * time.Second,
}
}
func main() {
client := NewProxiedClient()
// Context carries cancellation and deadlines.
// Always pass it as the first argument to HTTP methods.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/status", nil)
if err != nil {
panic(err)
}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("request failed: %v\n", err)
return
}
defer resp.Body.Close()
fmt.Printf("status: %s\n", resp.Status)
}
Timeouts belong on the client and the transport. Pick one that matches your failure tolerance.
Pitfalls and runtime errors
Proxy configuration looks simple until the network behaves unexpectedly. The most common mistake is ignoring NO_PROXY syntax. The variable expects a comma-separated list of hostnames, IP addresses, or CIDR blocks. Wildcards like *.example.com work, but spaces break the parser. If you set NO_PROXY="localhost, 127.0.0.1", the space causes the second entry to be ignored, and traffic to 127.0.0.1 routes through the proxy anyway.
Another trap is proxy authentication. http.ProxyFromEnvironment does not handle basic auth credentials automatically. If your proxy requires a username and password, you must embed them in the URL or write a custom proxy function that attaches an Proxy-Authorization header. Without it, the proxy returns a 407 Proxy Authentication Required response. The transport does not retry automatically. You get the 407 back in the response object, and you must handle it yourself.
Forgetting to reuse the client causes connection exhaustion. Each new http.Client creates a new transport with an empty connection pool. Under load, your process opens hundreds of TCP connections to the proxy, hits OS file descriptor limits, and starts failing with too many open files. The compiler will not catch this. You only see it when the service degrades under traffic.
If you accidentally pass a malformed proxy URL in the environment, http.ProxyFromEnvironment returns an error during the first request. The error surfaces as proxy URL parse error in the response. Always validate environment variables at startup if your deployment pipeline allows arbitrary values.
Test your proxy configuration with curl before trusting it in Go. The environment variables behave identically.
When to use what
Use http.ProxyFromEnvironment when your service runs in environments where proxy settings change between development, staging, and production. It keeps configuration out of your code and aligns with standard Unix tooling.
Use a custom Proxy function when you need dynamic routing logic, such as selecting different proxies based on request headers, user roles, or geographic regions. The function signature accepts an *http.Request and returns a *url.URL and an error.
Use a hardcoded proxy URL when you are building a tightly controlled internal tool that always connects through a specific gateway. Hardcoding removes runtime configuration drift and simplifies debugging.
Use no proxy configuration when your service runs in a fully open network or communicates exclusively with localhost and internal Kubernetes services. Extra hops add latency and failure points.
Match the proxy strategy to your deployment reality. Configuration belongs where it changes.