module LLM::HttpTransport

Overview

Shared HTTP transport for the provider clients (OpenAI-compatible and Ollama).

Both clients used to post through the bare one-shot helpers (HTTP::Client.post / Crest.post), which set no timeouts at all: a provider that accepts the connection and then never answers — a wedged local ollama serve, a proxy that black-holes the request, a hosted endpoint that stalls mid-generation — hung the scan forever, with no output and no indication of what it was waiting on. Requests now fail after a bounded wait.

It also retries the transient failures every hosted provider produces (429 rate limits, 502/503 from a load balancer, a dropped socket). Without that, one blip turned into a silent zero-endpoint AI result: the clients map any failure to "", which the analyzer treats as "this code defines no endpoints".

Defined in:

llm/http_transport.cr

Constant Summary

CONNECT_TIMEOUT_ENV = "NOIR_AI_CONNECT_TIMEOUT"
DEFAULT_CONNECT_TIMEOUT = 10.seconds

Connecting is fast even for remote providers, so a short budget here only shortens the "server isn't running" feedback loop that local provider users (ollama, vLLM, LM Studio) hit most often.

DEFAULT_TIMEOUT = 300.seconds

Generation is not fast: a large bundle against a CPU-bound local model legitimately takes minutes, so the read budget is generous and tunable rather than tight.

MAX_ATTEMPTS = 3
MAX_ERROR_SNIPPET_SIZE = 1024
MAX_RETRY_AFTER = 30.seconds

Cap on how long a provider's Retry-After may park the scan.

RETRYABLE_STATUS = Set {408, 425, 429, 500, 502, 503, 504, 529}

Status codes worth another attempt: rate limits plus the transient gateway/overload family. A 400/401/404 is a configuration problem that retrying can only make slower.

TIMEOUT_ENV = "NOIR_AI_TIMEOUT"

Class Method Summary

Class Method Detail

def self.backoff(attempt : Int32) : Time::Span #

1s, then 2s — bounded, and short enough that exhausting all attempts still returns while the caller is waiting.


[View source]
def self.connect_timeout : Time::Span #

[View source]
def self.duration_from_env(name : String) : Time::Span | Nil #

Reads a timeout override, in seconds. Anything that isn't a positive number (empty, 0, a word) falls back to the default instead of disabling the timeout — the point of the override is to move the bound, not to remove it.


[View source]
def self.post_json(url : String, body : String, headers : HTTP::Headers) : String | Nil #

POSTs a JSON body and returns the response body, or nil when the request could not be completed. Failures are reported here so every provider path surfaces them the same way instead of each client inventing its own (or, in Ollama's case, staying silent).


[View source]
def self.retry_after(response : HTTP::Client::Response | Nil) : Time::Span | Nil #

A provider that tells us when to come back (429s usually do) knows better than the fixed backoff, as long as it stays within our cap.


[View source]
def self.retry_delay(response : HTTP::Client::Response | Nil, attempt : Int32) : Time::Span #

[View source]
def self.retryable_status?(code : Int32) : Bool #

[View source]
def self.timeout : Time::Span #

[View source]
def self.truncate_error_snippet(body : String) : String #

[View source]