n4nAI

Build a chat completions client with Go net/http

Step-by-step tutorial for building a Go net/http chat completions client against OpenAI-compatible APIs, with structs, streaming, and error handling.

n4n Team2 min read431 words

Audio narration

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

Building a go net/http chat completions client from scratch strips away SDK abstractions and lets you control exactly how requests, retries, and streams behave. This tutorial implements a minimal but robust client against any OpenAI-compatible /v1/chat/completions endpoint using only the standard library.

Prerequisites

  • Go 1.21 or later
  • An API key for an OpenAI-compatible service (export it as OPENAI_API_KEY)
  • Basic familiarity with Go modules and net/http
  • curl for a quick sanity check (optional)

Project scaffold

mkdir go-chat-client && cd go-chat-client
go mod init example.com/go-chat-client

You do not need any third-party dependencies. The standard library covers JSON, HTTP, and streaming.

Define the API types

OpenAI’s chat completions schema is stable. We declare only the fields we actually read to keep the code small and explicit.

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

type ChatMessage struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

type ChatRequest struct {
	Model    string        `json:"model"`
	Messages []ChatMessage `json:"messages"`
	Stream   bool          `json:"stream,omitempty"`
}

type ChatResponse struct {
	ID      string `json:"id"`
	Object  string `json:"object"`
	Created int64  `json:"created"`
	Choices []struct {
		Index        int         `json:"index"`
		Message      ChatMessage `json:"message"`
		FinishReason string      `json:"finish_reason"`
	} `json:"choices"`
	Usage struct {
		PromptTokens     int `json:"prompt_tokens"`
		CompletionTokens int `json:"completion_tokens"`
		TotalTokens      int `json:"total_tokens"`
	} `json:"usage"`
}

Send a non-streaming request

Our first go net/http chat completions client call posts a JSON body and decodes the response. We set a hard timeout and surface non-200 statuses as errors.

func ChatCompletion(ctx context.Context, baseURL, apiKey, model string, msgs []ChatMessage) (*ChatResponse, error) {
	reqBody := ChatRequest{Model: model, Messages: msgs}
	payload, err := json.Marshal(reqBody)
	if err != nil {
		return nil, err
	}

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

	client := &http.Client{Timeout: 30 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

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

	var cr ChatResponse
	if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil {
		return nil, err
	}
	return &cr, nil
}

A minimal main to exercise it:

func main() {
	baseURL := "https://api.openai.com"
	apiKey := os.Getenv("OPENAI_API_KEY")
	msgs := []ChatMessage{{Role: "user", Content: "Say hello in JSON."}}

	ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
	defer cancel()

	resp, err := ChatCompletion(ctx, baseURL, apiKey, "gpt-4o-mini", msgs)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(resp.Choices[0].Message.Content)
	fmt.Printf("tokens: %d\n", resp.Usage.TotalTokens)
}

Expected output (content varies by model):

{
  "hello": "world"
}
tokens: 42

At this checkpoint you have a working go net/http chat completions client for single responses.

Stream responses with Server-Sent Events

Production chat UIs stream tokens. Set Stream: true and parse the SSE lines yourself. Each event is a data: line; the stream closes with data: [DONE].

func ChatCompletionStream(ctx context.Context, baseURL, apiKey, model string, msgs []ChatMessage) error {
	reqBody := ChatRequest{Model: model, Messages: msgs, Stream: true}
	payload, _ := json.Marshal(reqBody)
	req, _ := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/chat/completions", bytes.NewReader(payload))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+apiKey)

	client := &http.Client{} // no overall timeout for long streams
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

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

	scanner := bufio.NewScanner(resp.Body)
	scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
	for scanner.Scan() {
		line := scanner.Text()
		if !strings.HasPrefix(line, "data: ") {
			continue
		}
		data := strings.TrimPrefix(line, "data: ")
		if data == "[DONE]" {
			break
		}
		var chunk struct {
			Choices []struct {
				Delta struct {
					Content string `json:"content"`
				} `json:"delta"`
			} `json:"choices"`
		}
		if err := json.Unmarshal([]byte(data), &chunk); err != nil {
			continue
		}
		for _, c := range chunk.Choices {
			fmt.Print(c.Delta.Content)
		}
	}
	fmt.Println()
	return scanner.Err()
}

Run it and tokens appear incrementally, followed by a newline when [DONE] arrives.

Error handling and retries

A production go net/http chat completions client treats 429 and 5xx as retryable with backoff. Wrap the call:

func withRetry(ctx context.Context, fn func() error, attempts int) error {
	var err error
	for i := 0; i < attempts; i++ {
		if err = fn(); err == nil {
			return nil
		}
		if strings.Contains(err.Error(), "status 429") || strings.Contains(err.Error(), "status 5") {
			select {
			case <-ctx.Done():
				return ctx.Err()
			case <-time.After(time.Duration(i+1) * 500 * time.Millisecond):
			}
			continue
		}
		break
	}
	return err
}

Use it in main:

err := withRetry(ctx, func() error {
	return ChatCompletionStream(ctx, baseURL, apiKey, "gpt-4o-mini", msgs)
}, 3)

Context cancellation and timeouts

Always pass context.Context into request construction. For streams, use a cancellable context tied to a signal so a hung connection dies cleanly:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

The non-streaming client already uses a 30s http.Client timeout; for streams, rely on context cancellation instead of a fixed timeout.

Swap the base URL for a gateway

The client is agnostic to the backend. Set baseURL to any OpenAI-compatible server. For instance, n4n.ai exposes one OpenAI-compatible endpoint addressing 240+ models, with automatic fallback when a provider is rate-limited or degraded, per-token usage metering, and it honors client routing directives and forwards provider cache-control hints. Your ChatCompletion call needs no changes beyond the URL and model string.

baseURL = "https://api.n4n.ai" // example gateway base

Custom transport for observability

Wrap http.RoundTripper to log latency and headers without polluting business logic:

type logTransport struct{ rt http.RoundTripper }

func (t logTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	start := time.Now()
	resp, err := t.rt.RoundTrip(req)
	log.Printf("%s %s -> %v in %s", req.Method, req.URL.Path, resp.StatusCode, time.Since(start))
	return resp, err
}

client := &http.Client{Transport: logTransport{rt: http.DefaultTransport}}

Inject this client into ChatCompletion by parameterizing the function or using a struct.

Consolidated client struct

For real use, bundle the base URL, API key, and HTTP client into a type:

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

func (c *ChatClient) Complete(ctx context.Context, model string, msgs []ChatMessage) (*ChatResponse, error) {
	// same as ChatCompletion but using c.http, c.baseURL, c.apiKey
}

This keeps the go net/http chat completions client reusable across your codebase and makes testing with httptest.Server straightforward.

Testing with httptest

func TestComplete(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(200)
		w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"ok"}}],"usage":{"total_tokens":1}}`))
	}))
	defer srv.Close()

	c := &ChatClient{baseURL: srv.URL, apiKey: "test", http: srv.Client()}
	resp, err := c.Complete(context.Background(), "fake", nil)
	if err != nil || resp.Choices[0].Message.Content != "ok" {
		t.Fatal("unexpected")
	}
}

You now have a complete, dependency-free chat completions client in Go that handles non-streaming calls, SSE streams, retries, cancellation, and swappable backends.

Tagsgolangnet-httpllm-apihttp-client

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 →