XTLS/Xray-core · warning · errors.Error

outbound failed to relay connection

Error message

outbound failed to relay connection

What it means

Thrown in app/observatory/observer.go:179 when the probe HTTP GET (default https://www.google.com/generate_204, or config.ProbeUrl) returns an error from httpClient.Do. Unlike the dialer errors, this covers the whole request lifecycle — dial, TLS handshake (5s timeout), sending, and response — and marks the outbound as failed for this observation round.

Source

Thrown at app/observatory/observer.go:179

		Transport: &httpTransport,
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse
		},
		Jar:     nil,
		Timeout: time.Second * 5,
	}
	var GETTime time.Duration
	err := task.Run(o.ctx, func() error {
		startTime := time.Now()
		probeURL := "https://www.google.com/generate_204"
		if o.config.ProbeUrl != "" {
			probeURL = o.config.ProbeUrl
		}
		req, _ := http.NewRequest(http.MethodGet, probeURL, nil)
		utils.TryDefaultHeadersWith(req.Header, "nav")
		response, err := httpClient.Do(req)
		if err != nil {
			return errors.New("outbound failed to relay connection").Base(err)
		}
		if response.Body != nil {
			response.Body.Close()
		}
		endTime := time.Now()
		GETTime = endTime.Sub(startTime)
		return nil
	})
	if err != nil {
		errorMessage := "the outbound " + outbound + " is dead: GET request failed:" + err.Error() + "with outbound handler report underlying connection failed"
		errors.LogInfoInner(o.ctx, errorCollectorForRequest.UnderlyingError(), errorMessage)
		return ProbeResult{Alive: false, LastErrorReason: errorMessage}
	}
	errors.LogInfo(o.ctx, "the outbound ", outbound, " is alive:", GETTime.Seconds())
	return ProbeResult{Alive: true, Delay: GETTime.Milliseconds()}
}

func (o *Observer) updateStatusForResult(outbound string, result *ProbeResult) {

View on GitHub (pinned to 7d214f8b09)

Solutions

  1. Set observatory config ProbeUrl to an endpoint reachable through your outbounds (e.g. https://www.gstatic.com/generate_204 or a self-hosted 204 URL).
  2. If the outbound is legitimately slow, accept occasional dead marks or raise health-check tolerance via burst observatory with tuned interval.
  3. Verify the outbound credentials/streamSettings if probes consistently fail while manual traffic also fails.
  4. Read the chained Base error and errorCollector.UnderlyingError() to distinguish dial vs TLS vs timeout.

Example fix

// before
"observatory": {"subjectSelector": ["proxy"]}

// after
"observatory": {
  "subjectSelector": ["proxy"],
  "probeURL": "https://www.gstatic.com/generate_204",
  "probeInterval": "1m"
}
Defensive patterns

Strategy: retry

Validate before calling

// pre-flight: confirm the probe URL is reachable through the outbound before scheduling probes
resp, err := probeClient.Get(probeURL)
if err != nil { chooseDifferentProbeURL() }

Try / catch

// observatory already retries per probeInterval; consumers should tolerate transient dead marks
if !result.Alive && result.AlivePrev { /* flap: delay removal by one more interval */ }

Prevention

When it happens

Trigger: httpClient.Do fails for any reason: the dialer path failed (see 45-47), TLS handshake exceeded 5 seconds, the 5-second overall client Timeout was exceeded, or the connection was reset mid-request. The observer then records ProbeResult{Alive:false} with the composed 'outbound X is dead' message.

Common situations: Classic in geo-restricted or censored networks where google.com is unreachable directly through the outbound; also common with slow outbounds that cannot complete a full TLS+HTTP round trip in 5 seconds, causing the observer to flap an actually-working node.

Related errors


AI-assisted analysis of XTLS/Xray-core@7d214f8b09 (2026-08-15). Data as JSON: /api/errors/dc33aba207bffcb0. Report an issue: GitHub.