Intercepting network connection establishment in Golang

Most Golang libraries that establish network connections allow the caller to override or specify the function used for that purpose. For example here is a small golang program that replaces the default behavior of the net/http client with a dialer that has a timeout of 10 seconds specified.

 basic_example_http_transport.go 471 Bytes

package main

import (
	"fmt"
	"net"
	"net/http"
	"os"
	"time"
)

func main() {
	req, err := http.NewRequest("GET", "https://www.hydrogen18.com", nil)
	if err != nil {
		panic(err)
	}
	dialer := &net.Dialer{Timeout: 10 * time.Second}
	transport := &http.Transport{DialContext: dialer.DialContext}
	client := &http.Client{Transport: transport}
	response, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	fmt.Fprintf(os.Stdout, "code: %d\n", response.StatusCode)

}

This program shows the status code returned from my blog's server. If you just want to alter the dialing behavior, it's easy enough to wrap the function passed to the *http.Transport like this

 basic_example_http_transport_wrapped.go 682 Bytes

package main

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"os"
	"time"
)

func main() {
	req, err := http.NewRequest("GET", "https://www.hydrogen18.com", nil)
	if err != nil {
		panic(err)
	}
	dialer := &net.Dialer{Timeout: 10 * time.Second}
	transport := &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
		// this is a wrapped function call
		fmt.Fprintf(os.Stdout, "dialing %q %q\n", network, addr)
		return dialer.DialContext(ctx, network, addr)
	},
	}

	client := &http.Client{Transport: transport}
	response, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	fmt.Fprintf(os.Stdout, "code: %d\n", response.StatusCode)

}

This program just wraps the call to the dialer function and logs the parameters. It produces this output when ran

$ go run basic_example_http_transport_wrapped.go 
dialing "tcp" "www.hydrogen18.com:443"
code: 200

The obvious usage of this is to replace the dialer function in unit tests to avoid actually making network connections. But if we're interested in doing this in a real program, it turns out you can go way beyond just wrapping the .DialContext() function. The *net.Dialer supports a field called ControlContext that gets called before the actual dialing happens. Here's an extended example doing just that

 basic_example_http_transport_wrapped2.go 900 Bytes

package main

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"os"
	"syscall"
	"time"
)

func main() {
	req, err := http.NewRequest("GET", "https://www.hydrogen18.com", nil)
	if err != nil {
		panic(err)
	}
	dialer := &net.Dialer{Timeout: 10 * time.Second,
		ControlContext: func(ctx context.Context, network, addr string, _ syscall.RawConn) error {
			// this function is called as part of the dialing process
			fmt.Fprintf(os.Stdout, "control context %q %q\n", network, addr)
			return nil
		},
	}
	transport := &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
		fmt.Fprintf(os.Stdout, "dialing %q %q\n", network, addr)
		return dialer.DialContext(ctx, network, addr)
	},
	}

	client := &http.Client{Transport: transport}
	response, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	fmt.Fprintf(os.Stdout, "code: %d\n", response.StatusCode)

}

This is the result of running this program

$ go run basic_example_http_transport_wrapped2.go 
dialing "tcp" "www.hydrogen18.com:443"
control context "tcp4" "107.150.44.90:443"
code: 200

This is actually much more powerful than just wrapping the .DialContext() function. In the ControlContext implementation we get exactly what network type is used to connect and the IP address as resolved by DNS. You can even return an error to block the connection if you want to.

One of the interesting quirks about the *http.Client implementation is it will open as many connections as it needs. At a minimum, each new domain can result in a new connection being opened. Here's an updated example showing that when two requests are made to two separate domains using the same *http.Transport

 basic_example_http_transport_wrapped3.go 1.1 kB

package main

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"os"
	"syscall"
	"time"
)

func main() {

	dialer := &net.Dialer{Timeout: 10 * time.Second,
		ControlContext: func(ctx context.Context, network, addr string, _ syscall.RawConn) error {
			fmt.Fprintf(os.Stdout, "control context %q %q\n", network, addr)
			return nil
		},
	}
	transport := &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
		fmt.Fprintf(os.Stdout, "dialing %q %q\n", network, addr)
		return dialer.DialContext(ctx, network, addr)
	},
	}

	client := &http.Client{Transport: transport}
	req, err := http.NewRequest("GET", "https://www.hydrogen18.com", nil)
	if err != nil {
		panic(err)
	}
	response, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	fmt.Fprintf(os.Stdout, "code: %d\n", response.StatusCode)
	req, err = http.NewRequest("GET", "https://www.google.com", nil)
	if err != nil {
		panic(err)
	}
	response, err = client.Do(req)
	if err != nil {
		panic(err)
	}
	fmt.Fprintf(os.Stdout, "code: %d\n", response.StatusCode)

}

This is the result of running this program

$ go run basic_example_http_transport_wrapped3.go 
dialing "tcp" "www.hydrogen18.com:443"
control context "tcp4" "107.150.44.90:443"
code: 200
dialing "tcp" "www.google.com:443"
control context "tcp4" "142.250.115.106:443"
code: 200

We can see that two different connections were established. The internal implementation of *http.Client is quite advanced, it does things like reuse connections and upgrade to HTTP/2.0 as needed. If you're only going to make a single request, you can just wrap the .DialContext() function however you need to. But if you're going to make multiple requests, you might not want the same behavior for each destination. The most direct answer is to do string comparison against the destination. This can work, but you are quickly going to run into a number of different issues with this. For example: what if the specific behavior for each domain changes at runtime? There is a far better way to do this: using the context passed to the *net.Dialer.

The most obvious example of a real world use case for this is tracking how many new connections are established as a result of each request you make using net/http. The implementation of *http.Client does not have a way to do this, but we can implement it ourselves. It is possible to do this using net/http/httptrace but the purpose of this example is to demonstrate a more general mechanism.

 http_transport_tracking.go 2.5 kB

package main

import (
	"context"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"sync"
	"syscall"
	"time"
)

type contextKey int

const resultKey = contextKey(1234)

type resultItem struct {
	connections []string
	dialTime    []time.Duration
	code        int
	url         string
}

func main() {
	dialer := &net.Dialer{Timeout: 10 * time.Second,
		ControlContext: func(ctx context.Context, network, addr string, _ syscall.RawConn) error {
			result := ctx.Value(resultKey).(*resultItem)
			// record the connection attempt
			result.connections = append(result.connections, addr)
			return nil
		},
	}
	transport := &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
		result := ctx.Value(resultKey).(*resultItem)
		now := time.Now()
		conn, err := dialer.DialContext(ctx, network, addr)
		elapsed := time.Since(now)
		if err != nil {
			elapsed = time.Duration(0)
		}
		// record the time spent making the connection
		result.dialTime = append(result.dialTime, elapsed)
		return conn, err
	},
	}

	client := &http.Client{Transport: transport}
	urls := []string{
		"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/booklet_cover.png",
		"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/booklet_ticks.png",
		"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/booklet_unit_721d.png",
		"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/tpwd_public_hunting_interactive_map.png",
		"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/tpwd_public_hunting_interactive_map_detail.png",
	}
	wg := &sync.WaitGroup{}
	results := make(chan resultItem, 1)
	for i, targetUrl := range urls {
		wg.Add(1)

		go func() {

			defer wg.Done()
			if len(os.Getenv("DELAY")) != 0 {
				time.Sleep(time.Second * time.Duration(i))
			}
			result := resultItem{url: targetUrl}
			req, err := http.NewRequest("GET", targetUrl, nil)
			if err != nil {
				panic(err)
			}
			ctx := context.WithValue(context.Background(), resultKey, &result)
			req = req.WithContext(ctx)
			response, err := client.Do(req)
			if err != nil {
				panic(err)
			}
			_, _ = io.Copy(io.Discard, response.Body)
			_ = response.Body.Close()
			result.code = response.StatusCode
			results <- result

		}()
	}

	for range len(urls) {
		result := <-results
		fmt.Fprintf(os.Stdout, "%q code: %d\n", result.url, result.code)
		for i, addr := range result.connections {
			fmt.Fprintf(os.Stdout, "\t%s %v\n", addr, result.dialTime[i])
		}
	}

	wg.Wait()

}

This program can be run in two different modes. The first has no delay between requests

$ go run http_transport_tracking.go 
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/booklet_ticks.png" code: 200
    54.231.135.130:443 83.469039ms
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/booklet_unit_721d.png" code: 200
    54.231.135.130:443 83.941551ms
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/tpwd_public_hunting_interactive_map_detail.png" code: 200
    54.231.135.130:443 83.916883ms
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/tpwd_public_hunting_interactive_map.png" code: 200
    54.231.135.130:443 83.556984ms
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/booklet_cover.png" code: 200
    54.231.135.130:443 83.584018ms

Each request starts a new connection in this mode, with each connection taking around 83 milliseconds to be established. Now if the same program is run to introduce a delay between requests

$ DELAY=1 /usr/lib/go-1.23/bin/go run http_transport_tracking.go 
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/booklet_cover.png" code: 200
    52.216.216.26:443 145.799589ms
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/booklet_ticks.png" code: 200
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/booklet_unit_721d.png" code: 200
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/tpwd_public_hunting_interactive_map.png" code: 200
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/tpwd_public_hunting_interactive_map_detail.png" code: 200
    52.216.56.178:443 49.130942ms

In this case only the first and last requests establish new connections because the underlying *http.Transport reuses connections. Additionally, they wind up making connections to different IP addresses even though they are on the same domain name.

How does this work?

The implementation of this takes advantage of the fact the context of an *http.Request is shared all the way from the call to .Do() on the *http.Client to the underlying *net.Dialer. The primary purpose of a context is to allow a request's lifetime to be limited. But you can attach any piece of information to a context. In the implementation this is used to attach a pointer

ctx := context.WithValue(context.Background(), resultKey, &result)
req = req.WithContext(ctx)

This code starts from the global context.Background() and then creates another context with it as the parent. This new context has a value stored at the key specified by resultKey. The resultKey is just a static value with a unique type to differentiate it from other data that may be in the context. At this key the value &result is stored. To get this context to be used along with the request the function req.WithContext(ctx) is called, which actually returns a pointer to a new request.

The value &result points at a local variable in the goroutine. Since this pointer to a local variable is used when code modifies the underlying data it means those changes are visible in this calling function. The ControlContext and DialContext functions extract this pointer to do just that

dialer := &net.Dialer{Timeout: 10 * time.Second,
    ControlContext: func(ctx context.Context, network, addr string, _ syscall.RawConn) error {
        result := ctx.Value(resultKey).(*resultItem)
        result.connections = append(result.connections, addr)
        return nil
    },
}
transport := &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
    result := ctx.Value(resultKey).(*resultItem)
    now := time.Now()
    conn, err := dialer.DialContext(ctx, network, addr)
    elapsed := time.Since(now)
    if err != nil {
        elapsed = time.Duration(0)
    }
    result.dialTime = append(result.dialTime, elapsed)
    return conn, err
},
}

In each function, the pointer is extracted by specifying the same key and casting the result to a pointer of the correct type. This pointer is used to add data into the arrays within the resultItem type.

The main() function waits on results by reading them from a channel. When a result arrives the result of the request and any connections that were opened are logged. This allows us to get a report about the connection(s) opened by each request. This technique can be applied to any library that supports specifying a DialContext like function. You can also do all sorts of things like selectively deny connections based on whatever criteria you need.

Potential downside: thread safety

The only downside to this approach is that it is not intrinsically thread safe. There is no guarantee that for a single HTTP request exactly only dial request is made at any one time. We can however use Golang's race detector to check if the present usage has any race conditions

$go run -race http_transport_tracking.go
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/booklet_ticks.png" code: 200
    16.15.183.178:443 157.59925ms
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/booklet_unit_721d.png" code: 200
    16.15.183.178:443 158.120927ms
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/tpwd_public_hunting_interactive_map_detail.png" code: 200
    16.15.183.178:443 157.662385ms
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/tpwd_public_hunting_interactive_map.png" code: 200
    16.15.183.178:443 157.649325ms
"https://h18blog.s3.us-east-1.amazonaws.com/blog/parsing_tpwd_pdfs/booklet_cover.png" code: 200
    16.15.183.178:443 157.802113ms

This does not produce any warnings. If you need to guarantee thread safety in your application I suggest using channels, primitives from the sync/atomic package, or just use a mutex.


Copyright Eric Urban 2026, or the respective entity where indicated