پروکسی HTTP در Golang: استفاده از پروکسی با ⁦net/http⁩

تاریخ انتشار:

16 دقیقه مطالعه

Acar Diveroli
نویسنده: Acar Diveroli
سه ایستگاه روی یک مسیر مدرج: کلاینت ⁦net/http⁩، ایستگاه بزرگ آبی پروکسی با نماد تونل و مقصدی که ⁦200 OK⁩ پاسخ می‌دهد

سرویس Go شما باید درخواست‌هایش را از طریق پروکسی بفرستد: یک نقطه خروج در کشوری دیگر، آدرس ثابتی که شریک تجاری‌تان در لیست سفید گذاشته است، یا استخری از آدرس‌ها تا یک آدرس همه درخواست‌ها را حمل نکند. Go برای این کار به بسته اضافه‌ای نیاز ندارد؛ پشتیبانی از پروکسی، از جمله SOCKS5 و متغیرهای محیطی استاندارد، در خود net/http ساخته شده است. غافلگیری‌ها در پیش‌فرض‌ها پنهان‌اند: http.Get همین حالا هم HTTPS_PROXY را می‌خواند، localhost هرگز از پروکسی نمی‌گذرد، متغیرها در هر فرایند فقط یک بار خوانده می‌شوند و یک اتصال ⁦HTTP/2⁩ می‌تواند تابع پروکسی‌ای را که برای چرخش نوشته‌اید بی‌صدا نادیده بگیرد.

در این راهنما http.ProxyURL، ProxyFromEnvironment و قواعد NO_PROXY، هدر Proxy-Authorization، هر دو شکل SOCKS5 در Go، پروکسی متفاوت برای هر درخواست، مهلت‌ها (timeout)، تلاش دوباره، متن دقیق خطاها و یک برنامه دریافت کامل را مرور می‌کنیم. همه نمونه‌ها روی ⁦Go 1.26.6⁩ (با ⁦golang.org/x/net v0.62.0⁩) در برابر یک پروکسی HTTP محلی که user:pass می‌خواست و یک سرور SOCKS5 محلی اجرا شدند؛ خروجی‌ها و سطرهای لاگ واقعی‌اند و فقط میزبان پروکسی با pr.proxynet.io:8000 جایگزین شده است. سمت Node.js همین کار را در استفاده از پروکسی در Node.js آورده‌ایم.

⁦net/http⁩ چگونه تصمیم می‌گیرد از کدام پروکسی استفاده کند؟

هر درخواست از یک http.Transport می‌گذرد که فیلد Proxy آن تابعی است که درخواست را می‌گیرد و آدرس پروکسی، یا برای اتصال مستقیم nil، را برمی‌گرداند. طرح (scheme) آدرس نوع پروکسی را تعیین می‌کند: مستندات Transport در net/http طرح‌های http، https، socks5 و socks5h را فهرست می‌کند، طرح خالی به معنای http است و اطلاعات ورود درون آدرس به هدر Proxy-Authorization تبدیل می‌شود. http.DefaultTransport که پشت http.Get قرار دارد، Proxy را روی http.ProxyFromEnvironment می‌گذارد؛ Transport که خودتان می‌سازید تا وقتی چیزی تنظیم نکنید از هیچ پروکسی‌ای استفاده نمی‌کند.

سپس، بسته به مقصد:

  1. Transport به پروکسی وصل می‌شود، نه به مقصد؛ اگر پورت نوشته نشده باشد، برای http پورت 80، برای https پورت 443 و برای SOCKS پورت 1080 به کار می‌رود.
  2. مقصد ساده http://. درخواست با URL کامل در سطر درخواست به پروکسی می‌رود و Proxy-Authorization همراه هر درخواست فرستاده می‌شود.
  3. مقصد https://. Go یک بار CONNECT host:443 را همراه این هدر می‌فرستد؛ پس از پاسخ 200 پروکسی، دست‌دادن TLS درون تونل انجام می‌شود و پروکسی فقط میزبان و پورت را می‌بیند. متد CONNECT در ⁦RFC 9110⁩ تعریف شده است.
  4. پروکسی socks5://. Go دست‌دادن SOCKS را انجام می‌دهد، اگر آدرس اطلاعات ورود داشته باشد وارد می‌شود و نام میزبان مقصد را می‌فرستد، پس DNS را خود پروکسی resolve می‌کند.
  5. اتصال در مخزن نگه داشته می‌شود تا درخواست بعدی به همان مقصد از طریق همان پروکسی، روی keep-alive در ⁦HTTP/1.1⁩ یا روی ⁦HTTP/2⁩، از آن استفاده کند؛ اتصالی که دوباره استفاده می‌شود Proxy را دوباره فراخوانی نمی‌کند.

چگونه با ⁦http.ProxyURL⁩ پروکسی تنظیم کنیم؟

http.ProxyURL یک آدرس ثابت را در تابعی با شکل درست می‌پیچد. آدرس را یک بار تجزیه کنید، در یک Transport بگذارید و Transport را به Client بدهید که مهلت هم دارد:

go
package main

import (
	"cmp"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"time"
)

func main() {
	// The endpoint builder in the panel prints this string. Keep it in an environment variable, not in git.
	raw := cmp.Or(os.Getenv("PROXY_URL"), "http://user:pass@pr.proxynet.io:8000")
	proxyURL, err := url.Parse(raw)
	if err != nil {
		panic(err)
	}

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

	for _, target := range []string{"https://httpbin.org/ip", "http://httpbin.org/ip"} {
		resp, err := client.Get(target)
		if err != nil {
			fmt.Println("error:", err)
			continue
		}
		body, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		fmt.Printf("%s -> %d %s", target, resp.StatusCode, body)
	}
}
text
https://httpbin.org/ip -> 200 {
  "origin": "203.0.113.42"
}
http://httpbin.org/ip -> 200 {
  "origin": "203.0.113.42"
}

در سمت پروکسی این دو با هم فرق دارند: یک CONNECT برای مقصد HTTPS و یک GET با URL کامل که برای مقصد HTTP جلو فرستاده شده است، هر دو همراه اطلاعات ورود:

text
13:25:51.052 [http-proxy] CONNECT httpbin.org:443 proxy-authorization=Basic user -> 200 (tunnel #1)
13:25:51.563 [http-proxy] GET http://httpbin.org/ip proxy-authorization=Basic user user-agent=Go-http-client/1.1 -> forward

دو عادت: پس از هر پاسخ resp.Body.Close() را فراخوانی کنید، وگرنه اتصال نمی‌تواند به مخزن برگردد، و روی Client یک Timeout بگذارید، وگرنه یک نقطه خروج که پاسخ دادن را متوقف کند، goroutine را برای همیشه نگه می‌دارد. User-Agent پیش‌فرض Go-http-client/1.1 است؛ مقدار خودتان را تنظیم کنید تا مقصد بتواند برنامه شما را بشناسد.

Go متغیرهای HTTP_PROXY، HTTPS_PROXY و NO_PROXY را چگونه می‌خواند؟

http.ProxyFromEnvironment این سه متغیر، یا شکل با حروف کوچک آن‌ها، را برای هر درخواست می‌خواند؛ قواعد در مستندات بسته httpproxy آمده است، همان پیاده‌سازی‌ای که net/http به کار می‌برد:

  • HTTP_PROXY برای مقصدهای http:// و HTTPS_PROXY برای مقصدهای https:// به کار می‌رود: طرح مقصد تعیین‌کننده است، نه طرح پروکسی، پس آدرس پروکسی http:// در HTTPS_PROXY عادی است.
  • مقدار یا یک URL کامل است یا host[:port] خالی که http فرض می‌شود؛ هر چیز دیگری درخواست را ناموفق می‌کند.
  • اگر هم متغیر با حروف بزرگ و هم با حروف کوچک وجود داشته باشد، حروف کوچک برنده است.
  • NO_PROXY آدرس‌های آی‌پی، بازه‌های CIDR مانند 10.0.0.0/8 و نام‌های دامنه را فهرست می‌کند که هر کدام می‌تواند پورت هم داشته باشد. یک دامنه با خودش و زیردامنه‌هایش مطابقت دارد؛ نامی که با نقطه شروع شود فقط با زیردامنه‌ها مطابقت دارد؛ یک * تنها پروکسی را به کلی غیرفعال می‌کند.
  • localhost و آدرس‌های loopback هرگز از پروکسی نمی‌گذرند.

این برنامه بدون فرستادن چیزی، درباره پنج مقصد از ProxyFromEnvironment می‌پرسد:

go
package main

import (
	"fmt"
	"net/http"
)

func main() {
	targets := []string{
		"https://httpbin.org/ip",
		"http://httpbin.org/ip",
		"https://api.internal.example.com/v1",
		"http://10.0.0.7:8080/health",
		"http://localhost:9000/",
	}
	for _, target := range targets {
		req, _ := http.NewRequest(http.MethodGet, target, nil)
		proxyURL, err := http.ProxyFromEnvironment(req)
		switch {
		case err != nil:
			fmt.Printf("%-36s error: %v\n", target, err)
		case proxyURL == nil:
			fmt.Printf("%-36s direct\n", target)
		default:
			fmt.Printf("%-36s via %s\n", target, proxyURL.Redacted())
		}
	}
}

وقتی HTTPS_PROXY و HTTP_PROXY هر دو تنظیم باشند و NO_PROXY=.example.com,10.0.0.0/8:

text
https://httpbin.org/ip               via http://user:xxxxx@pr.proxynet.io:8000
http://httpbin.org/ip                via http://user:xxxxx@pr.proxynet.io:8000
https://api.internal.example.com/v1  direct
http://10.0.0.7:8080/health          direct
http://localhost:9000/               direct

وقتی فقط HTTP_PROXY تنظیم باشد، مقصدهای HTTPS مستقیم می‌روند؛ رایج‌ترین دلیل اینکه یک برنامه Go «پروکسی را نادیده می‌گیرد»:

text
https://httpbin.org/ip               direct
http://httpbin.org/ip                via http://user:xxxxx@pr.proxynet.io:8000
https://api.internal.example.com/v1  direct
http://10.0.0.7:8080/health          via http://user:xxxxx@pr.proxynet.io:8000
http://localhost:9000/               direct

Redacted() رمز را به شکل xxxxx می‌پوشاند؛ در لاگ‌ها از آن استفاده کنید.

یک قاعده در معنای متغیرها نیامده است: net/http محیط را یک بار، در نخستین درخواست، می‌خواند و برای تمام عمر فرایند نگه می‌دارد، پس os.Setenv پس از آن اثری ندارد:

text
before Setenv: <nil>
after Setenv, net/http: <nil>
after Setenv, httpproxy: http://user:xxxxx@pr.proxynet.io:8000

برای اینکه محیط تغییریافته در زمان اجرا خوانده شود، httpproxy.FromEnvironment().ProxyFunc() را، که متغیرها را در هر فراخوانی می‌خواند، در Transport.Proxy بگذارید.

Go نام کاربری و رمز پروکسی را چگونه می‌فرستد؟

وقتی آدرس پروکسی نام کاربری و رمز داشته باشد، Go خودش Proxy-Authorization: Basic … را می‌فرستد. برای مقصد ساده http:// این هدر همراه هر درخواست می‌رود، حتی روی اتصال keep-alive: در آزمون تلاش دوباره ما، چهار GET روی یک سوکت هر کدام آن را حمل کردند. برای مقصد https:// یک بار، در CONNECT، فرستاده می‌شود و تونل تا وقتی اتصال زنده است دوباره استفاده می‌شود.

نویسه‌هایی مانند @، : یا / در رمز، اگر با کدگذاری درصدی (percent-encoding) نوشته نشوند، آدرس را خراب می‌کنند؛ پس آدرس را از اجزایش بسازید:

go
proxyURL := &url.URL{
	Scheme: "http",
	Host:   "pr.proxynet.io:8000",
	User:   url.UserPassword("user", "p@ss:word"), // encoded as p%40ss%3Aword on the wire
}

Basic تنها روشی است که Go به پروکسی می‌فرستد؛ NTLM یا Kerberos به بسته شخص ثالث نیاز دارد. گیت‌وی یک ارائه‌دهنده Basic یا لیست سفید آی‌پی را می‌پذیرد؛ لیست سفید رمزی در آدرس باقی نمی‌گذارد و برای سروری با آدرس ثابت مناسب است. این دو را در احراز هویت پروکسی: نام کاربری و رمز یا لیست سفید آی‌پی مقایسه کرده‌ایم.

چگونه در Go از پروکسی SOCKS5 استفاده کنیم؟

دو راه وجود دارد. Transport.Proxy آدرس socks5:// را می‌پذیرد و دست‌دادن را خودش انجام می‌دهد؛ Go با socks5 دقیقاً مانند socks5h رفتار می‌کند، پس نام میزبان مقصد را خود پروکسی resolve می‌کند. بسته golang.org/x/net/proxy در عوض dialer مربوط به SOCKS5 را به‌صورت یک مقدار به شما می‌دهد که آن را در Transport.DialContext می‌گذارید و برای اتصال‌هایی که HTTP نیستند هم می‌توانید به کار ببرید.

go
package main

import (
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"net/url"
	"os"
	"time"

	"golang.org/x/net/proxy"
)

func main() {
	// SOCKS_URL=socks5://user:pass@pr.proxynet.io:<the SOCKS5 port the endpoint builder shows>
	socksURL, err := url.Parse(os.Getenv("SOCKS_URL"))
	if err != nil || socksURL.Host == "" {
		log.Fatal("set SOCKS_URL to socks5://user:pass@host:port")
	}

	// 1. net/http handles socks5:// on its own. The proxy resolves the host name.
	viaTransport := &http.Client{
		Transport: &http.Transport{Proxy: http.ProxyURL(socksURL)},
		Timeout:   20 * time.Second,
	}
	show("Transport.Proxy", viaTransport)

	// 2. golang.org/x/net/proxy gives you the SOCKS5 dialer itself, usable for any TCP connection.
	password, _ := socksURL.User.Password()
	auth := &proxy.Auth{User: socksURL.User.Username(), Password: password}
	dialer, err := proxy.SOCKS5("tcp", socksURL.Host, auth, &net.Dialer{Timeout: 10 * time.Second})
	if err != nil {
		log.Fatal(err)
	}
	viaDialer := &http.Client{
		Transport: &http.Transport{DialContext: dialer.(proxy.ContextDialer).DialContext},
		Timeout:   20 * time.Second,
	}
	show("x/net/proxy dialer", viaDialer)
}

func show(label string, client *http.Client) {
	resp, err := client.Get("https://httpbin.org/ip")
	if err != nil {
		fmt.Printf("%-19s error: %v\n", label, err)
		return
	}
	body, _ := io.ReadAll(resp.Body)
	resp.Body.Close()
	fmt.Printf("%-19s %d %s", label, resp.StatusCode, body)
}
text
Transport.Proxy     200 {
  "origin": "203.0.113.42"
}
x/net/proxy dialer  200 {
  "origin": "203.0.113.42"
}

سرور SOCKS5 ما برای هر دو کلاینت همان سه مرحله را ثبت کرد: Go روش‌های «بدون احراز هویت» و «نام کاربری/رمز» را پیشنهاد داد، سرور نام کاربری/رمز را آن‌طور که در ⁦RFC 1929⁩ تعریف شده انتخاب کرد و درخواست اتصال نام دامنه را حمل می‌کرد، نه آدرس آی‌پی:

text
13:26:07.490 [socks5] greeting methods=[0,2]
13:26:07.490 [socks5] auth user=user -> OK
13:26:07.490 [socks5] CONNECT domain httpbin.org:443

در مسیر dialer، Transport.Proxy را خالی بگذارید؛ dialer خودش از پروکسی می‌گذرد. SOCKS5 هم TCP و هم UDP را حمل می‌کند، اما net/http فقط اتصال TCP باز می‌کند. پروکسی SOCKS5 در Proxynet روی پورت جداگانه‌ای کنار پورت HTTP کار می‌کند، با همان نام کاربری و رمز.

کدام روش برای کدام وضعیت مناسب است؟

روشکجا قرار می‌گیرددامنه اثرنکته
http.ProxyURL(u)Transport.Proxyهمان Transportیک پروکسی ثابت
http.ProxyFromEnvironmentTransport.Proxy (پیش‌فرض در DefaultTransport)کل فرایندیک بار خوانده می‌شود؛ localhost مستثناست
تابع func(*http.Request) خودتانTransport.Proxyهر درخواستچرخش، قواعد به تفکیک میزبان
آدرس socks5://هر یک از سه مورد بالاهمان TransportDNS در سمت پروکسی resolve می‌شود
dialer از x/net/proxyTransport.DialContextهمان TransportTCP خام هم؛ Proxy را خالی بگذارید

چگونه برای هر درخواست از پروکسی متفاوتی استفاده کنیم؟

Transport.Proxy یک تابع معمولی است، پس می‌تواند در هر فراخوانی آدرس متفاوتی برگرداند. این تابع از یک فهرست به نوبت (round-robin) انتخاب می‌کند و انتخابش را چاپ می‌کند:

go
var next atomic.Uint64
transport := &http.Transport{
	// Round-robin over the list. Called when the Transport needs a connection for a request.
	Proxy: func(req *http.Request) (*url.URL, error) {
		u := proxies[(next.Add(1)-1)%uint64(len(proxies))]
		fmt.Printf("Proxy func: %s %s via %s\n", req.Method, req.URL, u.Redacted())
		return u, nil
	},
	DisableKeepAlives: true, // a new connection, and therefore a new proxy choice, for every request
}

سطر آخر مهم است. چهار GET به https://httpbin.org/ip فرستادیم، که ⁦HTTP/2⁩ را مذاکره می‌کند، با دو پروکسی در فهرست، و فراخوانی‌ها و تونل‌ها را شمردیم:

تنظیمات Transportفراخوانی تابع Proxyتونل‌های بازشده
پیش‌فرض‌ها (⁦HTTP/2⁩ مذاکره شد)11، فقط پروکسی اول
فقط ⁦HTTP/1.1⁩ (با map خالی برای TLSNextProto)42، یکی به‌ازای هر پروکسی، با استفاده دوباره
DisableKeepAlives: true44، به تناوب

با پیش‌فرض‌ها، Go یک اتصال ⁦HTTP/2⁩ از طریق پروکسی اول باز کرد و هر چهار درخواست را روی همان اتصال فرستاد؛ دیگر هرگز از تابع پرسیده نشد. با keep-alive در ⁦HTTP/1.1⁩ هر بار پرسیده شد، اما درخواست‌های سوم و چهارم اتصال بیکار به پروکسی خود را دوباره به کار بردند. فقط DisableKeepAlives، یا req.Close = true، به‌ازای هر درخواست یک تونل می‌دهد. گیت‌وی چرخشی هم از همین قاعده پیروی می‌کند، چون آی‌پی خروجی هنگام باز شدن تونل انتخاب می‌شود: گیت‌وی پروکسی مسکونی برای هر تونل تازه از طریق یک آدرس، خروجی را می‌چرخاند و یک پارامتر نشست در نام کاربری، برای جریان‌های ورود، یک خروجی را 1 تا 60 دقیقه نگه می‌دارد.

مهلت‌ها، محدودیت اتصال و TLS

http.Client.Timeout تنها تنظیمی است که هرگز نباید صفر بماند: کل درخواست را، از اتصال تا آخرین بایت، پوشش می‌دهد. تنظیم‌های ریزتر روی Transport هستند: DialContext با net.Dialer{Timeout: …} اتصال به پروکسی را محدود می‌کند، TLSHandshakeTimeout دست‌دادن درون تونل را و ResponseHeaderTimeout انتظار برای هدرها را؛ http.NewRequestWithContext با یک سررسید (deadline) همین کار را برای یک درخواست انجام می‌دهد.

سه فیلد تعیین می‌کنند چند اتصال باز می‌کنید. MaxConnsPerHost اتصال‌ها به‌ازای هر میزبان را سقف می‌زند و اتصال‌های بعدی را منتظر می‌گذارد؛ یک محدودکننده نرخ ارزان به تفکیک مقصد. پیش‌فرض MaxIdleConnsPerHost برابر 2 است؛ با workerهای بیشتر، اتصال‌های تمام‌شده به‌جای ماندن در مخزن بسته می‌شوند، پس آن را برابر تعداد workerها بگذارید. IdleConnTimeout اتصال‌هایی را که بی‌استفاده مانده‌اند می‌بندد.

برای TLS هرگز سراغ InsecureSkipVerify نروید. خطای x509: certificate signed by unknown authority پشت پروکسی یعنی یک پروکسی سازمانی TLS را با مرجع صدور گواهی (CA) خودش بازرسی می‌کند؛ آن CA را به tls.Config.RootCAs اضافه کنید. گیت‌وی یک ارائه‌دهنده هرگز به TLS درون تونل دست نمی‌زند، پس در آنجا خطا به گواهی مقصد اشاره دارد.

نمونه کامل: تلاش دوباره، backoff و سقف همروندی

برنامه دریافت زیر اجزا را کنار هم می‌گذارد: مهلت‌ها و محدودیت‌های مخزن بالا، تلاش دوباره برای خطاهای شبکه، 429 و 5xx با backoff نمایی و جزء تصادفی (jitter)، رعایت Retry-After، بدون تلاش دوباره روی 407، و حداکثر چهار درخواست هم‌زمان:

go
// fetch.go - GET a list of URLs through a proxy with timeouts, retries, backoff and a concurrency cap.
package main

import (
	"cmp"
	"context"
	"errors"
	"fmt"
	"io"
	"log"
	"math/rand/v2"
	"net"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"sync"
	"sync/atomic"
	"time"
)

const (
	maxAttempts = 4 // first try + 3 retries
	maxInFlight = 4 // requests running at the same time
)

var (
	retryStatus  = map[int]bool{429: true, 500: true, 502: true, 503: true, 504: true}
	errProxyAuth = errors.New("proxy rejected the credentials (407): check user:pass or the IP whitelist")
)

func newClient(proxyURL *url.URL) *http.Client {
	return &http.Client{
		Timeout: 30 * time.Second, // the whole request: connect, headers and body
		Transport: &http.Transport{
			Proxy:                 http.ProxyURL(proxyURL),
			DialContext:           (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
			TLSHandshakeTimeout:   10 * time.Second,
			ResponseHeaderTimeout: 15 * time.Second,
			MaxConnsPerHost:       maxInFlight,
			MaxIdleConnsPerHost:   maxInFlight,
			IdleConnTimeout:       60 * time.Second,
			// A 407 on the CONNECT tunnel becomes a typed error instead of "Proxy Authentication Required".
			OnProxyConnectResponse: func(_ context.Context, _ *url.URL, _ *http.Request, res *http.Response) error {
				if res.StatusCode == http.StatusProxyAuthRequired {
					return errProxyAuth
				}
				return nil
			},
		},
	}
}

// backoff: 1, 2, 4 s ... capped at 30 s, plus up to 1 s of jitter so workers do not retry in lockstep.
func backoff(attempt int) time.Duration {
	d := min(time.Duration(1<<(attempt-1))*time.Second, 30*time.Second)
	return d + time.Duration(rand.IntN(1000))*time.Millisecond
}

func sleep(ctx context.Context, d time.Duration) {
	select {
	case <-ctx.Done():
	case <-time.After(d):
	}
}

// fetch GETs one URL. Network errors, 429 and 5xx are retried; a 407 is not, because the
// credentials are wrong and another attempt cannot fix that.
func fetch(ctx context.Context, client *http.Client, target string) ([]byte, int, error) {
	var lastErr error
	for attempt := 1; attempt <= maxAttempts; attempt++ {
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
		if err != nil {
			return nil, 0, err
		}
		req.Header.Set("User-Agent", "price-monitor/1.0 (+mailto:you@example.com)")

		resp, err := client.Do(req)
		if err != nil {
			if errors.Is(err, errProxyAuth) || ctx.Err() != nil {
				return nil, 0, err
			}
			lastErr = err
			log.Printf("attempt %d/%d %s: %v", attempt, maxAttempts, target, err)
			sleep(ctx, backoff(attempt))
			continue
		}
		body, err := io.ReadAll(resp.Body)
		resp.Body.Close()
		if resp.StatusCode == http.StatusProxyAuthRequired { // plain-http target: the 407 is a response
			return body, resp.StatusCode, errProxyAuth
		}
		if !retryStatus[resp.StatusCode] {
			return body, resp.StatusCode, err // 200, 404, 301 ... the caller decides
		}
		lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
		if attempt == maxAttempts {
			break
		}
		// The site asked us to slow down. Its own Retry-After wins over our schedule.
		wait := backoff(attempt)
		if secs, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
			wait = time.Duration(secs) * time.Second
		}
		log.Printf("attempt %d/%d %s: HTTP %d, sleeping %s", attempt, maxAttempts, target, resp.StatusCode, wait.Round(100*time.Millisecond))
		sleep(ctx, wait)
	}
	return nil, 0, lastErr
}

func main() {
	proxyURL, err := url.Parse(cmp.Or(os.Getenv("PROXY_URL"), "http://user:pass@pr.proxynet.io:8000"))
	if err != nil {
		log.Fatal(err)
	}
	targets := os.Args[1:]
	if len(targets) == 0 {
		for i := 1; i <= 4; i++ {
			targets = append(targets, fmt.Sprintf("https://httpbin.org/ip?n=%d", i))
		}
		targets = append(targets, "https://httpbin.org/status/503")
	}

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()
	client := newClient(proxyURL)
	sem := make(chan struct{}, maxInFlight)
	var wg sync.WaitGroup
	var ok, failed atomic.Int32

	for _, target := range targets {
		wg.Add(1)
		sem <- struct{}{} // blocks while maxInFlight requests are running
		go func() {
			defer wg.Done()
			defer func() { <-sem }()
			started := time.Now()
			body, status, err := fetch(ctx, client, target)
			if err != nil {
				failed.Add(1)
				log.Printf("fail %s -> %v", target, err)
				return
			}
			ok.Add(1)
			log.Printf("ok   %s -> %d, %d bytes in %s", target, status, len(body), time.Since(started).Round(100*time.Millisecond))
		}()
	}
	wg.Wait()
	log.Printf("done: %d ok, %d failed", ok.Load(), failed.Load())
}

پنج URL، چهار worker و یک آدرس که همیشه 503 پاسخ می‌دهد تا مسیر تلاش دوباره آزموده شود:

text
2026/10/28 16:30:56 ok   https://httpbin.org/ip?n=1 -> 200, 32 bytes in 500ms
2026/10/28 16:30:56 ok   https://httpbin.org/ip?n=2 -> 200, 32 bytes in 500ms
2026/10/28 16:30:56 ok   https://httpbin.org/ip?n=4 -> 200, 32 bytes in 500ms
2026/10/28 16:30:56 attempt 1/4 https://httpbin.org/status/503: HTTP 503, sleeping 1.3s
2026/10/28 16:30:56 ok   https://httpbin.org/ip?n=3 -> 200, 32 bytes in 700ms
2026/10/28 16:30:58 attempt 2/4 https://httpbin.org/status/503: HTTP 503, sleeping 2.8s
2026/10/28 16:31:00 attempt 3/4 https://httpbin.org/status/503: HTTP 503, sleeping 4.8s
2026/10/28 16:31:05 fail https://httpbin.org/status/503 -> HTTP 503
2026/10/28 16:31:05 done: 4 ok, 1 failed

سه نکته طراحی. OnProxyConnectResponse وقتی اجرا می‌شود که پروکسی به CONNECT پاسخ می‌دهد، پیش از آنکه Go وضعیت غیر از 200 را به یک رشته خطای ساده تبدیل کند؛ خطایی با نوع مشخص (typed error) در آنجا به errors.Is اجازه می‌دهد حلقه تلاش دوباره را متوقف کند و با رمز نادرست، برنامه برای یک مقصد HTTPS و یک مقصد HTTP ساده fail … -> proxy rejected the credentials (407) را بدون حتی یک تلاش دوباره چاپ کرد. مجموعه تلاش دوباره عمداً کوچک است: 404 یا 403 همان‌طور که هست برگردانده می‌شود، چون نقطه خروج دیگری چیزی را که صفحه هست تغییر نمی‌دهد. در برابر سرور محلی‌ای که Retry-After: 2 می‌فرستاد، حلقه به‌جای زمان‌بندی خودش دقیقاً 2 ثانیه میان تلاش‌ها خوابید.

چگونه بررسی کنیم که درخواست واقعاً از پروکسی می‌گذرد؟

از یک سرویس بازتاب (echo) بپرسید کدام آدرس را می‌بیند، یک بار با Proxy: nil و یک بار با پروکسی. این nil عمدی است: Transport بدون مقدار Proxy هرگز محیط را نمی‌خواند، پس سطر اول حتی با HTTPS_PROXY تنظیم‌شده هم آدرس خودتان است.

go
direct := &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: 20 * time.Second}
proxied := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}, Timeout: 20 * time.Second}

fmt.Println("without proxy:", exitIP(direct))  // exitIP GETs https://api.ipify.org?format=json and returns the "ip" field
fmt.Println("with proxy:   ", exitIP(proxied))

با یک گیت‌وی واقعی، سطر دوم آدرس نقطه خروج را نشان می‌دهد. با پروکسی آزمون ما هر دو سطر یک آدرس چاپ کردند، چون پروکسی روی همان ماشین اجرا می‌شود؛ مدرک، سطر CONNECT api.ipify.org:443 در لاگ آن بود. دو سطر یکسان با یک پروکسی واقعی معمولاً یعنی HTTP_PROXY بدون HTTPS_PROXY، مقصدی در NO_PROXY، یا Transport ساخته‌شده بدون مقدار Proxy.

Colly چطور؟

Colly، پرکاربردترین فریم‌ورک خزش در Go، روی http.Transport ساخته شده است، پس همه آنچه در بالا آمد درباره آن هم صدق می‌کند. بسته proxy آن یک RoundRobinProxySwitcher دارد که آدرس‌های http://، https:// یا socks5:// را می‌گیرد و یک colly.ProxyFunc برمی‌گرداند، با همان امضای Transport.Proxy؛ آن را با c.SetProxyFunc(rp) وصل می‌کنید، همان‌طور که در نمونه proxy switcher در مستندات Colly آمده است. این switcher به‌ازای هر درخواست فراخوانی می‌شود، اما قاعده استفاده دوباره از اتصال در بخش چرخش هنوز تعیین می‌کند که آیا یک درخواست تونل تازه‌ای باز می‌کند یا نه.

خطاهای رایج و معنای آن‌ها

پیام‌ها همان‌طور که ⁦Go 1.26.6⁩ در آزمون‌های ما چاپ کرد، با میزبان جانشین:

  • Get "https://httpbin.org/ip": Proxy Authentication Required برای مقصد HTTPS، یا وضعیت 407 به‌صورت یک پاسخ عادی برای مقصد HTTP ساده: user:pass نادرست، نویسه‌های خاص کدگذاری‌نشده، یا آدرسی که در لیست سفید نیست. علت‌ها را در رفع خطای ⁦HTTP 407 Proxy Authentication Required⁩ گرد آورده‌ایم.
  • proxyconnect tcp: dial tcp pr.proxynet.io:8000: … به‌علاوه متن رد اتصال سیستم‌عامل (connect: connection refused در لینوکس و macOS، connectex: و یک جمله بومی‌سازی‌شده در ویندوز): چیزی آنجا گوش نمی‌دهد. proxyconnect می‌گوید شکست در راه رسیدن به پروکسی بوده است، نه به مقصد.
  • socks connect tcp pr.proxynet.io:8000->httpbin.org:443: unexpected protocol version 72: یک آدرس socks5:// به پورت HTTP اشاره کرده است؛ 72 مقدار بایت حرف H در HTTP/1.1 است.
  • socks connect tcp …: username/password authentication failed: سرور SOCKS5 اطلاعات ورود را رد کرد.
  • unexpected EOF: یک آدرس http:// روی پورت SOCKS5، که اتصال را هنگام CONNECT بست؛ طرح ناشناخته‌ای مانند socks4:// مانند http در نظر گرفته می‌شود و به همین شکل تمام می‌شود.
  • proxyconnect tcp: tls: first record does not look like a TLS handshake: طرح پروکسی https:// روی یک پورت HTTP ساده؛ از http:// استفاده کنید مگر اینکه ارائه‌دهنده بگوید پورت با TLS کار می‌کند.
  • پروکسی اصلاً استفاده نمی‌شود: HTTP_PROXY بدون HTTPS_PROXY، مقصدی که localhost است یا در NO_PROXY قرار دارد، متغیرهایی که پس از نخستین درخواست تنظیم شده‌اند، یا Transport ساخته‌شده بدون مقدار Proxy.

این تنظیمات کجا به کار می‌رود؟

  • بررسی قیمت و موجودی از چند کشور: پارامتر کشور در نام کاربری پروکسی و چرخش به‌ازای هر درخواست برای صفحه‌های محصول مستقل از هم.
  • کلاینت‌های API پشت یک آدرس مجاز، جایی که شریک تجاری یک آدرس ثابت را در لیست سفید گذاشته و سرویس باید از همان خارج شود.
  • پایش از بیرون شبکه خودتان، تا بررسی سلامت سایت را همان‌طور ببیند که بازدیدکننده‌ای در منطقه‌ای دیگر می‌بیند.
  • workerها در شبکه سازمانی که فقط از طریق پروکسی شرکت به اینترنت می‌رسند.

اشتباهات رایج

  • تنظیم فقط HTTP_PROXY. مقصدهای HTTPS مستقیم می‌روند؛ HTTPS_PROXY را هم تنظیم کنید.
  • تغییر محیط پس از نخستین درخواست. net/http آن را نگه می‌دارد؛ فرایند را دوباره آغاز کنید یا از httpproxy استفاده کنید.
  • انتظار چرخش با keep-alive روشن. اتصالی که دوباره استفاده می‌شود هرگز Proxy را دوباره فراخوانی نمی‌کند.
  • فراموش کردن resp.Body.Close(). اتصال‌ها هرگز به مخزن برنمی‌گردند.
  • نبود Client.Timeout. یک نقطه خروج خاموش، goroutine را برای همیشه مسدود می‌کند.
  • چسباندن رمزی با @ یا : در رشته URL. آن را با url.UserPassword بسازید.
  • تنظیم هم‌زمان Transport.Proxy و DialContext مربوط به SOCKS5. یکی را انتخاب کنید.
  • تلاش دوباره روی 407. تلاش دوم همان اطلاعات ورود نادرست را می‌فرستد.

کدام روش را انتخاب کنیم؟

وضعیت شماپیشنهاد
یک پروکسی، بدون تغییر کدHTTPS_PROXY و HTTP_PROXY، و NO_PROXY برای میزبان‌های داخلی
یک پروکسی، تنظیم در کدTransport{Proxy: http.ProxyURL(u)} همراه Client.Timeout
نقطه خروج تازه برای هر درخواستتابع Proxy خودتان یا یک گیت‌وی چرخشی، با DisableKeepAlives: true
همان نقطه خروج برای جریان ورودپارامتر نشست در نام کاربری، keep-alive روشن
SOCKS5 با DNS در سمت پروکسیsocks5://user:pass@host:port در Transport.Proxy
SOCKS5 برای اتصال غیر HTTPdialer از golang.org/x/net/proxy
workerهای زیاد روی یک سایتMaxConnsPerHost و MaxIdleConnsPerHost برابر تعداد workerها
پروکسی سازمانی با CA خودشCA در tls.Config.RootCAs، هرگز InsecureSkipVerify

پرسش‌های متداول

آیا http.Get در Go به‌طور خودکار از پروکسی استفاده می‌کند؟

بله، وقتی محیط چنین بگوید: http.Get از http.DefaultClient استفاده می‌کند که Transport آن Proxy را روی http.ProxyFromEnvironment می‌گذارد. Transport که خودتان می‌سازید بدون پروکسی شروع می‌کند.

آیا این همان GOPROXY است؟

خیر. GOPROXY به فرمان go می‌گوید ماژول‌ها را از کجا دانلود کند؛ ربطی به درخواست‌هایی که برنامه شما در زمان اجرا می‌فرستد ندارد.

چگونه فقط برای یک درخواست پروکسی تنظیم کنم؟

به آن درخواست Client و Transport مخصوص خودش را بدهید، یا تابع Proxy بنویسید که برای درخواست‌هایی که باید مستقیم بروند nil برگرداند. ممکن است اتصالی در مخزن به همان میزبان از قبل وجود داشته باشد؛ req.Close = true اتصال تازه را اجباری می‌کند.

آیا Go از socks5h پشتیبانی می‌کند؟

بله، و socks5 هم همان‌طور رفتار می‌کند: Go همیشه نام میزبان را به پروکسی می‌فرستد و پروکسی آن را resolve می‌کند؛ net/http حالت SOCKS5 با resolve محلی ندارد.

چرا localhost هرگز از پروکسی نمی‌گذرد؟

ProxyFromEnvironment برای localhost و آدرس‌های loopback، هر چه متغیرها بگویند، پروکسی برنمی‌گرداند؛ برای عبور دادن چنین درخواست‌هایی از پروکسی، به‌جای آن از http.ProxyURL استفاده کنید.

چگونه ببینم یک درخواست از کدام پروکسی استفاده کرده است؟

تابع Proxy را در تابع دیگری بپیچید و u.Redacted() را لاگ کنید، یا Transport.OnProxyConnectResponse را تنظیم کنید که آدرس پروکسی و پاسخ CONNECT را دریافت می‌کند. با keep-alive روشن، انتخاب فقط وقتی لاگ می‌شود که اتصال تازه‌ای باز شود.

خلاصه

در Go پروکسی تابعی روی http.Transport است: http.ProxyURL برای آدرس ثابت، http.ProxyFromEnvironment برای متغیرهای استاندارد، یا تابع خودتان برای انتخاب به‌ازای هر درخواست. user:pass را در آدرس بگذارید تا Go خودش Proxy-Authorization را بفرستد؛ برای SOCKS5 از socks5:// استفاده کنید؛ Client.Timeout و محدودیت‌های مخزن را تنظیم کنید؛ 429 و 5xx را با backoff دوباره امتحان کنید اما 407 را هرگز؛ وقتی هر درخواست نقطه خروج تازه‌ای می‌خواهد keep-alive را خاموش کنید. نقطه‌های اتصال مسکونی، ISP ثابت، دیتاسنتر و SOCKS5 که با همین تنظیمات کار می‌کنند در صفحه پروکسی‌های Proxynet آمده‌اند.

پرسش از ChatGPTپرسش از Claude