diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e34fef..9ea5ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Automatic retries** on retryable errors (429, 529 and 5xx): by default + `Typesafe::Client#evaluate` (and `Typesafe::Jev.evaluate`) now retries up to + 2 times (3 attempts in total) with an exponential backoff — 0.5 s base, + doubled on each retry, capped at 8 s, with jitter — before raising the last + retryable error. The POST is replayed verbatim; non-retryable errors + (400, 401, 403, 404, 422) still raise immediately without any replay. The + retries are silent: no log output, no configuration needed. + ## [1.0.0] - 2026-09-20 ### Added diff --git a/lib/typesafe/client.rb b/lib/typesafe/client.rb index 31e8297..18e9924 100644 --- a/lib/typesafe/client.rb +++ b/lib/typesafe/client.rb @@ -21,6 +21,15 @@ class Client DEFAULT_MODEL = "jev-latest" API_ENDPOINT = "https://api.typesafe.ai/v1/systemone" + # Default retry policy for retryable errors (429, 529, 5xx): 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. + RETRIES = 2 + BASE_DELAY = 0.5 + MAX_DELAY = 8.0 + private_constant :RETRIES, :BASE_DELAY, :MAX_DELAY + # @return [String] the API key sent as +Authorization: Bearer +. attr_reader :api_key @@ -60,6 +69,11 @@ def initialize(api_key: nil, model: nil) # {Typesafe::PermissionDeniedError}, {Typesafe::NotFoundError}, # {Typesafe::UnprocessableEntityError}, {Typesafe::RateLimitError}, # {Typesafe::OverloadedError} and {Typesafe::ServerError}. + # + # Retryable errors (429, 529 and 5xx) are retried automatically up to + # +RETRIES+ times with an exponential backoff; when the budget is + # exhausted, the last retryable error is raised. Non-retryable errors + # (400, 401, 403, 404, 422) raise immediately without any retry. 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) @@ -70,16 +84,39 @@ def evaluate(state:, questions:, model: nil) questions: questions.transform_values(&:to_h) ) - response = post(body) - unless response.is_a?(Net::HTTPSuccess) - raise Errors.from_response(status: response.code.to_i, headers: response, body: response.body) - end - + response = request_with_retries(body) Response.from_json(response.body) end private + # POSTs +body+ and retries retryable error responses up to +RETRIES+ + # times. The same +body+ is replayed verbatim on every attempt: an + # evaluation is stateless, so the replay is safe. Raises the error as + # soon as it is not retryable, or once the retry budget is exhausted. + def request_with_retries(body) + retries = 0 + loop do + response = post(body) + return response if response.is_a?(Net::HTTPSuccess) + + error = Errors.from_response(status: response.code.to_i, headers: response, body: response.body) + raise error unless error.retryable? + raise error if retries >= RETRIES + + retries += 1 + Kernel.sleep(delay_before_retry(retries)) + end + end + + # Delay before retry +retry_number+: 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). + def delay_before_retry(retry_number) + nominal = [BASE_DELAY * (2**(retry_number - 1)), MAX_DELAY].min + nominal * (0.5 + Kernel.rand * 0.5) + end + def post(body) uri = URI(API_ENDPOINT) request = Net::HTTP::Post.new(uri) diff --git a/spec/typesafe/client_spec.rb b/spec/typesafe/client_spec.rb index 252a9a7..15b826f 100644 --- a/spec/typesafe/client_spec.rb +++ b/spec/typesafe/client_spec.rb @@ -227,6 +227,12 @@ end context "errors" do + # The default retry policy sleeps between retries; stub the Kernel sleep + # so these retryable-error scenarios stay fast and deterministic. + before do + allow(Kernel).to receive(:sleep) + end + it "raises AuthenticationError on 401 with the API message" do stub_request(:post, endpoint).to_return( status: 401, @@ -277,6 +283,104 @@ .to raise_error(Typesafe::PermissionDeniedError) end end + + context "retries" do + # The policy waits before each retry via Kernel.sleep; record requested + # delays so the backoff is verifiable without slowing the suite down. + let(:sleeps) { [] } + + before do + allow(Kernel).to receive(:sleep) { |delay| sleeps << delay } + end + + it "succeeds after a 429, replaying the identical request" do + stub = stub_request(:post, endpoint) + .with( + headers: { + "Authorization" => "Bearer #{api_key}", + "Content-Type" => "application/json" + }, + body: hash_including("state" => state, "model" => "jev-latest") + ).to_return( + { status: 429, body: "{}" }, + { status: 200, body: JSON.generate(example_response) } + ) + + response = client.evaluate(state: state, questions: questions) + + expect(response).to eq(Typesafe::Response.from_h(example_response)) + expect(stub).to have_been_requested.times(2) + end + + [529, 500].each do |status| + it "succeeds after a #{status} with exactly two requests" do + stub = stub_request(:post, endpoint) + .with(body: hash_including("state" => state, "model" => "jev-latest")) + .to_return( + { status: status, body: "{}" }, + { status: 200, body: JSON.generate(example_response) } + ) + + response = client.evaluate(state: state, questions: questions) + + expect(response).to eq(Typesafe::Response.from_h(example_response)) + expect(stub).to have_been_requested.times(2) + end + end + + { + 429 => Typesafe::RateLimitError, + 529 => Typesafe::OverloadedError, + 500 => Typesafe::ServerError + }.each do |status, error_class| + it "raises the original #{error_class} once retryable failures exceed the budget" do + stub = stub_request(:post, endpoint).to_return(status: status, body: "{}") + + expect { client.evaluate(state: state, questions: questions) } + .to raise_error(error_class) + + expect(stub).to have_been_requested.times(3) + end + end + + { + 401 => Typesafe::AuthenticationError, + 403 => Typesafe::PermissionDeniedError, + 404 => Typesafe::NotFoundError, + 422 => Typesafe::UnprocessableEntityError + }.each do |status, error_class| + it "never retries a #{status}" do + stub = stub_request(:post, endpoint).to_return(status: status, body: "{}") + + expect { client.evaluate(state: state, questions: questions) } + .to raise_error(error_class) + + expect(stub).to have_been_requested.once + end + end + + it "sleeps 0.5s then 1s between attempts (exponential backoff with jitter)" do + 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.5, 1.0) + end + + it "does not sleep when the first attempt succeeds" do + stub_request(:post, endpoint).to_return(status: 200, body: JSON.generate(example_response)) + + client.evaluate(state: state, questions: questions) + + expect(sleeps).to be_empty + end + end end private diff --git a/spec/typesafe/jev_spec.rb b/spec/typesafe/jev_spec.rb index 34e1adb..44127ae 100644 --- a/spec/typesafe/jev_spec.rb +++ b/spec/typesafe/jev_spec.rb @@ -70,6 +70,23 @@ expect { client.evaluate(state: state, questions: questions, model: "jev-1.13.0") } .to raise_error(ArgumentError, /pins the model/) end + + it "inherits the default retries on retryable errors" do + client = described_class.new(api_key: api_key) + allow(Kernel).to receive(:sleep) + + 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) } + ) + + expect(client.evaluate(state: state, questions: questions)) + .to eq(Typesafe::Response.from_h(example_response)) + end end describe ".evaluate one-shot" do