n4nAI

net/http vs resty for calling LLM APIs in Go

A pragmatic head-to-head comparison of Go's net/http and resty for building LLM API clients: ergonomics, latency, retries, streaming, and verdicts.

n4n Team4 min read877 words

Audio narration

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

Choosing between go net/http vs resty llm api clients in Go is less about features and more about how much boilerplate you want to own. Both can hit an OpenAI-compatible endpoint, stream tokens, and forward custom headers, but they diverge sharply in retry semantics, dependency footprint, and control over the request lifecycle.

Capabilities

At the wire level, both libraries are the same engine: net/http is the transport, and resty is a fluent wrapper around it. For a non-streaming chat completion, resty gives you automatic JSON encoding of the request body and decoding of the response, plus query parameter building and header chaining. net/http makes you marshal the body, set Content-Type, and decode the response manually.

Where LLM APIs get weird is streaming. The server replies with text/event-stream, and you must read resp.Body incrementally, parse SSE frames, and handle partial JSON. Neither library parses SSE for you. With net/http you get http.Response and an io.ReadCloser directly. With resty you must call SetDoNotParseResponse(true) to bypass its automatic consumption of the body, then read resp.RawBody().

An inference gateway such as n4n.ai honors client routing directives and forwards provider cache-control hints via headers; both libraries set those equally well, though net/http forces explicit Header.Set calls while resty collects them in a fluent chain.

Price and Dependency Cost

Neither library costs money. Both are MIT-licensed. The real cost is dependency surface and accidental spend.

net/http ships in the standard library. Zero external modules, zero supply-chain risk, no version drift.

resty is github.com/go-resty/resty/v2. In practice it has no mandatory third-party transitive dependencies—it is pure Go—but it still adds a package to your module graph and tens of kilobytes to a statically linked binary. That is negligible for a service, but matters if you are building a tiny CLI or a WASI plugin.

The stealth cost is retries. resty has built-in retry with SetRetryCount and default backoff. If you enable it naively, it may retry on HTTP 429 or 500 by re-sending the same completion request. Because LLM calls are not idempotent (different sampling, same token cost), a blind retry doubles spend. net/http forces you to write the retry loop, which makes the idempotency decision explicit.

Latency and Throughput

For a request/response completion, resty’s reflection-based binding adds a small allocation overhead—typically sub-millisecond on modern hardware. Not worth worrying about.

For long responses (e.g., 8K token completions), resty by default reads the entire body into memory and runs json.Unmarshal on it. net/http lets you stream-decode with json.Decoder or just scan the SSE stream, keeping memory flat. Under high concurrency proxying to an LLM, that difference becomes a GC pressure issue.

Both share the same http.Transport connection pooling, so keep-alive and HTTP/2 multiplexing are identical once you configure the client.

Ergonomics and Developer Experience

Here is a minimal non-streaming call with net/http:

body, _ := json.Marshal(map[string]any{
    "model":    "gpt-4o-mini",
    "messages": []map[string]string{{"role": "user", "content": "hi"}},
})
req, _ := http.NewRequest("POST", "https://api.example.com/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+os.Getenv("KEY"))

resp, err := http.DefaultClient.Do(req)
if err != nil {
    // handle
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)

With resty:

resp, err := resty.New().R().
    SetHeader("Authorization", "Bearer "+os.Getenv("KEY")).
    SetBody(map[string]any{
        "model":    "gpt-4o-mini",
        "messages": []map[string]string{{"role": "user", "content": "hi"}},
    }).
    Post("https://api.example.com/v1/chat/completions")

The resty version is shorter and the intent is clearer. For a one-off script, that matters.

Streaming flips the equation. With net/http you already have the body; with resty you must remember the SetDoNotParseResponse flag or you will block on a never-ending response:

resp, err := resty.New().R().
    SetDoNotParseResponse(true).
    SetBody(streamReq).
    Post(url)
if err != nil { /* ... */ }
defer resp.RawBody().Close()
scanner := bufio.NewScanner(resp.RawBody())
for scanner.Scan() {
    // parse SSE line
}

Ecosystem and Community

net/http is the lingua franca. Every observability tracer, httptest server, and middleware framework targets it. If you need OpenTelemetry instrumentation on LLM calls, you wrap http.RoundTripper. If you need to record golden-file tests, httptest.ResponseRecorder is native.

resty has a decent following in CLI tools and small services, but you will occasionally fight its opinionated response handling when integrating with lower-level middleware. Its middleware model exists (resty.OnAfterResponse) but is less universal than RoundTripper.

Limits and Sharp Edges

  • resty default timeout is zero (no timeout) unless you set SetTimeout. net/http also has no default timeout on DefaultClient, but the pattern of constructing a http.Client{Timeout: ...} is more idiomatic and visible.
  • resty retries on network errors and, by default, on 5xx if you set retry count. You must explicitly restrict to RetryConditions to avoid resending generative POSTs.
  • net/http gives you full control of Request.Context for cancellation; resty exposes SetContext but hides the underlying request until execution.
  • Both handle streaming poorly if you forget to flush or if you use a buffered scanner with lines longer than 64KB (SSE can send large JSON blobs). Use bufio.Scanner with a larger buffer or bufio.Reader.

Head-to-Head Summary

Dimension net/http resty
Dependency footprint stdlib, zero external single module, no trans deps
JSON (de)serialization manual encoding/json automatic via reflection
Streaming SSE raw Body out of box raw via SetDoNotParseResponse
Retries manual loop, explicit built-in, needs guardrails
Request construction verbose, explicit fluent, concise
Header / routing control Header.Set chainable SetHeader
Memory under long output flat with streaming buffers full body by default
Ecosystem fit universal (otel, httptest) narrower, CLI-oriented

Which to Choose

Use net/http if:

  • You are building a library, SDK, or long-running proxy that fronts multiple LLM providers.
  • You need streaming completions with bounded memory.
  • You want zero dependency surface and explicit control over retries and timeouts.
  • You are already instrumenting http.RoundTripper for metrics.

Use resty if:

  • You are writing a throwaway script, a small admin tool, or a test harness that fires a few non-streaming requests.
  • You value concise request building over fine-grained lifecycle control.
  • You will remember to disable auto-response parsing for streams and to scope retries to connection errors only.

For a production service that calls an LLM gateway with routing directives and cache hints: the client library is incidental. Pick net/http unless your team has standardized on resty for other HTTP calls—then isolate the LLM client behind an interface so the transport can be swapped without touching business logic.

Tagsgolangnet-httprestycomparison

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 →