Skip to main content
This example demonstrates a real-world pattern: checking the same URL from multiple countries in parallel, collecting structured results, and handling failures gracefully. It’s designed as a starting point for price monitoring, ad verification, or geo-targeted data collection. The program uses a worker pool where each worker gets its own session and country assignment. Workers retry on failure with exponential backoff, and the entire job can be cancelled with a context timeout.

What this example covers

  • Configurable worker pool with controlled concurrency
  • One session per country (consistent IP per geo)
  • Structured result collection across all workers
  • Exponential backoff retry on failure
  • Context-based timeout and graceful shutdown
  • Clean separation between configuration, execution, and output

Full code

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"math"
	"net/http"
	"net/url"
	"os"
	"sync"
	"time"
)

// Config holds the job configuration.
type Config struct {
	PackageKey     string
	TargetURL      string
	Countries      []string
	MaxConcurrency int
	MaxRetries     int
	Timeout        time.Duration
}

// ProxyResult holds the outcome of a single geo-targeted request.
type ProxyResult struct {
	Country    string        `json:"country"`
	IP         string        `json:"ip,omitempty"`
	ISP        string        `json:"isp,omitempty"`
	City       string        `json:"city,omitempty"`
	StatusCode int           `json:"status_code"`
	BodySize   int           `json:"body_size"`
	Duration   time.Duration `json:"duration"`
	Attempts   int           `json:"attempts"`
	Error      string        `json:"error,omitempty"`
}

// CheckerResponse matches the SOAX checker JSON structure.
type CheckerResponse struct {
	Status bool `json:"status"`
	Data   struct {
		IP          string `json:"ip"`
		CountryCode string `json:"country_code"`
		CountryName string `json:"country_name"`
		Region      string `json:"region"`
		City        string `json:"city"`
		ISP         string `json:"isp"`
		Carrier     string `json:"carrier"`
	} `json:"data"`
}

func main() {
	cfg := Config{
		PackageKey: os.Getenv("SOAX_PACKAGE_KEY"),
		TargetURL:  "https://checker.soax.com/api/ipinfo",
		Countries: []string{
			"us", "gb", "de", "fr", "jp",
			"br", "au", "ca", "in", "kr",
		},
		MaxConcurrency: 5,
		MaxRetries:     3,
		Timeout:        30 * time.Second,
	}

	if cfg.PackageKey == "" {
		fmt.Println("Set SOAX_PACKAGE_KEY environment variable")
		os.Exit(1)
	}

	ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
	defer cancel()

	results := runJob(ctx, cfg)

	output, _ := json.MarshalIndent(results, "", "  ")
	fmt.Println(string(output))

	// Summary
	succeeded := 0
	for _, r := range results {
		if r.Error == "" {
			succeeded++
		}
	}
	fmt.Printf("\n%d/%d countries succeeded\n", succeeded, len(results))
}

// runJob dispatches workers and collects results.
func runJob(ctx context.Context, cfg Config) []ProxyResult {
	jobs := make(chan string, len(cfg.Countries))
	results := make(chan ProxyResult, len(cfg.Countries))

	// Start worker pool
	var wg sync.WaitGroup
	for i := 0; i < cfg.MaxConcurrency; i++ {
		wg.Add(1)
		go func(workerID int) {
			defer wg.Done()
			worker(ctx, workerID, cfg, jobs, results)
		}(i)
	}

	// Send jobs
	for _, country := range cfg.Countries {
		jobs <- country
	}
	close(jobs)

	// Wait for all workers to finish, then close results
	go func() {
		wg.Wait()
		close(results)
	}()

	// Collect results
	var collected []ProxyResult
	for r := range results {
		collected = append(collected, r)
	}

	return collected
}

// worker pulls countries from the jobs channel and fetches each one.
func worker(ctx context.Context, id int, cfg Config, jobs <-chan string, results chan<- ProxyResult) {
	for country := range jobs {
		select {
		case <-ctx.Done():
			results <- ProxyResult{
				Country: country,
				Error:   "cancelled: timeout exceeded",
			}
			return
		default:
			result := fetchWithRetry(ctx, cfg, country)
			results <- result
		}
	}
}

// fetchWithRetry attempts the request up to MaxRetries with exponential backoff.
func fetchWithRetry(ctx context.Context, cfg Config, country string) ProxyResult {
	var lastErr error

	for attempt := 1; attempt <= cfg.MaxRetries; attempt++ {
		result, err := fetchCountry(ctx, cfg, country)
		if err == nil {
			result.Attempts = attempt
			return result
		}

		lastErr = err

		// Don't sleep after the last attempt
		if attempt < cfg.MaxRetries {
			backoff := time.Duration(math.Pow(2, float64(attempt-1))) * 500 * time.Millisecond
			select {
			case <-ctx.Done():
				return ProxyResult{
					Country:  country,
					Attempts: attempt,
					Error:    "cancelled: timeout exceeded",
				}
			case <-time.After(backoff):
				// continue to next attempt
			}
		}
	}

	return ProxyResult{
		Country:  country,
		Attempts: cfg.MaxRetries,
		Error:    lastErr.Error(),
	}
}

// fetchCountry makes a single proxied request for a given country.
func fetchCountry(ctx context.Context, cfg Config, country string) (ProxyResult, error) {
	sessionID := fmt.Sprintf("geo_%s", country)
	proxyStr := fmt.Sprintf("http://country-%s-session-%s:%s@proxy.soax.com:1337", country, sessionID, cfg.PackageKey)

	proxyURL, err := url.Parse(proxyStr)
	if err != nil {
		return ProxyResult{}, fmt.Errorf("invalid proxy URL: %w", err)
	}

	client := &http.Client{
		Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
		Timeout:   10 * time.Second,
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.TargetURL, nil)
	if err != nil {
		return ProxyResult{}, fmt.Errorf("request creation failed: %w", err)
	}

	start := time.Now()
	resp, err := client.Do(req)
	duration := time.Since(start)

	if err != nil {
		return ProxyResult{}, fmt.Errorf("request failed: %w", err)
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return ProxyResult{}, fmt.Errorf("body read failed: %w", err)
	}

	result := ProxyResult{
		Country:    country,
		StatusCode: resp.StatusCode,
		BodySize:   len(body),
		Duration:   duration,
	}

	// Parse checker response to extract IP metadata
	var checker CheckerResponse
	if json.Unmarshal(body, &checker) == nil && checker.Status {
		result.IP = checker.Data.IP
		result.ISP = checker.Data.ISP
		result.City = checker.Data.City
	}

	return result, nil
}

How to run it

Set your package key as an environment variable:
export SOAX_PACKAGE_KEY="abc123"
go run main.go

Example output

[
  {
    "country": "us",
    "ip": "104.28.55.12",
    "isp": "Comcast",
    "city": "Denver",
    "status_code": 200,
    "body_size": 203,
    "duration": 842000000,
    "attempts": 1
  },
  {
    "country": "gb",
    "ip": "86.12.145.78",
    "isp": "BT",
    "city": "Manchester",
    "status_code": 200,
    "body_size": 198,
    "duration": 1103000000,
    "attempts": 1
  },
  {
    "country": "de",
    "ip": "91.64.33.210",
    "isp": "Deutsche Telekom",
    "city": "Berlin",
    "status_code": 200,
    "body_size": 201,
    "duration": 967000000,
    "attempts": 1
  }
]

10/10 countries succeeded

How it works

Worker pool. The MaxConcurrency setting controls how many countries are checked at the same time. With 10 countries and 5 workers, the job runs in two waves. Increase concurrency to run all countries simultaneously, or decrease it to stay within your package’s connection limits. One session per country. Each country gets a session ID (geo_us, geo_gb, etc.). This means the same IP is reused if you run the job multiple times within the session’s 60-second inactivity window. For monitoring jobs that run on a schedule, this gives you consistent IPs across runs. Exponential backoff. If a request fails, the worker waits before retrying: 500ms after the first failure, 1s after the second, 2s after the third. This avoids hammering the proxy during transient issues. Context timeout. The entire job has a 30-second deadline. If any worker is still running when the timeout hits, it gets cancelled and returns a clear error. This prevents hung requests from blocking your pipeline. Structured results. Every result includes the country, exit IP, ISP, response timing, retry count, and any errors. This makes it easy to pipe into a database, alerting system, or dashboard.

Adapting this for your use case

Change the target URL. Replace checker.soax.com/api/ipinfo with the URL you’re monitoring. The result struct will need updating to match your target’s response format. Add more countries. The Countries slice controls which geos are checked. Add or remove country codes as needed. Switch to mobile. Change the proxy string to include network-mob:
proxyStr := fmt.Sprintf("http://network-mob-country-%s-session-%s:%s@proxy.soax.com:1337", country, sessionID, cfg.PackageKey)
Add rotation. If you want a fresh IP on every run instead of reusing sessions, remove the session parameter from the proxy string, or append a timestamp to the session ID to force a new session each time:
sessionID := fmt.Sprintf("geo_%s_%d", country, time.Now().Unix())
Write results to a file. Replace the fmt.Println at the end with a file write for scheduled jobs:
os.WriteFile("results.json", output, 0644)

Next steps

Residential proxies

Full parameter reference for session, rotation, and error handling options.

Go examples

Simpler Go examples for getting started.

Error codes

Understand what errors mean and how to handle them in your code.

Connection debugging

Troubleshoot timeouts, auth failures, and geo mismatches.