turbopuffer 2.1.0 → 2.2.0

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: 20d0c90e03716bcf7ca9387385505eb4bd4711888f16eb0d3bde30d70ad0829a
4
- data.tar.gz: 29b6153b4c7250a63170be4c7b1010933ef7c96e7817fd78de6d4790fed8a481
3
+ metadata.gz: f5782a65079f1aa648db3dde706b11543e0e35a2c93eb14f32d64c42bdc260e5
4
+ data.tar.gz: 5878b8bf8b11267e60cf1c6edd2f045e6ae427c0da3c12adec20b239179a693f
5
5
  SHA512:
6
- metadata.gz: b7243b9b3134d2fa3741ed94b79c5813f4d23a5f106d561865a8a9e1891ac76c7cb0d66088bbb3e2b9f0f1970ed023bffc06a9c98059ac8af9e0b8f544b0249d
7
- data.tar.gz: f249a2b3afa1092d5bab4ea3510ee6de24719b8e61fbbcc5975f90e6d6ecbc002298283454f0f1eccf9c313f9f7def328b6f508df894f9a852ae3759d8b6ac66
6
+ metadata.gz: 4716b1484413ac6c3b33583cccb0138c3068b41d7711e5062d81265dcff09dec7ad1508ca6468e08bbaebb1d0390794aaa71b1e026156841e75d6b228cdd71ba
7
+ data.tar.gz: a8cdfc20d9779db1e32922c6b23053163da52bf1dbe71f4d1555f95ea64ede5ea95c479d62f2975e70213d21ed7e4d754ab77e6f7266587f2029d2ab64e14542
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.0 (2026-06-08)
4
+
5
+ Full Changelog: [v2.1.0...v2.2.0](https://github.com/turbopuffer/turbopuffer-ruby/compare/v2.1.0...v2.2.0)
6
+
7
+ ### Features
8
+
9
+ * stainless: update sdks to support case-insensitive fuzzy filter ([d02bb79](https://github.com/turbopuffer/turbopuffer-ruby/commit/d02bb79d809f3b2285020f3aac4a3441ac1e38b2))
10
+ * transparent async polling ([#61](https://github.com/turbopuffer/turbopuffer-ruby/issues/61)) ([d93c499](https://github.com/turbopuffer/turbopuffer-ruby/commit/d93c49924d0df4bb4be9935f2090ce6fa26da18e))
11
+
3
12
  ## 2.1.0 (2026-06-03)
4
13
 
5
14
  Full Changelog: [v2.1.0-alpha.1...v2.1.0](https://github.com/turbopuffer/turbopuffer-ruby/compare/v2.1.0-alpha.1...v2.1.0)
data/README.md CHANGED
@@ -26,7 +26,7 @@ To use this gem, install via Bundler by adding the following to your application
26
26
  <!-- x-release-please-start-version -->
27
27
 
28
28
  ```ruby
29
- gem "turbopuffer", "~> 2.1.0"
29
+ gem "turbopuffer", "~> 2.2.0"
30
30
  ```
31
31
 
32
32
  <!-- x-release-please-end -->
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turbopuffer
4
+ module Internal
5
+ # Support for transparent polling of async tpuf APIs.
6
+ #
7
+ # Every API request is stamped with `Prefer: respond-async`. If the server
8
+ # applies the preference (i.e. responds with `202 Accepted` +
9
+ # `preference-applied: respond-async`) the SDK polls that URL until the
10
+ # operation finishes and returns the final result as if the API call had
11
+ # been synchronous.
12
+ #
13
+ # @api private
14
+ module RespondAsync
15
+ HEADER_PREFER = "prefer"
16
+ HEADER_PREFERENCE_APPLIED = "preference-applied"
17
+ HEADER_LOCATION = "location"
18
+ RESPOND_ASYNC = "respond-async"
19
+
20
+ POLL_INTERVAL = 1.0
21
+ POLL_REQUEST_TIMEOUT = 60.0
22
+
23
+ # Timeout tracking for async polling.
24
+ #
25
+ # @api private
26
+ class Timeout
27
+ # @param timeout [Float]
28
+ def initialize(timeout)
29
+ @deadline = Turbopuffer::Internal::Util.monotonic_secs + timeout
30
+ end
31
+
32
+ # @return [Float]
33
+ def remaining
34
+ [@deadline - Turbopuffer::Internal::Util.monotonic_secs, 0].max
35
+ end
36
+
37
+ # @return [Float]
38
+ def poll_timeout
39
+ [remaining, POLL_REQUEST_TIMEOUT].min
40
+ end
41
+
42
+ # @return [Float]
43
+ def sleep_duration
44
+ [remaining, POLL_INTERVAL].min
45
+ end
46
+ end
47
+
48
+ class << self
49
+ # @param headers [Hash{String=>String}]
50
+ #
51
+ # @return [void]
52
+ def prepare_headers(headers)
53
+ headers[HEADER_PREFER] ||= RESPOND_ASYNC
54
+ end
55
+
56
+ # @param client [Turbopuffer::Internal::Transport::BaseClient]
57
+ # @param request [Hash{Symbol=>Object}] the original built request
58
+ # @param response [Net::HTTPResponse]
59
+ # @param stream [Enumerable<String>]
60
+ #
61
+ # @return [Array(Integer, Net::HTTPResponse, Enumerable<String>)]
62
+ def maybe_poll(client, request, response, stream)
63
+ return [Integer(response.code), response, stream] unless respond_async_applied?(response)
64
+
65
+ # Drain the original 202 body so the connection returns to the pool.
66
+ stream&.each { next }
67
+
68
+ orig_url = request.fetch(:url)
69
+ location = extract_location(orig_url, response)
70
+
71
+ timeout = Timeout.new(request.fetch(:timeout))
72
+
73
+ loop do
74
+ result = poll_once(client, orig_url, location, timeout)
75
+ return result if result
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ # @param response [Net::HTTPResponse]
82
+ #
83
+ # @return [Boolean]
84
+ def respond_async_applied?(response)
85
+ return false unless response.code == "202"
86
+
87
+ applied = response[HEADER_PREFERENCE_APPLIED].to_s
88
+ applied.strip.downcase == RESPOND_ASYNC
89
+ end
90
+
91
+ # @param orig_url [URI::Generic]
92
+ # @param response [Net::HTTPResponse]
93
+ #
94
+ # @return [URI::Generic]
95
+ def extract_location(orig_url, response)
96
+ raw_location = response[HEADER_LOCATION].to_s.strip
97
+ if raw_location.empty?
98
+ raise Turbopuffer::Errors::APIError.new(
99
+ url: orig_url,
100
+ message: "Server returned async response without a 'Location' header."
101
+ )
102
+ end
103
+
104
+ # Normalize so the default port for the scheme is applied.
105
+ orig = URI(orig_url.to_s)
106
+
107
+ # Resolve the Location against the original request URL.
108
+ begin
109
+ location = URI.join(orig, raw_location)
110
+ rescue URI::InvalidURIError
111
+ raise Turbopuffer::Errors::APIError.new(
112
+ url: orig_url,
113
+ message: "malformed 'Location' header: #{raw_location.inspect}"
114
+ )
115
+ end
116
+
117
+ if [location.scheme, location.host, location.port] !=
118
+ [orig.scheme, orig.host, orig.port]
119
+ raise Turbopuffer::Errors::APIError.new(
120
+ url: orig_url,
121
+ message: "'Location' origin does not match request origin: #{raw_location.inspect}"
122
+ )
123
+ end
124
+
125
+ location
126
+ end
127
+
128
+ # @param client [Turbopuffer::Internal::Transport::BaseClient]
129
+ # @param orig_url [URI::Generic]
130
+ # @param location [URI::Generic]
131
+ # @param timeout [Timeout]
132
+ #
133
+ # @return [Array(Integer, Net::HTTPResponse, Array<String>), nil]
134
+ def poll_once(client, orig_url, location, timeout)
135
+ raise Turbopuffer::Errors::APITimeoutError.new(url: orig_url) if timeout.remaining.zero?
136
+
137
+ body =
138
+ begin
139
+ client.request(
140
+ method: :get,
141
+ path: location.request_uri,
142
+ model: Turbopuffer::Internal::Type::Unknown,
143
+ options: {
144
+ extra_headers: {HEADER_PREFER => ""},
145
+ timeout: timeout.poll_timeout
146
+ }
147
+ )
148
+ rescue JSON::ParserError => e
149
+ raise Turbopuffer::Errors::APIError.new(
150
+ url: orig_url,
151
+ message: "malformed poll response: #{e.message}"
152
+ )
153
+ end
154
+
155
+ case body
156
+ in {status: "running"}
157
+ sleep(timeout.sleep_duration)
158
+ nil
159
+ in {status: "finished", result: {success: success}}
160
+ response = Net::HTTPOK.new("1.1", 200, "OK")
161
+ response["content-type"] = "application/json"
162
+ [200, response, [JSON.generate(success)]]
163
+ in {status: "finished", result: {error: {status_code: Integer => err_status, **rest}}}
164
+ raise Turbopuffer::Errors::APIStatusError.for(
165
+ url: orig_url,
166
+ status: err_status,
167
+ headers: nil,
168
+ body: rest[:detail],
169
+ request: nil,
170
+ response: nil
171
+ )
172
+ else
173
+ raise Turbopuffer::Errors::APIError.new(
174
+ url: orig_url,
175
+ message: "malformed poll response: #{body.inspect}"
176
+ )
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -309,6 +309,8 @@ module Turbopuffer
309
309
  headers["x-stainless-timeout"] = timeout.to_s
310
310
  end
311
311
 
312
+ Turbopuffer::Internal::RespondAsync.prepare_headers(headers)
313
+
312
314
  headers.reject! { |_, v| v.to_s.empty? }
313
315
 
314
316
  body =
@@ -501,12 +503,18 @@ module Turbopuffer
501
503
 
502
504
  # Don't send the current retry count in the headers if the caller modified the header defaults.
503
505
  send_retry_header = request.fetch(:headers)["x-stainless-retry-count"] == "0"
504
- status, response, stream = send_request(
506
+ _, response, stream = send_request(
505
507
  request,
506
508
  redirect_count: 0,
507
509
  retry_count: 0,
508
510
  send_retry_header: send_retry_header
509
511
  )
512
+ status, response, stream = Turbopuffer::Internal::RespondAsync.maybe_poll(
513
+ self,
514
+ request,
515
+ response,
516
+ stream
517
+ )
510
518
 
511
519
  headers = Turbopuffer::Internal::Util.normalized_headers(response.each_header.to_h)
512
520
  decoded = Turbopuffer::Internal::Util.decode_content(headers, stream: stream)
@@ -11,13 +11,22 @@ module Turbopuffer
11
11
  required :max_edit_distance,
12
12
  -> { Turbopuffer::Internal::Type::ArrayOf[Turbopuffer::FuzzyMaxEditDistance] }
13
13
 
14
- # @!method initialize(max_edit_distance:)
14
+ # @!attribute case_sensitive
15
+ # Whether searching with Fuzzy filter is case-sensitive. Defaults to `true` (i.e.
16
+ # case-sensitive).
17
+ #
18
+ # @return [Boolean, nil]
19
+ optional :case_sensitive, Turbopuffer::Internal::Type::Boolean
20
+
21
+ # @!method initialize(max_edit_distance:, case_sensitive: nil)
15
22
  # Some parameter documentations has been truncated, see
16
23
  # {Turbopuffer::Models::FuzzyParams} for more details.
17
24
  #
18
25
  # Additional parameters for the Fuzzy filter.
19
26
  #
20
27
  # @param max_edit_distance [Array<Turbopuffer::Models::FuzzyMaxEditDistance>] Maximum edit distance allowed at each query length. Queries shorter than the fir
28
+ #
29
+ # @param case_sensitive [Boolean] Whether searching with Fuzzy filter is case-sensitive. Defaults to `true` (i.e.
21
30
  end
22
31
  end
23
32
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Turbopuffer
4
- VERSION = "2.1.0"
4
+ VERSION = "2.2.0"
5
5
  end
data/lib/turbopuffer.rb CHANGED
@@ -51,6 +51,7 @@ require_relative "turbopuffer/file_part"
51
51
  require_relative "turbopuffer/errors"
52
52
  require_relative "turbopuffer/internal/transport/base_client"
53
53
  require_relative "turbopuffer/internal/transport/pooled_net_requester"
54
+ require_relative "turbopuffer/internal/respond_async"
54
55
  require_relative "turbopuffer/client"
55
56
  require_relative "turbopuffer/internal/namespace_page"
56
57
  require_relative "turbopuffer/models/pinning_config"
@@ -13,22 +13,38 @@ module Turbopuffer
13
13
  sig { returns(T::Array[Turbopuffer::FuzzyMaxEditDistance]) }
14
14
  attr_accessor :max_edit_distance
15
15
 
16
+ # Whether searching with Fuzzy filter is case-sensitive. Defaults to `true` (i.e.
17
+ # case-sensitive).
18
+ sig { returns(T.nilable(T::Boolean)) }
19
+ attr_reader :case_sensitive
20
+
21
+ sig { params(case_sensitive: T::Boolean).void }
22
+ attr_writer :case_sensitive
23
+
16
24
  # Additional parameters for the Fuzzy filter.
17
25
  sig do
18
26
  params(
19
- max_edit_distance: T::Array[Turbopuffer::FuzzyMaxEditDistance::OrHash]
27
+ max_edit_distance:
28
+ T::Array[Turbopuffer::FuzzyMaxEditDistance::OrHash],
29
+ case_sensitive: T::Boolean
20
30
  ).returns(T.attached_class)
21
31
  end
22
32
  def self.new(
23
33
  # Maximum edit distance allowed at each query length. Queries shorter than the
24
34
  # first threshold return no matches.
25
- max_edit_distance:
35
+ max_edit_distance:,
36
+ # Whether searching with Fuzzy filter is case-sensitive. Defaults to `true` (i.e.
37
+ # case-sensitive).
38
+ case_sensitive: nil
26
39
  )
27
40
  end
28
41
 
29
42
  sig do
30
43
  override.returns(
31
- { max_edit_distance: T::Array[Turbopuffer::FuzzyMaxEditDistance] }
44
+ {
45
+ max_edit_distance: T::Array[Turbopuffer::FuzzyMaxEditDistance],
46
+ case_sensitive: T::Boolean
47
+ }
32
48
  )
33
49
  end
34
50
  def to_hash
@@ -1,17 +1,26 @@
1
1
  module Turbopuffer
2
2
  module Models
3
3
  type fuzzy_params =
4
- { max_edit_distance: ::Array[Turbopuffer::FuzzyMaxEditDistance] }
4
+ {
5
+ max_edit_distance: ::Array[Turbopuffer::FuzzyMaxEditDistance],
6
+ case_sensitive: bool
7
+ }
5
8
 
6
9
  class FuzzyParams < Turbopuffer::Internal::Type::BaseModel
7
10
  attr_accessor max_edit_distance: ::Array[Turbopuffer::FuzzyMaxEditDistance]
8
11
 
12
+ attr_reader case_sensitive: bool?
13
+
14
+ def case_sensitive=: (bool) -> bool
15
+
9
16
  def initialize: (
10
- max_edit_distance: ::Array[Turbopuffer::FuzzyMaxEditDistance]
17
+ max_edit_distance: ::Array[Turbopuffer::FuzzyMaxEditDistance],
18
+ ?case_sensitive: bool
11
19
  ) -> void
12
20
 
13
21
  def to_hash: -> {
14
- max_edit_distance: ::Array[Turbopuffer::FuzzyMaxEditDistance]
22
+ max_edit_distance: ::Array[Turbopuffer::FuzzyMaxEditDistance],
23
+ case_sensitive: bool
15
24
  }
16
25
  end
17
26
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turbopuffer
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Turbopuffer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-03 00:00:00.000000000 Z
11
+ date: 2026-06-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: cgi
@@ -55,6 +55,7 @@ files:
55
55
  - lib/turbopuffer/file_part.rb
56
56
  - lib/turbopuffer/internal.rb
57
57
  - lib/turbopuffer/internal/namespace_page.rb
58
+ - lib/turbopuffer/internal/respond_async.rb
58
59
  - lib/turbopuffer/internal/transport/base_client.rb
59
60
  - lib/turbopuffer/internal/transport/pooled_net_requester.rb
60
61
  - lib/turbopuffer/internal/type/array_of.rb