L2 · 03 · Calling LLM APIs Properly

The Level 1 examples made a single happy-path HTTP call. Real code needs configuration, typed requests, timeouts, retries with back-off, streaming, token and cost tracking, and good error handling. This page builds a small, production-shaped LLM client in Scala 3 (sttp + circe) and Python (httpx).

Level 260 minTrack 10 · Building with LLM APIsLast verified 2026-10

By the end of this page you can

Anatomy of an LLM API call

Your serviceStore Assistant LLM client (your code) • config from env (URL, model, key)• typed request / response• timeouts (connect + read)• retry 429/5xx with back-off• streaming• usage & cost logging (no secrets) Hosted provider (HTTPS + key) Company AI gateway Local model (Ollama / LM Studio)
Many providers, gateways and local runners accept the same OpenAI-compatible request shape, so one client can target any of them just by changing configuration.

Configuration, never hard-coded

VariableExampleNotes
LLM_BASE_URLhttp://localhost:11434/v1Local Ollama; or your provider/gateway URL
LLM_MODELllama3.2Pin an exact model/version in production
LLM_API_KEY(from a secret manager)Never in code, git, logs or prompts
At work, use the approved route Many companies require AI traffic to go through an internal gateway (for logging, data protection and cost control) and only allow approved models. Your client should make the base URL configurable so it can point wherever policy says.

Which errors to retry

StatusMeaningRetry?Action
400Bad request (invalid params, context too long, schema invalid)NoFix the request; for context length, trim input
401 / 403Bad or missing key / not permittedNoFix credentials or permissions
404Unknown model or endpointNoCheck model name and base URL
408 / timeouts / connection resetTransient network issueYesBack off and retry
429Rate limit or quotaYesHonour Retry-After; slow down; queue
500 / 502 / 503 / 504 (and overload codes)Server-side problem or overloadYesBack off; consider a fallback model

Exponential back-off with jitter

Wait base × 2attempt, capped, multiplied by a random factor. The randomness ("jitter") stops many clients from retrying in lock-step and overloading the server again. Limit total attempts (3–5) and total time.

Scala 3 client (sttp + circe)

//> using scala 3
//> using dep com.softwaremill.sttp.client4::core:4.0.25
//> using dep io.circe::circe-core:0.14.15
//> using dep io.circe::circe-parser:0.14.15
import io.circe.{Codec, Decoder, Encoder}
import io.circe.parser.decode
import io.circe.syntax.*
import sttp.client4.*
import sttp.model.Uri
import scala.concurrent.duration.*
import scala.util.{Random, Try}

// ---------- Wire types (OpenAI-compatible chat completions) ----------
final case class Message(role: String, content: String) derives Codec.AsObject
final case class ChatRequest(model: String, messages: List[Message], temperature: Double, max_tokens: Int)
    derives Encoder.AsObject
final case class Usage(prompt_tokens: Int, completion_tokens: Int) derives Decoder
final case class Choice(message: Message, finish_reason: Option[String]) derives Decoder
final case class ChatResponse(choices: List[Choice], usage: Option[Usage]) derives Decoder:
  def text: String = choices.headOption.map(_.message.content).getOrElse("")

// ---------- Config & errors ----------
final case class LlmConfig(baseUrl: String, model: String, apiKey: Option[String])
object LlmConfig:
  def fromEnv: LlmConfig = LlmConfig(
    baseUrl = sys.env.getOrElse("LLM_BASE_URL", "http://localhost:11434/v1"),
    model   = sys.env.getOrElse("LLM_MODEL", "llama3.2"),
    apiKey  = sys.env.get("LLM_API_KEY")
  )

enum LlmError:
  case Http(status: Int, body: String, retryAfterSec: Option[Int])
  case Network(message: String)
  case Decode(message: String)

  def retryable: Boolean = this match
    case Http(s, _, _) => Set(408, 409, 429, 500, 502, 503, 504)(s)
    case Network(_)    => true
    case Decode(_)     => false

// ---------- Client ----------
final class LlmClient(cfg: LlmConfig, backend: SyncBackend = DefaultSyncBackend()):
  private val endpoint = Uri.unsafeParse(s"${cfg.baseUrl.stripSuffix("/")}/chat/completions")

  def chat(
      messages: List[Message],
      temperature: Double = 0.2,
      maxTokens: Int = 800,
      maxAttempts: Int = 4
  ): Either[LlmError, ChatResponse] =
    val body = ChatRequest(cfg.model, messages, temperature, maxTokens).asJson.noSpaces

    def once(): Either[LlmError, ChatResponse] =
      val base   = basicRequest.post(endpoint).contentType("application/json").body(body).readTimeout(90.seconds)
      val req    = cfg.apiKey.fold(base)(key => base.auth.bearer(key))
      Try(req.send(backend)).toEither.left.map(e => LlmError.Network(e.toString)).flatMap { resp =>
        resp.body match
          case Right(json) => decode[ChatResponse](json).left.map(e => LlmError.Decode(e.getMessage))
          case Left(err)   => Left(LlmError.Http(resp.code.code, err.take(500), resp.header("Retry-After").flatMap(_.toIntOption)))
      }

    def backoff(attempt: Int, retryAfter: Option[Int]): FiniteDuration =
      retryAfter.map(_.seconds).getOrElse:
        val capMs = math.min(30000.0, 500.0 * math.pow(2, attempt))
        (capMs * (0.5 + Random.nextDouble() / 2)).toLong.millis

    def loop(attempt: Int): Either[LlmError, ChatResponse] =
      once() match
        case Left(e) if e.retryable && attempt < maxAttempts =>
          val wait = backoff(attempt, e match { case LlmError.Http(_, _, ra) => ra; case _ => None })
          System.err.println(s"[llm] attempt $attempt failed ($e), retrying in ${wait.toMillis} ms")
          Thread.sleep(wait.toMillis)
          loop(attempt + 1)
        case result =>
          result.foreach(r => r.usage.foreach(u => System.err.println(s"[llm] tokens in=${u.prompt_tokens} out=${u.completion_tokens}")))
          result

    loop(1)

/** Prices are per million tokens and vary by model: read them from config, don't hard-code. */
def estimateCostUsd(u: Usage, inPerMillion: BigDecimal, outPerMillion: BigDecimal): BigDecimal =
  (BigDecimal(u.prompt_tokens) * inPerMillion + BigDecimal(u.completion_tokens) * outPerMillion) / 1000000

@main def ask(question: String): Unit =
  val client = LlmClient(LlmConfig.fromEnv)
  val result = client.chat(List(
    Message("system", "You are a concise assistant for retail store staff."),
    Message("user", question)
  ))
  result match
    case Right(r) =>
      println(r.text)
      if r.choices.headOption.flatMap(_.finish_reason).contains("length") then
        System.err.println("[llm] warning: answer was cut off (max_tokens reached)")
    case Left(err) => System.err.println(s"LLM call failed: $err")
# Local model, no key needed:
ollama pull llama3.2
scala-cli LlmClient.scala -- "How should staff handle a pickup order that is not on the shelf?"

# Hosted provider or company gateway:
export LLM_BASE_URL="https://<your-provider-or-gateway>/v1"
export LLM_MODEL="<approved-model>"
export LLM_API_KEY="$(<read from your secret manager>)"

Design points:

Python client (httpx)

import json
import os
import random
import sys
import time
from collections.abc import Iterator

import httpx

RETRYABLE = {408, 409, 429, 500, 502, 503, 504}


class LlmClient:
    def __init__(self) -> None:
        self.base_url = os.environ.get("LLM_BASE_URL", "http://localhost:11434/v1").rstrip("/")
        self.model = os.environ.get("LLM_MODEL", "llama3.2")
        key = os.environ.get("LLM_API_KEY")
        self.headers = {"Authorization": f"Bearer {key}"} if key else {}
        self.http = httpx.Client(timeout=httpx.Timeout(90.0, connect=10.0))

    def _wait(self, attempt: int, retry_after: str | None) -> float:
        if retry_after and retry_after.isdigit():
            return float(retry_after)
        return min(30.0, 0.5 * 2**attempt) * random.uniform(0.5, 1.0)

    def chat(self, messages: list[dict], temperature: float = 0.2, max_tokens: int = 800, max_attempts: int = 4) -> dict:
        payload = {"model": self.model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens}
        for attempt in range(1, max_attempts + 1):
            try:
                r = self.http.post(f"{self.base_url}/chat/completions", json=payload, headers=self.headers)
            except (httpx.ConnectError, httpx.ReadTimeout, httpx.RemoteProtocolError) as e:
                if attempt == max_attempts:
                    raise
                wait = self._wait(attempt, None)
                print(f"[llm] network error {e!r}, retrying in {wait:.1f}s", file=sys.stderr)
                time.sleep(wait)
                continue
            if r.status_code in RETRYABLE and attempt < max_attempts:
                wait = self._wait(attempt, r.headers.get("retry-after"))
                print(f"[llm] HTTP {r.status_code}, retrying in {wait:.1f}s", file=sys.stderr)
                time.sleep(wait)
                continue
            r.raise_for_status()
            data = r.json()
            if usage := data.get("usage"):
                print(f"[llm] tokens in={usage['prompt_tokens']} out={usage['completion_tokens']}", file=sys.stderr)
            return data
        raise RuntimeError("unreachable")

    def stream(self, messages: list[dict], temperature: float = 0.2) -> Iterator[str]:
        """Yield text fragments as they arrive (Server-Sent Events)."""
        payload = {"model": self.model, "messages": messages, "temperature": temperature, "stream": True}
        with self.http.stream("POST", f"{self.base_url}/chat/completions", json=payload, headers=self.headers) as r:
            r.raise_for_status()
            for line in r.iter_lines():
                if not line.startswith("data: "):
                    continue
                chunk = line.removeprefix("data: ")
                if chunk == "[DONE]":
                    break
                delta = json.loads(chunk)["choices"][0].get("delta", {}).get("content")
                if delta:
                    yield delta


if __name__ == "__main__":
    client = LlmClient()
    question = " ".join(sys.argv[1:]) or "Give 3 tips for handling a busy in-store pickup counter."
    for piece in client.stream([{"role": "user", "content": question}]):
        print(piece, end="", flush=True)
    print()

Streaming

With "stream": true, the server sends the answer as a sequence of Server-Sent Events while it is being generated. Users see text appear immediately instead of waiting 20 seconds for the full answer.

data: {"choices":[{"delta":{"role":"assistant","content":""}}]}
data: {"choices":[{"delta":{"content":"Check"}}]}
data: {"choices":[{"delta":{"content":" the back"}}]}
data: {"choices":[{"delta":{"content":" room first."}}]}
data: {"choices":[{"delta":{},"finish_reason":"stop"}]}
data: [DONE]
//> using scala 3
//> using dep io.circe::circe-parser:0.14.15
import io.circe.parser.parse
import java.net.URI
import java.net.http.{HttpClient, HttpRequest, HttpResponse}
import scala.jdk.CollectionConverters.*

@main def streamDemo(question: String): Unit =
  val base  = sys.env.getOrElse("LLM_BASE_URL", "http://localhost:11434/v1")
  val model = sys.env.getOrElse("LLM_MODEL", "llama3.2")
  val body  = io.circe.Json.obj(
    "model"    -> io.circe.Json.fromString(model),
    "stream"   -> io.circe.Json.True,
    "messages" -> io.circe.Json.arr(io.circe.Json.obj(
      "role" -> io.circe.Json.fromString("user"), "content" -> io.circe.Json.fromString(question)))
  ).noSpaces

  val builder = HttpRequest.newBuilder(URI.create(s"$base/chat/completions"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
  sys.env.get("LLM_API_KEY").foreach(k => builder.header("Authorization", s"Bearer $k"))

  val lines = HttpClient.newHttpClient().send(builder.build(), HttpResponse.BodyHandlers.ofLines()).body()
  lines.iterator().asScala
    .collect { case l if l.startsWith("data: ") => l.stripPrefix("data: ") }
    .takeWhile(_ != "[DONE]")
    .flatMap(chunk => parse(chunk).toOption.flatMap(_.hcursor.downField("choices").downN(0).downField("delta").get[String]("content").toOption))
    .foreach(piece => print(piece))
  println()

Tokens, cost and latency

SDKs vs raw HTTP

Official SDKs

Typed models, built-in retries, streaming helpers, and provider-specific features. Best when you commit to one provider. Several providers publish Java SDKs usable from Scala.

Raw HTTP (this page)

You understand every byte, it's portable across providers and gateways, and it has few dependencies. Best for learning, for gateways, and for multi-provider setups.

Production checklist

Try it (30 min)
  1. Install Ollama, run ollama pull llama3.2, and run LlmClient.scala and llm_client.py.
  2. Stop Ollama and run again. Watch the retries and back-off in the log.
  3. Set LLM_MODEL to a model that doesn't exist. Confirm you get a non-retried error.
  4. Combine with page 02: send the return-request schema in response_format and decode the answer into ReturnRequest.

Key takeaways