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 curlfor 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.