Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 42 additions & 5 deletions lib/typesafe/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>+.
attr_reader :api_key

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
104 changes: 104 additions & 0 deletions spec/typesafe/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions spec/typesafe/jev_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading