llm_conductor 1.8.3 → 1.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 669ae889bc5b65b4455617425a5c3628a66ed21741eca0930c78652f4b8bfbad
4
- data.tar.gz: 396219406bf2db7236c223cc36200dc1be2739d6a87ab6cc50ccdc75be7482ff
3
+ metadata.gz: ae52774bedf1066c7a420926f11404840691b648189698d0a8ba6ba9a3c60aa1
4
+ data.tar.gz: 320524f99596447ffe77593901cb15a0b97143c6611225726371b8bedf25b1fc
5
5
  SHA512:
6
- metadata.gz: 9e71aaa12abdf4c1bf1d750f4e28b41508d5e0b999d8373858e5ad0a16d8ea7f24c059553e59b5b38377b262bd5e6963f0a424b745d9f58e064d4135d4c05779
7
- data.tar.gz: 2ab40251e03a5b8eb01bb6d7cfdbe0bac4b1137768b4c42b991da8649ec146698d17e012d076aa102643e37ddaa94951552e8e0c8f80b68ac14fd7875f7e8cdd
6
+ metadata.gz: 5b2d29f7a17e40c0fc12162ebda9057575b9db4576e4b62dbf3223be201ca82c12e39ca946cf5d85344e435d7039838562fdf2f9b97e3355ee831ee62c06d209
7
+ data.tar.gz: 41dfa8fd00c686a9d032a6d47f4953a401e2cb8e306c315f69d016277bc2b163950a660fdba0a483518c802905fe36ec349833c631c5de9e7019ec909bd8b98a
data/.rubocop.yml CHANGED
@@ -40,8 +40,6 @@ Metrics/MethodLength:
40
40
  Max: 15
41
41
  Exclude:
42
42
  - 'lib/llm_conductor/prompts.rb'
43
- - 'lib/llm_conductor/clients/openrouter_client.rb'
44
- - 'lib/llm_conductor/clients/zai_client.rb'
45
43
  - 'lib/llm_conductor/client_factory.rb'
46
44
  - 'lib/llm_conductor/eval/**/*'
47
45
  - 'examples/*.rb'
@@ -101,8 +99,6 @@ Metrics/BlockLength:
101
99
  Metrics/AbcSize:
102
100
  Exclude:
103
101
  - 'lib/llm_conductor/prompts.rb'
104
- - 'lib/llm_conductor/clients/openrouter_client.rb'
105
- - 'lib/llm_conductor/clients/zai_client.rb'
106
102
  - 'lib/llm_conductor/eval/**/*'
107
103
  - 'examples/*.rb'
108
104
 
@@ -117,16 +113,12 @@ Metrics/CyclomaticComplexity:
117
113
  Exclude:
118
114
  - 'lib/llm_conductor.rb'
119
115
  - 'lib/llm_conductor/prompts.rb'
120
- - 'lib/llm_conductor/clients/openrouter_client.rb'
121
- - 'lib/llm_conductor/clients/zai_client.rb'
122
116
  - 'lib/llm_conductor/eval/**/*'
123
117
  - 'examples/*.rb'
124
118
 
125
119
  Metrics/PerceivedComplexity:
126
120
  Exclude:
127
121
  - 'lib/llm_conductor/prompts.rb'
128
- - 'lib/llm_conductor/clients/openrouter_client.rb'
129
- - 'lib/llm_conductor/clients/zai_client.rb'
130
122
  - 'lib/llm_conductor/eval/**/*'
131
123
 
132
124
  Layout/LineLength:
data/README.md CHANGED
@@ -9,6 +9,7 @@ A unified Ruby interface for multiple Language Model providers from [Ekohe](http
9
9
  - 🖼️ **Vision Support** - Send images alongside text (OpenAI, Anthropic, OpenRouter, Z.ai, Gemini)
10
10
  - 🔧 **Custom Parameters** - Fine-tune with temperature, top_p, and more
11
11
  - 💰 **Cost Tracking** - Automatic token counting and cost estimation
12
+ - ♻️ **Automatic Retries** - Exponential backoff with jitter on rate limits and transient errors
12
13
  - ⚡ **Smart Configuration** - Environment variables or code-based setup
13
14
  - 📊 **Model Evaluation** - Compare models/vendors on cost, latency, tokens & LLM-judged quality (opt-in)
14
15
 
@@ -91,7 +92,11 @@ export ZAI_API_KEY=your-key-here
91
92
  ```ruby
92
93
  LlmConductor.configure do |config|
93
94
  config.default_model = 'gpt-4o-mini'
94
-
95
+
96
+ # Retries on transient failures (see "Retries & Rate Limits" below)
97
+ config.max_retries = 3 # total attempts, including the first
98
+ config.retry_delay = 1.0 # seconds; seeds the exponential backoff
99
+
95
100
  config.openai(api_key: ENV['OPENAI_API_KEY'])
96
101
  config.anthropic(api_key: ENV['ANTHROPIC_API_KEY'])
97
102
  config.gemini(api_key: ENV['GEMINI_API_KEY'])
@@ -102,6 +107,28 @@ LlmConductor.configure do |config|
102
107
  end
103
108
  ```
104
109
 
110
+ ### Retries & Rate Limits
111
+
112
+ Transient provider failures are retried automatically for **every** provider — the
113
+ policy lives in `BaseClient`, driven by the two settings above.
114
+
115
+ ```ruby
116
+ config.max_retries = 3 # TOTAL attempts, not extra ones. 1 disables retrying.
117
+ config.retry_delay = 1.0 # first retry after ~1s, the next after ~2s (doubling)
118
+ ```
119
+
120
+ **Retried**: `429`, `500`, `502`, `503`, `504`, `408`, and connection/read timeouts.
121
+ **Not retried**: `400`, `401`, `403`, `404`, `422` and TLS errors — a malformed
122
+ request or a bad credential is identical on attempt two, so it surfaces immediately.
123
+
124
+ Backoff is exponential with additive jitter, so a retry never fires sooner than
125
+ configured but concurrent workers do not retry in lockstep. A `Retry-After` header
126
+ takes precedence when the provider sends one. With `config.logger` set, each retry
127
+ logs one line (attempt, status, delay — never the API key).
128
+
129
+ Retries ride out short spikes; they do not survive a sustained quota outage.
130
+ See the **[Retries & Rate Limits Guide](docs/retries.md)** for the full policy.
131
+
105
132
  ## Supported Providers
106
133
 
107
134
  | Provider | Auto-Detect | Vision | Custom Params |
@@ -332,6 +359,7 @@ See [`examples/model_eval_usage.rb`](examples/model_eval_usage.rb) for a complet
332
359
  ## Documentation
333
360
 
334
361
  - **[Custom Parameters Guide](docs/custom-parameters.md)** - Temperature, top_p, and more
362
+ - **[Retries & Rate Limits Guide](docs/retries.md)** - Backoff, jitter and which statuses are retried
335
363
  - **[Vision Support Guide](docs/vision-support.md)** - Using images with LLMs
336
364
  - **[Model Evaluation](#model-evaluation-opt-in)** - Compare models on cost, latency & LLM-judged quality
337
365
  - **[Examples](examples/)** - Working code examples for all providers
@@ -343,6 +371,7 @@ Check the [examples/](examples/) directory for comprehensive examples:
343
371
  - `simple_usage.rb` - Basic text generation
344
372
  - `ollama_params_usage.rb` - Custom parameters with Ollama
345
373
  - `gemini_thinking_usage.rb` - Gemini thinking configuration (`thinkingConfig`)
374
+ - `retry_config_usage.rb` - Retry configuration and the resulting backoff curve
346
375
  - `gpt_vision_usage.rb` - Vision with OpenAI
347
376
  - `claude_vision_usage.rb` - Vision with Anthropic
348
377
  - `gemini_vision_usage.rb` - Vision with Gemini
@@ -8,6 +8,10 @@ LlmConductor.configure do |config|
8
8
  config.default_model = 'gpt-5-mini'
9
9
  config.default_vendor = :openai
10
10
  config.timeout = 30
11
+ # Retries on transient failures (429/5xx, connection & read timeouts).
12
+ # max_retries is the TOTAL attempt budget including the first call, so 3 means
13
+ # one call plus at most two retries. retry_delay seeds an exponential backoff
14
+ # (~1s, ~2s, ~4s ...) with jitter. See docs/retries.md.
11
15
  config.max_retries = 3
12
16
  config.retry_delay = 1.0
13
17
  # Use Ruby's built-in Logger class directly
data/docs/README.md CHANGED
@@ -14,6 +14,15 @@ Learn how to fine-tune LLM generation with parameters like `temperature`, `top_p
14
14
  **Currently supported**: Ollama, Gemini (including Gemini 3.x `thinkingConfig`)
15
15
  **Coming soon**: OpenAI, Anthropic, Groq, OpenRouter, Z.ai
16
16
 
17
+ ### [Retries & Rate Limits](retries.md)
18
+ How `max_retries` and `retry_delay` drive automatic retries. Includes:
19
+ - Which statuses are retried, and which deliberately are not
20
+ - The exponential backoff formula and its jitter
21
+ - `Retry-After` handling
22
+ - What retries fix, and what they do not
23
+
24
+ **Applies to**: every provider — the policy lives in `BaseClient`
25
+
17
26
  ### [Vision Support](vision-support.md)
18
27
  Complete guide to using vision/multimodal capabilities. Includes:
19
28
  - Sending images with text prompts
data/docs/retries.md ADDED
@@ -0,0 +1,130 @@
1
+ # Retries & Rate Limits Guide
2
+
3
+ Transient provider failures — a rate limit, an overloaded region, a dropped
4
+ connection — are retried automatically, with exponential backoff seeded from your
5
+ configuration.
6
+
7
+ ## 🚀 Quick Reference
8
+
9
+ ```ruby
10
+ LlmConductor.configure do |config|
11
+ config.max_retries = 3 # total attempts, including the first one
12
+ config.retry_delay = 1.0 # seconds; seeds the exponential backoff
13
+ config.logger = Logger.new($stdout) # optional: one warn line per retry
14
+ end
15
+ ```
16
+
17
+ | Setting | Default | Meaning |
18
+ |---------|---------|---------|
19
+ | `max_retries` | `3` | **Total attempts**, not extra ones. `3` = one call plus at most two retries. `1` (or `0`) disables retrying. |
20
+ | `retry_delay` | `1.0` | Seconds before the first retry. Each further retry doubles it. |
21
+
22
+ ### What gets retried
23
+
24
+ | Failure | Retried | Why |
25
+ |---------|---------|-----|
26
+ | `429 Too Many Requests` | ✅ | Rate limit / saturated quota pool — the next attempt may land |
27
+ | `500`, `502`, `503`, `504` | ✅ | Server-side overload or a bad gateway hop |
28
+ | `408 Request Timeout` | ✅ | Same as a timeout, expressed as a status |
29
+ | Connection & read timeouts | ✅ | `Faraday::ConnectionFailed`, `Faraday::TimeoutError`, `Net::ReadTimeout`, `Net::OpenTimeout`, `Errno::ECONNRESET` |
30
+ | `400`, `401`, `403`, `404`, `422` | ❌ | A malformed request or a bad credential is identical on attempt two |
31
+ | TLS/SSL errors | ❌ | A certificate problem does not resolve itself |
32
+ | Anything else | ❌ | Surfaces on the first attempt, unchanged |
33
+
34
+ ---
35
+
36
+ ## Overview
37
+
38
+ `Configuration` has declared `max_retries` and `retry_delay` for a long time, but
39
+ until 1.8.4 nothing read them: `OpenrouterClient` and `ZaiClient` each carried
40
+ their own hardcoded loop, and `GeminiClient` had no retry at all — a single Vertex
41
+ 429 became an immediate, user-visible error.
42
+
43
+ The policy now lives in one place, `LlmConductor::Clients::Concerns::Retryable`,
44
+ mixed into `BaseClient`. Every provider request — `generate` and
45
+ `generate_simple` alike — goes through it, so the two configuration knobs mean the
46
+ same thing everywhere.
47
+
48
+ ## Backoff
49
+
50
+ ```
51
+ delay = min(retry_delay * 2 ** (attempt - 1), 30.0)
52
+ delay += delay * 0.25 * rand # jitter
53
+ ```
54
+
55
+ With the defaults (`retry_delay = 1.0`, `max_retries = 3`):
56
+
57
+ | Attempt | Fails with | Waits |
58
+ |---------|------------|-------|
59
+ | 1 | 429 | ~1.0–1.25s |
60
+ | 2 | 429 | ~2.0–2.5s |
61
+ | 3 | 429 | — raises / returns an error `Response` |
62
+
63
+ The jitter is **additive**, so a retry never fires sooner than you configured; its
64
+ purpose is to stop a fleet of concurrent workers from retrying in lockstep against
65
+ the same shared quota pool. The computed backoff is capped at 30 seconds so a
66
+ generous `retry_delay` cannot park a worker for minutes.
67
+
68
+ ## Retry-After
69
+
70
+ When a provider sends a `Retry-After` header, it replaces the computed backoff.
71
+ Both formats are understood — a delay in seconds (`Retry-After: 7`) and an
72
+ HTTP-date (`Retry-After: Wed, 26 Aug 2026 10:20:00 GMT`). The value is capped at
73
+ 60 seconds: past that, failing fast and letting the caller decide beats blocking a
74
+ worker. An unparseable value is ignored and the computed backoff is used instead.
75
+
76
+ ## Logging
77
+
78
+ With a logger configured, each retry emits exactly one `warn` line:
79
+
80
+ ```
81
+ [LlmConductor::Clients::GeminiClient] transient HTTP 429 on attempt 1/3; retrying in 1.07s
82
+ ```
83
+
84
+ The line deliberately carries only the class/status, the attempt and the delay.
85
+ The exception's message and response body carry the request URL, and for Gemini's
86
+ generative-language endpoint that URL embeds the API key as a query parameter — so
87
+ neither is ever logged.
88
+
89
+ ## What retries do *not* fix
90
+
91
+ Retries ride out **short spikes**: a burst of 429s while a shared quota pool
92
+ drains, a region hiccup, a dropped connection. They do not survive a **sustained**
93
+ quota outage — if the pool stays saturated for minutes, every attempt inside the
94
+ budget fails and the error surfaces exactly as it did before, just later. For that
95
+ you need capacity changes on the provider side (a dedicated quota, a different
96
+ region or model), a queue that can defer the work, or a fallback vendor.
97
+
98
+ ## Errors after the budget is exhausted
99
+
100
+ Nothing changes about how failures reach you. `generate` and `generate_simple`
101
+ still return a `Response`, and the last error is reported on it:
102
+
103
+ ```ruby
104
+ response = LlmConductor.generate(model: 'gemini-2.5-flash', prompt: 'Hi', vendor: :gemini)
105
+
106
+ unless response.success?
107
+ puts response.metadata[:error] # e.g. "the server responded with status 429"
108
+ puts response.metadata[:error_class] # e.g. "Faraday::TooManyRequestsError"
109
+ end
110
+ ```
111
+
112
+ ## Per-provider notes
113
+
114
+ - **Gemini** — gemini-ai re-raises Faraday 5xx as `Gemini::Errors::RequestError`,
115
+ keeping the original in `#request`; 429s arrive as a bare
116
+ `Faraday::TooManyRequestsError`. Both shapes are recognised.
117
+ - **OpenRouter** — previously retried 502 only, five times, on a hardcoded
118
+ `2**n` backoff. It now uses your configuration, and covers rate limits and
119
+ timeouts as well as 502.
120
+ - **Z.ai** — its Faraday stack has no `raise_error` middleware, so HTTP errors
121
+ come back as a normal response with an error body rather than a raised
122
+ exception. The retry policy never sees them. This predates 1.8.4 (the hardcoded
123
+ `rescue Faraday::ServerError` there was dead code for the same reason) and is
124
+ left for a separate change, since adding the middleware would change what Z.ai
125
+ callers observe on failure.
126
+
127
+ ## Example
128
+
129
+ See [`examples/retry_config_usage.rb`](../examples/retry_config_usage.rb) for a
130
+ runnable walkthrough of the knobs and the resulting backoff.
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Retry configuration (config.max_retries / config.retry_delay).
5
+ #
6
+ # Transient provider failures — a 429 from a saturated quota pool, a 503 from an
7
+ # overloaded region, a dropped connection — are retried with exponential backoff
8
+ # seeded from retry_delay, up to max_retries *total* attempts (so the default of 3
9
+ # means one call plus at most two retries).
10
+ #
11
+ # Statuses that will not change on a second identical request (400, 401, 403, 404,
12
+ # 422) are never retried; they surface on the first attempt.
13
+ #
14
+ # GEMINI_API_KEY=... ruby examples/retry_config_usage.rb
15
+ #
16
+ # The last section needs no API key: it prints the backoff curve so you can see
17
+ # what a given retry_delay actually costs before you set it in production.
18
+
19
+ require_relative '../lib/llm_conductor'
20
+ require 'logger'
21
+
22
+ MODEL = ENV['MODEL'] || 'gemini-2.5-flash'
23
+ PROMPT = 'Name the capital of France. Answer with one word.'
24
+
25
+ LlmConductor.configure do |config|
26
+ config.gemini(api_key: ENV['GEMINI_API_KEY']) if ENV['GEMINI_API_KEY']
27
+
28
+ # Total attempts, including the first. Set to 1 to disable retrying.
29
+ config.max_retries = 3
30
+
31
+ # Seconds before the first retry; each further retry doubles it.
32
+ config.retry_delay = 1.0
33
+
34
+ # With a logger configured, every retry emits one warn line naming the attempt,
35
+ # the status and the delay. The API key is never part of it.
36
+ config.logger = Logger.new($stdout)
37
+ end
38
+
39
+ if ENV['GEMINI_API_KEY']
40
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
+ response = LlmConductor.generate(model: MODEL, prompt: PROMPT, vendor: :gemini)
42
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
43
+
44
+ if response.success?
45
+ puts "OK in #{elapsed.round(2)}s — #{response.output.to_s.strip}"
46
+ else
47
+ # Reached only after the whole attempt budget is spent. Retries ride out short
48
+ # spikes; they do not survive a sustained quota outage.
49
+ puts "FAILED after #{elapsed.round(2)}s — #{response.metadata[:error]}"
50
+ puts " error class: #{response.metadata[:error_class]}"
51
+ end
52
+ else
53
+ puts 'GEMINI_API_KEY not set — skipping the live call.'
54
+ end
55
+
56
+ puts
57
+ puts 'Backoff curve (before jitter, which only ever adds):'
58
+
59
+ retryable = LlmConductor::Clients::Concerns::Retryable
60
+ [0.25, 1.0, 5.0].each do |retry_delay|
61
+ delays = (1...LlmConductor.configuration.max_retries).map do |attempt|
62
+ [retry_delay * (2**(attempt - 1)), retryable::MAX_BACKOFF_SECONDS].min
63
+ end
64
+ total = delays.sum
65
+ puts format(
66
+ ' retry_delay=%<seed>-5s waits %<waits>-22s worst case %<total>.2fs of waiting',
67
+ seed: retry_delay, waits: delays.map { |d| "#{d}s" }.join(', '), total:
68
+ )
69
+ end
70
+
71
+ puts
72
+ puts 'Retried: 429, 500, 502, 503, 504, 408, connection/read timeouts'
73
+ puts 'Not retried: 400, 401, 403, 404, 422, TLS errors — see docs/retries.md'
@@ -3,13 +3,20 @@
3
3
  require 'tiktoken_ruby'
4
4
  require 'ollama-ai'
5
5
  require 'openai'
6
+ require_relative 'concerns/retryable'
6
7
 
7
8
  module LlmConductor
8
9
  module Clients
9
10
  # Base client class providing common functionality for all LLM providers
10
11
  # including prompt building, token counting, and response formatting.
12
+ #
13
+ # Every provider request goes through Concerns::Retryable, so transient
14
+ # rate-limit/overload failures are retried according to
15
+ # configuration.max_retries and configuration.retry_delay. See
16
+ # docs/retries.md for the exact policy.
11
17
  class BaseClient
12
18
  include Prompts
19
+ include Concerns::Retryable
13
20
 
14
21
  attr_reader :model, :type, :params
15
22
 
@@ -22,7 +29,7 @@ module LlmConductor
22
29
  def generate(data:)
23
30
  prompt = build_prompt(data)
24
31
  input_tokens = calculate_tokens(prompt)
25
- output_text = generate_content(prompt)
32
+ output_text = with_retries { generate_content(prompt) }
26
33
  output_tokens = calculate_tokens(output_text || '')
27
34
 
28
35
  # Logging AI request metadata if logger is set
@@ -39,7 +46,7 @@ module LlmConductor
39
46
  # Simple generation method that accepts a direct prompt and returns a Response object
40
47
  def generate_simple(prompt:)
41
48
  input_tokens = calculate_tokens(prompt)
42
- output_text = generate_content(prompt)
49
+ output_text = with_retries { generate_content(prompt) }
43
50
  output_tokens = calculate_tokens(output_text || '')
44
51
 
45
52
  # Logging AI request metadata if logger is set
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'net/http'
5
+ require 'time'
6
+
7
+ module LlmConductor
8
+ module Clients
9
+ module Concerns
10
+ # Shared retry policy for provider API calls.
11
+ #
12
+ # +LlmConductor::Configuration+ has always declared +max_retries+ and
13
+ # +retry_delay+; this concern is what makes them mean something. It is mixed
14
+ # into BaseClient, so every provider goes through the same policy and the two
15
+ # configuration knobs behave identically everywhere.
16
+ #
17
+ # Only failures that a second identical request can plausibly fix are retried:
18
+ # the rate-limit and overload statuses in RETRYABLE_STATUSES, plus the
19
+ # connection/read timeout classes in TRANSPORT_ERRORS. Everything else —
20
+ # notably 400/401/403/404 and any other 4xx — surfaces on the first attempt,
21
+ # because retrying a malformed request or a bad credential only wastes time.
22
+ #
23
+ # Backoff is exponential and seeded from +retry_delay+:
24
+ #
25
+ # delay = min(retry_delay * 2**(attempt - 1), MAX_BACKOFF_SECONDS)
26
+ # delay += delay * JITTER_RATIO * rand
27
+ #
28
+ # The jitter is additive, so a retry never fires sooner than configured, but
29
+ # concurrent workers hitting the same shared quota pool stop retrying in
30
+ # lockstep. A +Retry-After+ header, when the API sends one, replaces the
31
+ # computed backoff (still jittered, still capped).
32
+ module Retryable
33
+ # Statuses worth a second attempt: rate limiting and transient overload.
34
+ RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504].freeze
35
+
36
+ # Transport-level failures, which carry no HTTP status at all.
37
+ TRANSPORT_ERRORS = [
38
+ Faraday::ConnectionFailed,
39
+ Faraday::TimeoutError,
40
+ Faraday::NilStatusError,
41
+ Net::OpenTimeout,
42
+ Net::ReadTimeout,
43
+ Errno::ECONNRESET,
44
+ Errno::ECONNREFUSED
45
+ ].freeze
46
+
47
+ # Ceiling for the computed exponential backoff, so a generous retry_delay
48
+ # cannot park a worker for minutes.
49
+ MAX_BACKOFF_SECONDS = 30.0
50
+
51
+ # Ceiling for a server-supplied Retry-After. A library should not block its
52
+ # caller for longer than this; past it, failing fast is the better answer.
53
+ MAX_RETRY_AFTER_SECONDS = 60.0
54
+
55
+ # Additive jitter, as a fraction of the delay: 0.25 spreads retries over
56
+ # [delay, delay * 1.25).
57
+ JITTER_RATIO = 0.25
58
+
59
+ # All backoff waiting funnels through here. Keeping it a single module
60
+ # method gives tests one seam to observe instead of stubbing Kernel#sleep on
61
+ # every client, and leaves one place to change should a caller ever need a
62
+ # non-blocking scheduler.
63
+ def self.sleep_for(seconds)
64
+ sleep(seconds)
65
+ end
66
+
67
+ private
68
+
69
+ # Run the block, retrying transient failures per the configured policy.
70
+ #
71
+ # @yield the provider request to attempt
72
+ # @return [Object] the block's value
73
+ # @raise [StandardError] the last error, once attempts are exhausted or the
74
+ # failure is not retryable
75
+ def with_retries
76
+ attempt = 0
77
+ max_attempts = retry_attempts
78
+
79
+ begin
80
+ attempt += 1
81
+ yield
82
+ rescue StandardError => e
83
+ raise if attempt >= max_attempts || !retryable_error?(e)
84
+
85
+ delay = retry_delay_for(e, attempt)
86
+ log_retry(e, attempt, max_attempts, delay)
87
+ Retryable.sleep_for(delay)
88
+ retry
89
+ end
90
+ end
91
+
92
+ # Total attempts allowed, including the first one.
93
+ #
94
+ # +max_retries+ is read as a total attempt budget (matching the hardcoded
95
+ # loops this concern replaced), so the default of 3 means one initial call
96
+ # plus at most two retries. Anything below 1 degrades to a single attempt.
97
+ def retry_attempts
98
+ [configuration.max_retries.to_i, 1].max
99
+ end
100
+
101
+ # @return [Boolean] whether an identical retry could plausibly succeed
102
+ def retryable_error?(error)
103
+ err = unwrap_error(error)
104
+ status = http_status(err)
105
+ return RETRYABLE_STATUSES.include?(status) unless status.nil?
106
+
107
+ TRANSPORT_ERRORS.any? { |klass| err.is_a?(klass) }
108
+ end
109
+
110
+ # Seconds to wait before the next attempt.
111
+ def retry_delay_for(error, attempt)
112
+ apply_jitter(retry_after_seconds(error) || backoff_seconds(attempt))
113
+ end
114
+
115
+ # Exponential backoff seeded from configuration.retry_delay.
116
+ def backoff_seconds(attempt)
117
+ base = configuration.retry_delay.to_f * (2**(attempt - 1))
118
+ [base, MAX_BACKOFF_SECONDS].min
119
+ end
120
+
121
+ # Spread concurrent retries without ever firing earlier than asked.
122
+ def apply_jitter(seconds)
123
+ seconds + (seconds * JITTER_RATIO * rand)
124
+ end
125
+
126
+ # Honour a Retry-After header when the API sends a usable one.
127
+ # @return [Float, nil] seconds to wait, or nil when absent/unparseable
128
+ def retry_after_seconds(error)
129
+ raw = header_value(response_headers(error), 'retry-after').to_s.strip
130
+ return if raw.empty?
131
+
132
+ seconds = parse_retry_after(raw)
133
+ seconds&.clamp(0.0, MAX_RETRY_AFTER_SECONDS)
134
+ end
135
+
136
+ # Retry-After is either a delay in seconds or an HTTP-date.
137
+ def parse_retry_after(raw)
138
+ return raw.to_f if raw.match?(/\A\d+(\.\d+)?\z/)
139
+
140
+ Time.httpdate(raw) - Time.now
141
+ rescue ArgumentError
142
+ nil
143
+ end
144
+
145
+ # Log every retry through the gem's configured logger.
146
+ #
147
+ # Deliberately reports only the class/status, attempt and delay: the
148
+ # exception's message and response carry the request URL, and for Gemini's
149
+ # generative-language endpoint that URL embeds the API key as a query param.
150
+ def log_retry(error, attempt, max_attempts, delay)
151
+ err = unwrap_error(error)
152
+ status = http_status(err)
153
+ reason = status ? "HTTP #{status}" : err.class.name
154
+
155
+ configuration.logger&.warn(
156
+ "[LlmConductor::Clients::#{short_class_name}] transient #{reason} " \
157
+ "on attempt #{attempt}/#{max_attempts}; retrying in #{format('%.2f', delay)}s"
158
+ )
159
+ end
160
+
161
+ # Some clients wrap the transport error (gemini-ai re-raises Faraday errors
162
+ # as Gemini::Errors::RequestError, keeping the original in #request).
163
+ def unwrap_error(error)
164
+ inner = error.respond_to?(:request) ? error.request : nil
165
+ inner.is_a?(StandardError) ? inner : error
166
+ end
167
+
168
+ # @return [Integer, nil] HTTP status, or nil for transport-level failures
169
+ def http_status(error)
170
+ error.respond_to?(:response_status) ? error.response_status : nil
171
+ end
172
+
173
+ # @return [Hash, nil] response headers, when the error carries a response
174
+ def response_headers(error)
175
+ err = unwrap_error(error)
176
+ err.respond_to?(:response_headers) ? err.response_headers : nil
177
+ end
178
+
179
+ # Case-insensitive header lookup that works for plain Hashes as well as
180
+ # Faraday's own headers object.
181
+ def header_value(headers, name)
182
+ return unless headers.respond_to?(:to_h)
183
+
184
+ _key, value = headers.to_h.find { |key, _| key.to_s.downcase == name }
185
+ value
186
+ end
187
+
188
+ def short_class_name
189
+ self.class.name.to_s.split('::').last
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -6,6 +6,10 @@ module LlmConductor
6
6
  module Clients
7
7
  # OpenRouter client implementation for accessing various LLM providers through OpenRouter API
8
8
  # Supports both text-only and multimodal (vision) requests
9
+ #
10
+ # Transient failures (502s from overloaded free-tier models, rate limits,
11
+ # timeouts) are retried by BaseClient's Concerns::Retryable using
12
+ # configuration.max_retries / configuration.retry_delay.
9
13
  class OpenrouterClient < BaseClient
10
14
  include Concerns::VisionSupport
11
15
 
@@ -14,34 +18,13 @@ module LlmConductor
14
18
  def generate_content(prompt)
15
19
  content = format_content(prompt)
16
20
 
17
- # Retry logic for transient 502 errors (common with free-tier models)
18
- # Free-tier vision models can be slow/overloaded, so we use more retries
19
- max_retries = 5
20
- retry_count = 0
21
-
22
- begin
23
- client.chat(
24
- parameters: {
25
- model:,
26
- messages: [{ role: 'user', content: }],
27
- provider: { sort: 'throughput' }
28
- }
29
- ).dig('choices', 0, 'message', 'content')
30
- rescue Faraday::ServerError => e
31
- retry_count += 1
32
-
33
- # Log retry attempts if logger is configured
34
- configuration.logger&.warn(
35
- "OpenRouter API error (attempt #{retry_count}/#{max_retries}): #{e.message}"
36
- )
37
-
38
- raise unless e.response[:status] == 502 && retry_count < max_retries
39
-
40
- wait_time = 2**retry_count # Exponential backoff: 2, 4, 8, 16, 32 seconds
41
- configuration.logger&.info("Retrying in #{wait_time}s...")
42
- sleep(wait_time)
43
- retry
44
- end
21
+ client.chat(
22
+ parameters: {
23
+ model:,
24
+ messages: [{ role: 'user', content: }],
25
+ provider: { sort: 'throughput' }
26
+ }
27
+ ).dig('choices', 0, 'message', 'content')
45
28
  end
46
29
 
47
30
  def client
@@ -9,6 +9,9 @@ module LlmConductor
9
9
  #
10
10
  # Note: Z.ai uses OpenAI-compatible API format but with /v4/ path instead of /v1/
11
11
  # We use Faraday directly instead of the ruby-openai gem to properly handle the API path
12
+ #
13
+ # Transient failures are retried by BaseClient's Concerns::Retryable using
14
+ # configuration.max_retries / configuration.retry_delay.
12
15
  class ZaiClient < BaseClient
13
16
  include Concerns::VisionSupport
14
17
 
@@ -17,41 +20,28 @@ module LlmConductor
17
20
  def generate_content(prompt)
18
21
  content = format_content(prompt)
19
22
 
20
- # Retry logic for transient errors (similar to OpenRouter)
21
- max_retries = 3
22
- retry_count = 0
23
-
24
- begin
25
- # Make direct HTTP request to Z.ai API since they use /v4/ instead of /v1/
26
- response = http_client.post('chat/completions') do |req|
27
- req.body = {
28
- model:,
29
- messages: [{ role: 'user', content: }]
30
- }.to_json
31
- end
32
-
33
- # Response body is already parsed as Hash by Faraday's JSON middleware
34
- response_data = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
35
- response_data.dig('choices', 0, 'message', 'content')
36
- rescue Faraday::ServerError => e
37
- retry_count += 1
38
-
39
- # Log retry attempts if logger is configured
40
- configuration.logger&.warn(
41
- "Z.ai API error (attempt #{retry_count}/#{max_retries}): #{e.message}"
42
- )
43
-
44
- raise unless retry_count < max_retries
45
-
46
- wait_time = 2**retry_count # Exponential backoff: 2, 4, 8 seconds
47
- configuration.logger&.info("Retrying in #{wait_time}s...")
48
- sleep(wait_time)
49
- retry
23
+ # Make direct HTTP request to Z.ai API since they use /v4/ instead of /v1/
24
+ response = http_client.post('chat/completions') do |req|
25
+ req.body = {
26
+ model:,
27
+ messages: [{ role: 'user', content: }]
28
+ }.to_json
50
29
  end
30
+
31
+ # Response body is already parsed as Hash by Faraday's JSON middleware
32
+ response_data = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
33
+ response_data.dig('choices', 0, 'message', 'content')
51
34
  end
52
35
 
53
36
  # HTTP client for making requests to Z.ai API
54
37
  # Z.ai uses /v4/ in their path, not /v1/ like OpenAI, so we use Faraday directly
38
+ #
39
+ # NOTE: this stack has no `response :raise_error` middleware, so an HTTP error
40
+ # from Z.ai comes back as a normal response with an error body rather than a
41
+ # raised Faraday error. The shared retry policy therefore never sees it — the
42
+ # same blind spot the hardcoded `rescue Faraday::ServerError` here had. Adding
43
+ # the middleware would change what Z.ai callers observe on failure (an error
44
+ # Response instead of a nil output), so it is left for a separate change.
55
45
  def http_client
56
46
  @http_client ||= begin
57
47
  config = LlmConductor.configuration.provider_config(:zai)
@@ -12,6 +12,10 @@ module LlmConductor
12
12
  @default_model = 'gpt-5-mini'
13
13
  @default_vendor = :openai
14
14
  @timeout = 30
15
+ # Retry policy for transient provider failures, applied to every client by
16
+ # LlmConductor::Clients::Concerns::Retryable. max_retries is a TOTAL attempt
17
+ # budget including the first call (3 = one call plus at most two retries);
18
+ # retry_delay seeds the exponential backoff. See docs/retries.md.
15
19
  @max_retries = 3
16
20
  @retry_delay = 1.0
17
21
  @logger = nil
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmConductor
4
- VERSION = '1.8.3'
4
+ VERSION = '1.8.4'
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_conductor
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.3
4
+ version: 1.8.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ben Zheng
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-08-25 00:00:00.000000000 Z
10
+ date: 2026-08-26 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -169,6 +169,7 @@ files:
169
169
  - config/initializers/llm_conductor.rb
170
170
  - docs/README.md
171
171
  - docs/custom-parameters.md
172
+ - docs/retries.md
172
173
  - docs/vision-support.md
173
174
  - examples/claude_vision_usage.rb
174
175
  - examples/data_builder_usage.rb
@@ -182,6 +183,7 @@ files:
182
183
  - examples/openrouter_vision_usage.rb
183
184
  - examples/prompt_registration.rb
184
185
  - examples/rag_usage.rb
186
+ - examples/retry_config_usage.rb
185
187
  - examples/simple_usage.rb
186
188
  - examples/zai_usage.rb
187
189
  - lib/llm_conductor.rb
@@ -189,6 +191,7 @@ files:
189
191
  - lib/llm_conductor/clients/anthropic_client.rb
190
192
  - lib/llm_conductor/clients/base_client.rb
191
193
  - lib/llm_conductor/clients/concerns/gemini_generation_config.rb
194
+ - lib/llm_conductor/clients/concerns/retryable.rb
192
195
  - lib/llm_conductor/clients/concerns/vision_support.rb
193
196
  - lib/llm_conductor/clients/gemini_client.rb
194
197
  - lib/llm_conductor/clients/gpt_client.rb