From 97e7132debd8ff948ce8d3c20585325e0e7abba4 Mon Sep 17 00:00:00 2001 From: "arsenik-dtheo[bot]" <330762117+arsenik-dtheo[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:21:59 +0000 Subject: [PATCH 1/2] =?UTF-8?q?test(client):=20sc=C3=A9narios=20de=20confi?= =?UTF-8?q?guration=20par=20retry=5Foptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retry_options: sur Client.new (et Jev.new) : défauts 2/0,5/8,0 pour clés absentes, valeurs nil et retry_options: nil ; validation stricte (clé inconnue ou valeur invalide -> ArgumentError nommant la clé) ; options normalisées exposées et figées, mutation du Hash passé sans effet ; max_retries: 0 = tentative unique ; base_delay/max_delay observables via les délais demandés ; héritage Jev. --- spec/typesafe/client_spec.rb | 190 +++++++++++++++++++++++++++++++++++ spec/typesafe/jev_spec.rb | 48 +++++++++ 2 files changed, 238 insertions(+) diff --git a/spec/typesafe/client_spec.rb b/spec/typesafe/client_spec.rb index 84c8a0c..87afc85 100644 --- a/spec/typesafe/client_spec.rb +++ b/spec/typesafe/client_spec.rb @@ -76,6 +76,102 @@ expect(model).not_to be_frozen expect(client).to be_frozen end + + describe "retry_options" do + it "defaults to 2 retries, a 0.5 s base delay and an 8 s cap, exposed normalized and frozen" do + client = described_class.new(api_key: api_key) + + expect(client.retry_options).to eq(max_retries: 2, base_delay: 0.5, max_delay: 8.0) + expect(client.retry_options[:max_retries]).to be_a(Integer) + expect(client.retry_options[:base_delay]).to be_a(Float) + expect(client.retry_options[:max_delay]).to be_a(Float) + expect(client.retry_options).to be_frozen + expect(client).to be_frozen + end + + it "treats retry_options: nil as all defaults" do + client = described_class.new(api_key: api_key, retry_options: nil) + + expect(client.retry_options).to eq(max_retries: 2, base_delay: 0.5, max_delay: 8.0) + end + + it "fills absent keys with the defaults, keeping the given ones" do + client = described_class.new(api_key: api_key, retry_options: { max_retries: 0 }) + + expect(client.retry_options).to eq(max_retries: 0, base_delay: 0.5, max_delay: 8.0) + end + + it "treats nil values as absent" do + client = described_class.new( + api_key: api_key, + retry_options: { max_retries: nil, base_delay: nil, max_delay: nil } + ) + + expect(client.retry_options).to eq(max_retries: 2, base_delay: 0.5, max_delay: 8.0) + end + + it "accepts Integer and Float delays, normalizing them to Float" do + client = described_class.new(api_key: api_key, retry_options: { base_delay: 1, max_delay: 4 }) + + expect(client.retry_options).to eq(max_retries: 2, base_delay: 1.0, max_delay: 4.0) + expect(client.retry_options[:base_delay]).to be_a(Float) + expect(client.retry_options[:max_delay]).to be_a(Float) + end + + it "freezes its own copy without freezing the caller's Hash" do + options = { max_retries: 1, base_delay: 0.5, max_delay: 8.0 } + client = described_class.new(api_key: api_key, retry_options: options) + + expect(client.retry_options).to be_frozen + expect(options).not_to be_frozen + end + + it "is unaffected by mutating the Hash passed at construction" do + options = { max_retries: 1, base_delay: 0.5, max_delay: 8.0 } + client = described_class.new(api_key: api_key, retry_options: options) + options[:max_retries] = 5 + + expect(client.retry_options[:max_retries]).to eq(1) + end + + context "validation" do + it "raises on an unknown key, naming the key in the message" do + expect { described_class.new(api_key: api_key, retry_options: { max_retrie: 3 }) } + .to raise_error(ArgumentError, /max_retrie/) + end + + it "raises on a String key, naming the key in the message" do + expect { described_class.new(api_key: api_key, retry_options: { "max_retries" => 2 }) } + .to raise_error(ArgumentError, /max_retries/) + end + + it "raises when retry_options is not a Hash" do + expect { described_class.new(api_key: api_key, retry_options: [1, 2]) } + .to raise_error(ArgumentError, /retry_options must be a Hash/) + end + + it "raises naming the key when max_retries is not a non-negative Integer" do + [-1, 2.5, "2", true].each do |value| + expect { described_class.new(api_key: api_key, retry_options: { max_retries: value }) } + .to raise_error(ArgumentError, /max_retries/) + end + end + + it "raises naming the key when base_delay is not a non-negative number" do + [-0.5, "0.5", true].each do |value| + expect { described_class.new(api_key: api_key, retry_options: { base_delay: value }) } + .to raise_error(ArgumentError, /base_delay/) + end + end + + it "raises naming the key when max_delay is not a non-negative number" do + [-1, "8", true].each do |value| + expect { described_class.new(api_key: api_key, retry_options: { max_delay: value }) } + .to raise_error(ArgumentError, /max_delay/) + end + end + end + end end describe "#evaluate" do @@ -293,6 +389,89 @@ allow(Kernel).to receive(:sleep) { |delay| sleeps << delay } end + context "configured policy" do + it "performs a single attempt with max_retries: 0, raising on the first retryable failure" do + client = described_class.new(api_key: api_key, retry_options: { max_retries: 0 }) + stub = stub_request(:post, endpoint).to_return(status: 529, body: "{}") + + expect { client.evaluate(state: state, questions: questions) } + .to raise_error(Typesafe::OverloadedError) + + expect(stub).to have_been_requested.once + expect(sleeps).to be_empty + end + + it "retries with a looser base_delay, observable in the requested sleeps" do + client = described_class.new(api_key: api_key, retry_options: { max_retries: 1, base_delay: 1.0 }) + + stub_request(:post, endpoint).to_return( + { status: 529, body: "{}" }, + { status: 200, body: JSON.generate(example_response) } + ) + + client.evaluate(state: state, questions: questions) + + expect(sleeps.length).to eq(1) + expect(sleeps[0]).to be_between(0.5, 1.0) + end + + it "retries with a tighter base_delay, observable in the requested sleeps" do + client = described_class.new(api_key: api_key, retry_options: { max_retries: 1, base_delay: 0.1 }) + + stub_request(:post, endpoint).to_return( + { status: 529, body: "{}" }, + { status: 200, body: JSON.generate(example_response) } + ) + + client.evaluate(state: state, questions: questions) + + expect(sleeps.length).to eq(1) + expect(sleeps[0]).to be_between(0.05, 0.1) + end + + it "caps the exponential backoff at the configured max_delay" do + client = described_class.new( + api_key: api_key, + retry_options: { max_retries: 2, base_delay: 4.0, max_delay: 0.5 } + ) + + stub_request(:post, endpoint).to_return( + { status: 529, body: "{}" }, + { status: 529, body: "{}" }, + { status: 200, body: JSON.generate(example_response) } + ) + + client.evaluate(state: state, questions: questions) + + expect(sleeps.length).to eq(2) + expect(sleeps[0]).to be_between(0.25, 0.5) + expect(sleeps[1]).to be_between(0.25, 0.5) + end + + it "exhausts the budget after max_retries retries" do + client = described_class.new(api_key: api_key, retry_options: { max_retries: 1 }) + stub = stub_request(:post, endpoint).to_return(status: 529, body: "{}") + + expect { client.evaluate(state: state, questions: questions) } + .to raise_error(Typesafe::OverloadedError) + + expect(stub).to have_been_requested.times(2) + end + + [nil, {}, { max_retries: nil, base_delay: nil, max_delay: nil }].each do |options| + it "keeps the default behavior with retry_options: #{options.inspect}" do + client = described_class.new(api_key: api_key, retry_options: options) + stub = stub_request(:post, endpoint).to_return(status: 529, body: "{}") + + expect { client.evaluate(state: state, questions: questions) } + .to raise_error(Typesafe::OverloadedError) + + expect(stub).to have_been_requested.times(3) + expect(client.retry_options).to eq(max_retries: 2, base_delay: 0.5, max_delay: 8.0) + end + end + end + it "succeeds after a 429, replaying the identical request" do stub = stub_request(:post, endpoint) .with( @@ -452,6 +631,17 @@ allow(Kernel).to receive(:sleep) { |delay| sleeps << delay } end + it "with max_retries: 0, performs a single attempt on a network failure" do + client = described_class.new(api_key: api_key, retry_options: { max_retries: 0 }) + stub = stub_request(:post, endpoint).to_raise(Errno::ECONNREFUSED) + + expect { client.evaluate(state: state, questions: questions) } + .to raise_error(Typesafe::ConnectionError) + + expect(stub).to have_been_requested.once + expect(sleeps).to be_empty + end + # Representative network-failure families. Resolv::ResolvError exists # only on Ruby >= 3.3: on 3.1/3.2 a DNS failure raises SocketError, so # the spec keeps the list version-portable. diff --git a/spec/typesafe/jev_spec.rb b/spec/typesafe/jev_spec.rb index 713d2b6..9d51c72 100644 --- a/spec/typesafe/jev_spec.rb +++ b/spec/typesafe/jev_spec.rb @@ -44,6 +44,18 @@ it "pins the model to \"jev-latest\"" do expect(described_class.new(api_key: api_key).model).to eq("jev-latest") end + + it "accepts retry_options and exposes the normalized, frozen policy" do + client = described_class.new(api_key: api_key, retry_options: { max_retries: 0 }) + + expect(client.retry_options).to eq(max_retries: 0, base_delay: 0.5, max_delay: 8.0) + expect(client.retry_options).to be_frozen + end + + it "validates retry_options strictly, raising an ArgumentError at construction" do + expect { described_class.new(api_key: api_key, retry_options: { max_retrie: 3 }) } + .to raise_error(ArgumentError, /max_retrie/) + end end describe "#evaluate" do @@ -101,6 +113,42 @@ expect { client.evaluate(state: state, questions: questions) } .to raise_error(Typesafe::ConnectionError) end + + it "reflects retry_options: { max_retries: 0 } with a single attempt" do + client = described_class.new(api_key: api_key, retry_options: { max_retries: 0 }) + allow(Kernel).to receive(:sleep) + + stub = stub_request(:post, endpoint) + .with( + headers: { "Authorization" => "Bearer #{api_key}" }, + body: { "state" => state, "model" => "jev-latest", "questions" => { "is_urgent" => { "type" => "noul", "instructions" => "Does this convey urgency?" } } } + ).to_return(status: 529, body: "{}") + + expect { client.evaluate(state: state, questions: questions) } + .to raise_error(Typesafe::OverloadedError) + + expect(stub).to have_been_requested.once + end + + it "reflects a configured base_delay in the requested sleeps" do + sleeps = [] + client = described_class.new(api_key: api_key, retry_options: { max_retries: 1, base_delay: 1.0 }) + allow(Kernel).to receive(:sleep) { |delay| sleeps << delay } + + stub_request(:post, endpoint) + .with( + headers: { "Authorization" => "Bearer #{api_key}" }, + body: { "state" => state, "model" => "jev-latest", "questions" => { "is_urgent" => { "type" => "noul", "instructions" => "Does this convey urgency?" } } } + ).to_return( + { status: 529, body: "{}" }, + { status: 200, body: JSON.generate(example_response) } + ) + + client.evaluate(state: state, questions: questions) + + expect(sleeps.length).to eq(1) + expect(sleeps[0]).to be_between(0.5, 1.0) + end end describe ".evaluate one-shot" do From d28f6c22d0256e7ee21d60e53d98659685139744 Mon Sep 17 00:00:00 2001 From: "arsenik-dtheo[bot]" <330762117+arsenik-dtheo[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:23:27 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(client):=20toute=20la=20politique=20de?= =?UTF-8?q?=20retry=20r=C3=A9glable=20via=20retry=5Foptions:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client.new (et Jev.new, qui hérite) acceptent un unique argument retry_options: (Hash) : max_retries / base_delay / max_delay, défauts 2 / 0,5 / 8,0 pour clé absente ou nil, et retry_options: nil pour tous les défauts. Validation stricte à la construction (ArgumentError nommant la clé sur clé inconnue ou valeur invalide : numériques non négatifs, max_retries entier). Le Hash normalisé est dupliqué et figé, exposé sur le client ; muter le Hash passé n'a plus d'effet. La boucle retentative et le backoff lisent la politique configurée ; la signature de evaluate ne change pas. --- CHANGELOG.md | 11 ++++ lib/typesafe/client.rb | 131 ++++++++++++++++++++++++++++++++++------- lib/typesafe/jev.rb | 10 +++- 3 files changed, 128 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b73868a..ff110e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), exponential backoff, then raised once the budget is exhausted. A brief outage is therefore absorbed by the automatic retries. +- **Configurable retry policy via `retry_options:`**: `Typesafe::Client.new` + (and `Typesafe::Jev.new`) now accept a single `retry_options:` Hash that + tunes the retry loop — `max_retries` (2 by default), `base_delay` (0.5 s) + and `max_delay` (8.0 s). Absent keys, `nil` values and `retry_options: nil` + keep the defaults, and `max_retries: 0` restores the + single-attempt behavior. Unknown keys and invalid values (negative or + non-numeric delays, non-Integer `max_retries`) raise an `ArgumentError` + naming the key at construction time. The normalized options are dupped and + frozen on the client (mutating the Hash passed at initialization has no + effect); `evaluate` keeps its signature. + ## [1.0.0] - 2026-09-20 ### Added diff --git a/lib/typesafe/client.rb b/lib/typesafe/client.rb index 4a7de02..d9ca338 100644 --- a/lib/typesafe/client.rb +++ b/lib/typesafe/client.rb @@ -23,14 +23,25 @@ class Client API_ENDPOINT = "https://api.typesafe.ai/v1/systemone" # Default retry policy for retryable failures — the 429/529/5xx status - # errors and the +ConnectionError+ wrapping network errors: at most - # +RETRIES+ retries (so a total of +RETRIES + 1+ attempts), with an - # exponential backoff between attempts starting at +BASE_DELAY+ seconds, - # doubled on each retry and capped at +MAX_DELAY+, with jitter. + # errors and the +ConnectionError+ wrapping network errors: at most 2 + # retries (so a total of 3 attempts), with an exponential backoff between + # attempts starting at 0.5 s, doubled on each retry and capped at 8 s, + # with jitter. These are the defaults used when a key is absent or nil; + # the policy is configured per client via +retry_options:+. See + # {#normalize_retry_options}. RETRIES = 2 BASE_DELAY = 0.5 MAX_DELAY = 8.0 - private_constant :RETRIES, :BASE_DELAY, :MAX_DELAY + + # The normalized retry policy: every accepted key present, with the + # defaults 2 / 0.5 / 8.0. +retry_options:+ fills it with the given keys + # and defaults for the rest. + DEFAULT_RETRY_OPTIONS = { + max_retries: RETRIES, + base_delay: BASE_DELAY, + max_delay: MAX_DELAY + }.freeze + private_constant :RETRIES, :BASE_DELAY, :MAX_DELAY, :DEFAULT_RETRY_OPTIONS # Network-level exception families wrapped into a +ConnectionError+ before # any HTTP response: socket and DNS failures, connection/read/write @@ -65,16 +76,34 @@ class Client # @return [String] the default model used when +evaluate+ gets none. attr_reader :model + # @return [Hash{Symbol => Integer, Float}] the normalized retry policy, + # frozen: all three accepted keys present (+max_retries+, +base_delay+, + # +max_delay+) with the effective values — the given ones where the + # caller provided them, the defaults (2 / 0.5 / 8.0) otherwise. The + # Hash is a private copy: mutating the Hash passed at initialization + # has no effect once the client is built. + attr_reader :retry_options + # @param api_key [String, nil] the TypeSafe API key; falls back to the # +TYPESAFE_API_KEY+ environment variable. # @param model [String, nil] the default model; defaults to "jev-latest". - # @raise [ArgumentError] if no usable API key is found or +model+ is - # present but not a non-empty String. - def initialize(api_key: nil, model: nil) + # @param retry_options [Hash{Symbol => Integer, Float}, nil] the retry + # policy for retryable failures. Accepted keys: +max_retries+ + # (non-negative Integer), +base_delay+ and +max_delay+ (non-negative + # numbers); absent keys or +nil+ values fall back to the defaults + # 2 / 0.5 / 8.0, and +nil+ means every default. Any other key or an + # invalid value raises an +ArgumentError+ naming the key. The strategy + # itself is not configurable in v1: delays are either the server's + # +Retry-After+ on a 429 or the exponential backoff with jitter. + # @raise [ArgumentError] if no usable API key is found, +model+ is + # present but not a non-empty String, or +retry_options+ contains an + # unknown key or an invalid value. + def initialize(api_key: nil, model: nil, retry_options: nil) @api_key = freeze_string(api_key || ENV.fetch("TYPESAFE_API_KEY") { nil }, "api_key must be a non-empty String " \ "(passed at initialization or set in the TYPESAFE_API_KEY environment variable)") @model = freeze_string(model || DEFAULT_MODEL, "model must be a non-empty String") + @retry_options = normalize_retry_options(retry_options) freeze end @@ -106,11 +135,15 @@ def initialize(api_key: nil, model: nil) # {Typesafe::OverloadedError} and {Typesafe::ServerError}. # # Retryable errors — 429, 529, 5xx and network failures — are retried - # automatically up to +RETRIES+ times; the wait honors the server's - # +Retry-After+ / +Retry-After-Ms+ header on a 429, otherwise an - # exponential backoff applies. When the budget is exhausted, the last - # retryable error is raised. Non-retryable errors (400, 401, 403, 404, - # 422) raise immediately without any retry. + # automatically up to the configured +max_retries+ (2 by default); the + # wait honors the server's +Retry-After+ / +Retry-After-Ms+ header on a + # 429, otherwise an exponential backoff applies, starting at the + # configured +base_delay+ and capped at the configured +max_delay+. When + # the budget is exhausted, the last retryable error is raised. A + # +max_retries+ of 0 restores the single-attempt behavior of a client + # without retries. Non-retryable errors (400, 401, 403, 404, 422) raise + # immediately without any retry. The policy is fixed at initialization: + # the +evaluate+ signature does not change. def evaluate(state:, questions:, model: nil) model = model.nil? ? self.model : freeze_string(model, "model must be a non-empty String") questions = validate_questions!(questions) @@ -127,7 +160,62 @@ def evaluate(state:, questions:, model: nil) private - # POSTs +body+ and retries retryable failures up to +RETRIES+ times. + # Builds the frozen, normalized retry policy from the caller's + # +retry_options+: every accepted key present, unknown keys and invalid + # values rejected with an +ArgumentError+ naming the key, and the result + # frozen — a fresh Hash, so mutating the caller's object has no effect. + def normalize_retry_options(retry_options) + options = DEFAULT_RETRY_OPTIONS.dup + unless retry_options.nil? + unless retry_options.is_a?(Hash) + raise ArgumentError, "retry_options must be a Hash, got #{retry_options.inspect}" + end + + retry_options.each do |key, value| + next if value.nil? + + case key + when :max_retries + validate_max_retries!(key, value) + options[key] = value + when :base_delay, :max_delay + validate_delay!(key, value) + options[key] = value.to_f + else + raise ArgumentError, + "unknown retry_options key #{key.inspect} " \ + "(accepted keys: :max_retries, :base_delay, :max_delay)" + end + end + end + + options.freeze + end + + # Raises an +ArgumentError+ naming +key+ unless +value+ is a + # non-negative Integer — the +max_retries+ contract: any other numeric + # (a Float, e.g.) or a non-numeric is rejected, and a negative budget is + # meaningless. 0 is valid and means a single attempt. + def validate_max_retries!(key, value) + return if value.is_a?(Integer) && value >= 0 + + raise ArgumentError, + "retry_options :#{key} must be a non-negative Integer, got #{value.inspect}" + end + + # Raises an +ArgumentError+ naming +key+ unless +value+ is a + # non-negative number — the +base_delay+ / +max_delay+ contract. Only + # ordered numbers qualify (Float, Integer, Rational); anything else (a + # String, a Boolean, an unordered +Complex+) is rejected. + def validate_delay!(key, value) + return if value.is_a?(Numeric) && value.respond_to?(:>=) && value >= 0 + + raise ArgumentError, + "retry_options :#{key} must be a non-negative number, got #{value.inspect}" + end + + # POSTs +body+ and retries retryable failures up to the configured + # +max_retries+ times. # The same +body+ is replayed verbatim on every attempt: an evaluation is # stateless, so the replay is safe. Each attempt either returns the HTTP # response, or an +APIError+ — the status error built from a non-2xx @@ -142,7 +230,7 @@ def request_with_retries(body) return outcome if outcome.is_a?(Net::HTTPSuccess) raise outcome unless outcome.retryable? - raise outcome if retries >= RETRIES + raise outcome if retries >= retry_options[:max_retries] retries += 1 Kernel.sleep(delay_before_retry(outcome, retries)) @@ -165,16 +253,17 @@ def attempt(body) # Delay before retry +retry_number+. When the server imposes the pace on a # rate limit (429), its +Retry-After+ / +Retry-After-Ms+ header is honored # exactly; otherwise — including for +ConnectionError+, which carries no - # HTTP headers — the delay is the exponential backoff: the base delay - # doubled on each retry, capped at +MAX_DELAY+ seconds, with equal jitter - # so parallel clients do not align (uniform between half the nominal delay - # and the nominal delay). A non-numeric or non-positive +Retry-After+ is - # ignored and falls back on the default backoff. + # HTTP headers — the delay is the exponential backoff: the configured + # +base_delay+ doubled on each retry, capped at the configured + # +max_delay+ seconds, with equal jitter so parallel clients do not align + # (uniform between half the nominal delay and the nominal delay). A + # non-numeric or non-positive +Retry-After+ is ignored and falls back on + # the default backoff. def delay_before_retry(error, retry_number) server_delay = error.is_a?(RateLimitError) ? error.retry_after : nil return server_delay if server_delay && server_delay > 0 - nominal = [BASE_DELAY * (2**(retry_number - 1)), MAX_DELAY].min + nominal = [retry_options[:base_delay] * (2**(retry_number - 1)), retry_options[:max_delay]].min nominal * (0.5 + Kernel.rand * 0.5) end diff --git a/lib/typesafe/jev.rb b/lib/typesafe/jev.rb index 12ba1e7..c567937 100644 --- a/lib/typesafe/jev.rb +++ b/lib/typesafe/jev.rb @@ -19,9 +19,13 @@ class Jev < Client # @param api_key [String, nil] the TypeSafe API key; falls back to the # +TYPESAFE_API_KEY+ environment variable. - # @raise [ArgumentError] if no usable API key is found. - def initialize(api_key: nil) - super(api_key: api_key, model: PINNED_MODEL) + # @param retry_options [Hash{Symbol => Integer, Float}, nil] the retry + # policy, forwarded to the client unchanged; see + # {Typesafe::Client#initialize}. + # @raise [ArgumentError] if no usable API key is found, or +retry_options+ + # contains an unknown key or an invalid value. + def initialize(api_key: nil, retry_options: nil) + super(api_key: api_key, model: PINNED_MODEL, retry_options: retry_options) end # Evaluates a state against a map of questions with the pinned model.