A selector can rotate perfectly and still keep choosing the first proxy. The trick is accidentally creating a new selector for every request, which resets its counter before it has a second choice to make.

Construct the selector once and retain it with the transport. The HTTP proxy list can supply public HTTP candidates for the URL set when the requests permit public intermediaries. Start with an already validated, non-empty list of proxy URLs:

func rotatingProxy(urls []*url.URL) func(*http.Request) (*url.URL, error) {
    var mu sync.Mutex
    next := 0
    return func(*http.Request) (*url.URL, error) {
        mu.Lock()
        selected := urls[next]
        next = (next + 1) % len(urls)
        mu.Unlock()
        return selected, nil
    }
}

The surrounding application must reject an empty list, validate the URLs, and use the normal net/http, net/url, and sync imports. The list should remain immutable while this selector uses it, or be replaced through a separately synchronized design.

Attach the returned function to one retained http.Transport. The mutex protects the selection sequence across concurrent callbacks. It does not require holding a lock while a slow network request completes.

Do not consume a route merely to print it

Calling the selector again for a diagnostic message advances the counter again. That output may describe the next candidate rather than the candidate used by the request you are investigating. Record the selection inside the real callback using a safe endpoint identifier.

A callback invocation is not proof of a successfully delivered request. Redirects or additional transport attempts can complicate a simple “one application call, one pick” assumption. If a workflow must keep one route throughout several operations, choose that route at the workflow boundary instead of using a global rotating callback.

Round-robin also does not promise distinct public exits. It schedules endpoints. The final identity remains a property of the path each endpoint supplies.

Related articles