plandex-ai/plandex · error

connection to plan stream timed out due to missing heartbeat

Error message

connection to plan stream timed out due to missing heartbeats

What it means

This error is produced by connectPlanRespStream in app/cli/api/stream.go when the plan SSE-style response stream receives no messages (including heartbeat frames) for HeartbeatTimeout (16 seconds, roughly 3 missed heartbeats). The reader goroutine arms a timer and resets it on every message; if the timer fires, it reports this error through the onStream callback and closes the HTTP response body. It indicates the server stopped sending data or the connection silently stalled without an I/O error.

Source

Thrown at app/cli/api/stream.go:29

	"time"

	shared "plandex-shared"
)

// 3 heartbeat misses = timeout
const HeartbeatTimeout = 16 * time.Second

func connectPlanRespStream(body io.ReadCloser, onStream types.OnStreamPlan) {
	reader := bufio.NewReader(body)
	timer := time.NewTimer(HeartbeatTimeout)
	defer timer.Stop()

	go func() {
		for {
			select {
			case <-timer.C:
				log.Println("Connection to plan stream timed out due to missing heartbeats")
				onStream(types.OnStreamPlanParams{Msg: nil, Err: fmt.Errorf("connection to plan stream timed out due to missing heartbeats")})
				body.Close()
				return
			default:
			}

			s, err := readUntilSeparator(reader, shared.STREAM_MESSAGE_SEPARATOR)
			if err != nil {
				log.Println("Error reading line:", err)
				onStream(types.OnStreamPlanParams{Msg: nil, Err: err})
				body.Close()
				return
			}

			timer.Reset(HeartbeatTimeout)

			// ignore heartbeats
			if s == string(shared.StreamMessageHeartbeat) {
				continue

View on GitHub (pinned to e2d772072e)

Solutions

  1. Check network path (VPN, proxy, corporate firewall) for idle/streaming connection timeouts and raise or disable them for the API host.
  2. Retry the plan request — transient server stalls usually resolve; enable any available retry/resume logic in the CLI.
  3. Verify the Plandex server is healthy and not overloaded (restart or scale the server) since stalled heartbeats often mean the backend hung.
  4. Upgrade both CLI and server versions to ensure heartbeat intervals and timeout (HeartbeatTimeout=16s) are compatible.
Defensive patterns

Strategy: retry

Validate before calling

// Before starting a long plan stream, verify reachability and avoid idle-killing intermediaries
if err := checkServerReachable(serverURL); err != nil {
    return fmt.Errorf("server unreachable before streaming: %w", err)
}
// ensure proxies keep the connection alive: disable idle timeouts / enable TCP keepalive on the http.Client transport

Type guard

// Narrow the error surfaced via OnStreamPlanParams
func isHeartbeatTimeout(err error) bool {
    return err != nil && strings.Contains(err.Error(), "timed out due to missing heartbeats")
}

Try / catch

// In the OnStreamPlan callback
if params.Err != nil {
    if isHeartbeatTimeout(params.Err) {
        // close/reconnect: retry the stream or resume the plan rather than treating it as fatal
        retryWithBackoff(func() error { return startPlanStream(onStream) })
        return
    }
    handleFatal(params.Err)
}

Prevention

When it happens

Trigger: During an active plan stream (connectPlanRespStream), the readUntilSeparator call blocks for >16s with no message arriving — the server never resets the timer, so it fires and the goroutine calls onStream with this error.

Common situations: Server under heavy load or hung LLM backend stops emitting heartbeats; a proxy/firewall/load balancer silently drops or buffers the long-lived streaming connection; network outage that doesn't surface as a read error; server crashed mid-stream without closing the TCP connection.

Understand the failure class

Related errors


AI-assisted analysis of plandex-ai/plandex@e2d772072e (2026-09-05). Data as JSON: /api/errors/60bfa157e41a7cee. Report an issue: GitHub.