n4nAI

Timeouts and context cancellation for Go LLM calls

Learn how to implement go context cancellation llm timeout patterns in Go HTTP clients to prevent hung LLM requests and waste spend.

n4n Team3 min read627 words

Audio narration

Coming soon — every post will get a voice note here.

A hung LLM request can burn tokens and block your service indefinitely if you don’t enforce a go context cancellation llm timeout. Go’s context package gives you precise control over request lifetimes, but the standard net/http client needs explicit wiring to respect it.

Step 1: Separate transport-level timeouts from request deadlines

The net/http client has two distinct failure domains. Transport timeouts (dial, TLS handshake, idle connection) protect against slow network setup. The context deadline protects against a slow or stalled upstream model inference.

Set transport timeouts conservatively so connections don’t hang before your request even starts:

package llm

import (
	"net"
	"net/http"
	"time"
)

func newTransport() *http.Transport {
	return &http.Transport{
		DialContext: (&net.Dialer{
			Timeout:   5 * time.Second,
			KeepAlive: 30 * time.Second,
		}).DialContext,
		TLSHandshakeTimeout:   5 * time.Second,
		ResponseHeaderTimeout: 10 * time.Second,
		IdleConnTimeout:       90 * time.Second,
		MaxIdleConns:          100,
		MaxIdleConnsPerHost:   10,
	}
}

These values are starting points. If your go context cancellation llm timeout is 30 seconds, the transport headers must arrive well before that.

Step 2: Build a minimal LLM client that takes context

Wrap http.Client with a method that always uses http.NewRequestWithContext. Never call http.Post or http.Get—they ignore your context.

type Client struct {
	baseURL string
	http    *http.Client
	apiKey  string
}

func NewClient(baseURL, apiKey string) *Client {
	return &Client{
		baseURL: baseURL,
		http:    &http.Client{Transport: newTransport()},
		apiKey:  apiKey,
	}
}

type chatReq struct {
	Model    string `json:"model"`
	Messages []struct {
		Role    string `json:"role"`
		Content string `json:"content"`
	} `json:"messages"`
}

func (c *Client) Complete(ctx context.Context, model, prompt string) ([]byte, error) {
	body := chatReq{Model: model}
	body.Messages = append(body.Messages, struct {
		Role    string `json:"role"`
		Content string `json:"content"`
	}{Role: "user", Content: prompt})

	buf, _ := json.Marshal(body)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/chat/completions", bytes.NewReader(buf))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+c.apiKey)

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		b, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("llm status %d: %s", resp.StatusCode, b)
	}
	return io.ReadAll(resp.Body)
}

This skeleton already respects context cancellation: if ctx is cancelled, c.http.Do returns immediately.

Step 3: Apply a go context cancellation llm timeout at the call site

Never hardcode the timeout inside the client. Let the caller decide based on the endpoint’s latency profile. For a chat completion behind a gateway, 20–40 seconds is typical.

func handleChat(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	// 25s budget for the LLM call, derived from go context cancellation llm timeout needs
	ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
	defer cancel()

	client := NewClient("https://api.example.com", os.Getenv("LLM_KEY"))
	resp, err := client.Complete(ctx, "gpt-4o-mini", "Explain RAFT")
	if err != nil {
		if errors.Is(err, context.DeadlineExceeded) {
			http.Error(w, "llm timeout", http.StatusGatewayTimeout)
			return
		}
		http.Error(w, err.Error(), http.StatusBadGateway)
		return
	}
	w.Write(resp)
}

If you route through a single OpenAI-compatible endpoint such as n4n.ai, the same context propagates and cancels in-flight requests when the provider is slow or rate-limited, saving tokens on abandoned generations.

Step 4: Stream responses without leaking goroutines

Streaming is where most teams get context handling wrong. The server sends tokens slowly; your read loop must observe cancellation.

func (c *Client) Stream(ctx context.Context, model, prompt string, fn func(token string)) error {
	body := map[string]interface{}{
		"model":    model,
		"stream":   true,
		"messages": []map[string]string{{"role": "user", "content": prompt}},
	}
	buf, _ := json.Marshal(body)
	req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/chat/completions", bytes.NewReader(buf))
	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.http.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	scanner := bufio.NewScanner(resp.Body)
	scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}
		if !scanner.Scan() {
			if err := scanner.Err(); err != nil && !errors.Is(err, context.Canceled) {
				return err
			}
			return nil
		}
		line := scanner.Text()
		if strings.HasPrefix(line, "data: ") {
			fn(strings.TrimPrefix(line, "data: "))
		}
	}
}

The select checks cancellation every iteration. Without it, a slow network write on the server side can stall scanner.Scan() and ignore a cancelled context until the next read—which might be never.

Step 5: Test cancellation with httptest

You cannot claim your go context cancellation llm timeout works without a test that proves the client aborts before the server responds.

func TestClientTimeout(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(2 * time.Second) // simulate slow model
		w.Write([]byte(`{"ok":true}`))
	}))
	defer srv.Close()

	c := NewClient(srv.URL, "test")
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	_, err := c.Complete(ctx, "test", "hi")
	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("expected deadline exceeded, got %v", err)
	}
}

Run go test -run TestClientTimeout -v. If it passes, your client respects the deadline. If it hangs for two seconds, you wired context incorrectly.

Step 6: Avoid the body-close race on cancellation

When context cancels mid-read, resp.Body.Close() is called by defer. That tears down the connection. But if you’ve spawned a separate goroutine to read the body, it may write to a closed pipe. Keep reads in the same goroutine as the context check, as shown in Step 4.

Also, do not reuse the same http.Client with a zero Timeout field and rely solely on context. The client-level Timeout covers the entire exchange including reading the body; context covers it too but with clearer error typing. Set http.Client{Timeout: 0} and depend on context, or set both—just be aware that client.Timeout returns http.ErrHandlerTimeout, not a context error.

Step 7: Verify success in production

After deployment, confirm the following:

  • Metrics show context.DeadlineExceeded errors correlated with your configured go context cancellation llm timeout, not with transport errors.
  • No goroutine leaks: runtime.NumGoroutine() stable under load tests that force cancellations.
  • Token spend drops for aborted requests (most LLM providers stop billing once the connection closes, but verify with per-token metering).

A simple curl loop with a proxy that delays responses will exercise the path:

# use a local proxy or toxiproxy to add 30s latency
for i in {1..100}; do curl -m 5 http://localhost:8080/chat -d '{"prompt":"test"}'; done

If your service returns 504 within 5 seconds consistently, the timeout is enforced.

Step 8: Advanced pattern—per-attempt timeout with fallback

When you have multiple model providers, wrap the call in a retry that uses a fresh context for each attempt but a parent context for total budget:

func completeWithFallback(parent context.Context, c *Client, models []string) ([]byte, error) {
	for _, m := range models {
		attemptCtx, cancel := context.WithTimeout(parent, 8*time.Second)
		resp, err := c.Complete(attemptCtx, m, "hello")
		cancel()
		if err == nil {
			return resp, nil
		}
		if errors.Is(err, context.DeadlineExceeded) {
			continue // try next model
		}
		return nil, err
	}
	return nil, errors.New("all models timed out")
}

This bounds each provider call while preserving the overall go context cancellation llm timeout from the inbound request.

Final notes

Context cancellation is not optional for LLM clients. A single stuck generation can exhaust your worker pool and rack up cost. Wire context.WithTimeout at every boundary, use NewRequestWithContext, and test the failure path explicitly. The code above is production-grade minus your auth and retry telemetry—drop it in and measure.

Tagsgolangcontexttimeoutsllm-api

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All go net/http llm api client posts →