patron 1.0.0 → 1.1.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.
@@ -3,11 +3,14 @@
3
3
  require 'uri'
4
4
  require 'patron/error'
5
5
  require 'patron/request'
6
+ require 'patron/header_parser'
7
+ require 'patron/response_head'
6
8
  require 'patron/response_decoding'
7
9
  require 'patron/response'
10
+ require 'patron/response_stream'
11
+ require 'patron/transfer_stats'
8
12
  require 'patron/session_ext'
9
13
  require 'patron/util'
10
- require 'patron/header_parser'
11
14
  require 'patron/compat'
12
15
 
13
16
  module Patron
@@ -126,6 +129,13 @@ module Patron
126
129
  # @see low_speed_time
127
130
  attr_accessor :low_speed_limit
128
131
 
132
+ # @return [Numeric, nil] how long to wait for the next piece of the response, in seconds,
133
+ # counted from the previous one. Unlike #timeout it does not bound the whole transfer,
134
+ # which makes it the one to use for streams. Defaults to nil, no limit. Exceeding it
135
+ # raises Patron::TimeoutError.
136
+ # @see Patron::Request#read_timeout=
137
+ attr_accessor :read_timeout
138
+
129
139
  # @return [#call, nil] callable object that will be called with 4 arguments
130
140
  # during request/response execution - `dltotal`, `dlnow`, `ultotal`, `ulnow`.
131
141
  # All these arguments are in bytes. Returning `false` from the callback aborts the
@@ -208,8 +218,8 @@ module Patron
208
218
  end
209
219
 
210
220
  # Retrieve the contents of the specified +url+ as with #get, but the
211
- # content at the URL is downloaded directly into the specified file. The file will be accessed
212
- # by libCURL bypassing the Ruby runtime entirely.
221
+ # content at the URL is downloaded directly into the specified file. The body is streamed
222
+ # into the file through one reused buffer, see #stream.
213
223
  #
214
224
  # Note that when using this option, the Response object will have ++nil++ as the body, and you
215
225
  # will need to read your target file for access to the body string).
@@ -247,9 +257,10 @@ module Patron
247
257
  # unline ++post++, a Hash is not accepted as the ++data++ argument.
248
258
  #
249
259
  # @param url[String] the URL to fetch
250
- # @param data[#to_s, #to_path] an object that can be converted to a String
251
- # to create the request body, or that responds to #to_path to upload the
252
- # entire request body from that file
260
+ # @param data[#to_s, #to_path, #read] an object that can be converted to a String
261
+ # to create the request body, that responds to #to_path to upload the
262
+ # entire request body from that file, or that responds to #read to stream
263
+ # the request body from it (see Request#upload_data=)
253
264
  # @param headers[Hash] the hash of header keys to values
254
265
  # @return [Patron::Response]
255
266
  def put(url, data, headers = {})
@@ -260,9 +271,10 @@ module Patron
260
271
  # unline ++post++, a Hash is not accepted as the ++data++ argument.
261
272
  #
262
273
  # @param url[String] the URL to fetch
263
- # @param data[#to_s, #to_path] an object that can be converted to a String
264
- # to create the request body, or that responds to #to_path to upload the
265
- # entire request body from that file
274
+ # @param data[#to_s, #to_path, #read] an object that can be converted to a String
275
+ # to create the request body, that responds to #to_path to upload the
276
+ # entire request body from that file, or that responds to #read to stream
277
+ # the request body from it (see Request#upload_data=)
266
278
  # @param headers[Hash] the hash of header keys to values
267
279
  # @return [Patron::Response]
268
280
  def patch(url, data, headers = {})
@@ -283,17 +295,15 @@ module Patron
283
295
  # Uploads the passed `data` to the specified `url` using an HTTP POST.
284
296
  #
285
297
  # @param url[String] the URL to fetch
286
- # @param data[Hash, #to_s, #to_path] a Hash of form fields/values,
298
+ # @param data[Hash, #to_s, #to_path, #read] a Hash of form fields/values,
287
299
  # or an object that can be converted to a String
288
- # to create the request body, or an object that responds to #to_path to upload the
289
- # entire request body from that file
300
+ # to create the request body, an object that responds to #to_path to upload the
301
+ # entire request body from that file, or one that responds to #read to stream
302
+ # the request body from it (see Request#upload_data=)
290
303
  # @param headers[Hash] the hash of header keys to values
291
304
  # @return [Patron::Response]
292
305
  def post(url, data, headers = {})
293
- if data.is_a?(Hash)
294
- data = data.map { |k, v| urlencode(k.to_s) + '=' + urlencode(v.to_s) }.join('&')
295
- headers = headers.merge('Content-Type' => 'application/x-www-form-urlencoded')
296
- end
306
+ data, headers = form_encode(data, headers)
297
307
  request(:post, url, headers, data: data)
298
308
  end
299
309
 
@@ -332,19 +342,102 @@ module Patron
332
342
  end
333
343
  # @!endgroup
334
344
 
345
+ # @!group Streaming
346
+ # Performs a request and yields its response as a ResponseStream as soon as the head of the
347
+ # final response has arrived, before the body. The block receives the body by reading from
348
+ # the stream; nothing is buffered meanwhile, and a consumer that does not read holds the
349
+ # transfer back. When the block ends before the body does (`break`, `return`, an exception)
350
+ # the transfer is aborted and its connection closed; call ResponseStream#drain! first to keep it.
351
+ #
352
+ # The Session is busy for the duration of the block, so another request on it from inside
353
+ # the block raises Patron::Error. #timeout still bounds the whole transfer and is usually
354
+ # wanted at 0 here, with `read_timeout:` bounding the wait for each next piece instead.
355
+ #
356
+ # @example
357
+ # stats = session.stream(:get, "/events", { "Accept" => "text/event-stream" }, timeout: 0, read_timeout: 30) do |stream|
358
+ # raise "Unexpected #{stream.status}" unless stream.ok?
359
+ # stream.each_line { |line| handle(line) }
360
+ # end
361
+ # stats.complete? #=> true
362
+ #
363
+ # @param action[#to_s] the HTTP verb
364
+ # @param url[String] the URL for the request
365
+ # @param headers[Hash] headers to send along with the request
366
+ # @param options[Hash] the same options as #request takes
367
+ # @yieldparam stream [Patron::ResponseStream] closed once the block ends
368
+ # @return [Patron::TransferStats] the receipt of the transfer
369
+ # @raise [ArgumentError] without a block
370
+ def stream(action, url, headers = {}, **options, &block)
371
+ raise ArgumentError, "Patron::Session#stream needs a block" unless block
372
+ run_stream(build_request(action, url, headers, **options), &block).__send__(:receipt)
373
+ end
374
+
375
+ # Same as #stream with a GET
376
+ #
377
+ # @param url[String] the URL to fetch
378
+ # @param headers[Hash] the hash of header keys to values
379
+ # @param options[Hash] the same options as #request takes
380
+ # @yieldparam stream [Patron::ResponseStream]
381
+ # @return [Patron::TransferStats]
382
+ def get_stream(url, headers = {}, **options, &block)
383
+ stream(:get, url, headers, **options, &block)
384
+ end
385
+
386
+ # Same as #stream with a PUT, sending `data` like #put does
387
+ #
388
+ # @param url[String] the URL for the request
389
+ # @param data[#to_s, #to_path, #read] the request body, see #put
390
+ # @param headers[Hash] the hash of header keys to values
391
+ # @param options[Hash] the same options as #request takes
392
+ # @yieldparam stream [Patron::ResponseStream]
393
+ # @return [Patron::TransferStats]
394
+ def put_stream(url, data, headers = {}, **options, &block)
395
+ stream(:put, url, headers, **options, data: data, &block)
396
+ end
397
+
398
+ # Same as #stream with a POST, sending `data` like #post does: a Hash is form-encoded
399
+ #
400
+ # @param url[String] the URL for the request
401
+ # @param data[Hash, #to_s, #to_path, #read] the request body, see #post
402
+ # @param headers[Hash] the hash of header keys to values
403
+ # @param options[Hash] the same options as #request takes
404
+ # @yieldparam stream [Patron::ResponseStream]
405
+ # @return [Patron::TransferStats]
406
+ def post_stream(url, data, headers = {}, **options, &block)
407
+ data, headers = form_encode(data, headers)
408
+ stream(:post, url, headers, **options, data: data, &block)
409
+ end
410
+
411
+ # Same as #stream with a PATCH, sending `data` like #patch does
412
+ #
413
+ # @param url[String] the URL for the request
414
+ # @param data[#to_s, #to_path, #read] the request body, see #patch
415
+ # @param headers[Hash] the hash of header keys to values
416
+ # @param options[Hash] the same options as #request takes
417
+ # @yieldparam stream [Patron::ResponseStream]
418
+ # @return [Patron::TransferStats]
419
+ def patch_stream(url, data, headers = {}, **options, &block)
420
+ stream(:patch, url, headers, **options, data: data, &block)
421
+ end
422
+ # @!endgroup
423
+
335
424
  # @!group Basic API methods
336
425
  # Send an HTTP request to the specified `url`.
337
426
  #
338
427
  # @param action[#to_s] the HTTP verb
339
428
  # @param url[String] the URL for the request
340
429
  # @param headers[Hash] headers to send along with the request
341
- # @param options[Hash] any additional Request attributes, plus `:data`, `:file`, `:multipart` and `:query`
430
+ # @param options[Hash] any additional Request attributes, plus `:data`, `:file`, `:multipart` and `:query`.
431
+ # `:data` may be a Hash, a String, or an object responding to #to_path or #read (see Request#upload_data=)
342
432
  # @see Patron::Request
343
433
  # @return [Patron::Response]
344
434
  def request(action, url, headers = {}, *positional, **options)
345
435
  # The 0.x positional options Hash is tolerated here rather than in a prepended module, since
346
436
  # sniffer and friends alias_method-chain this method and a prepend would recurse into the alias
347
437
  options = Compat.hash_to_keywords("Patron::Session#request with an options Hash", positional, options)
438
+ if options[:file] && !options[:multipart] && action.to_s.casecmp?("get")
439
+ return download(build_request(action, url, headers, **options.except(:file)), options[:file])
440
+ end
348
441
  handle_request(build_request(action, url, headers, **options))
349
442
  end
350
443
 
@@ -368,7 +461,8 @@ module Patron
368
461
  # @param action[String] the HTTP verb
369
462
  # @param url[#to_s] the addition to the base url component, or a complete URL
370
463
  # @param headers[Hash] a hash of headers, "Expect" will be automatically set to an empty string if not provided
371
- # @param options[Hash] any overriding options (will shadow the options from the Session object)
464
+ # @param options[Hash] any overriding options (will shadow the options from the Session object).
465
+ # `:data` may be a Hash, a String, or an object responding to #to_path or #read (see Request#upload_data=)
372
466
  # @return [Patron::Request] the request that will be passed to ++handle_request++
373
467
  def build_request(action, url, headers = {}, **options)
374
468
  # Without an empty Expect header uploads wait for a "100 Continue" that may never come
@@ -393,10 +487,47 @@ module Patron
393
487
  automatic_content_encoding timeout connect_timeout dns_cache_timeout low_speed_time low_speed_limit
394
488
  force_ipv4 max_redirects username password proxy proxy_type auth_type insecure ssl_version http_version
395
489
  cacert ssl_cert ssl_cert_type ssl_key ssl_key_password ignore_content_length buffer_size
396
- download_byte_limit progress_callback
490
+ download_byte_limit progress_callback read_timeout
397
491
  ].freeze
398
492
  private_constant :REQUEST_ATTRIBUTES_FROM_SESSION
399
493
 
494
+ # The ensure is what aborts the transfer on break, return, raise, Thread#kill or Task#stop.
495
+ # A transfer_start that raised has cleaned up after itself - and when it raised because
496
+ # a stream is already open in this very fiber, transfer_finish would end that one
497
+ def run_stream(request)
498
+ head = stream = nil
499
+ begin
500
+ head = transfer_start(request)
501
+ status, effective_url, redirect_count, header_data = head
502
+ stream = ResponseStream.new(self, status: status, url: effective_url, redirect_count: redirect_count,
503
+ header_data: header_data, default_charset: default_response_charset)
504
+ yield stream
505
+ ensure
506
+ if stream
507
+ stream.__send__(:finish)
508
+ elsif head
509
+ transfer_finish
510
+ end
511
+ end
512
+ stream
513
+ end
514
+
515
+ def download(request, path)
516
+ File.open(path, "wb") do |file|
517
+ stream = run_stream(request) { |s| IO.copy_stream(s, file) }
518
+ stats = stream.__send__(:receipt)
519
+ response_class.new(url: stats.url, status: stats.status, redirect_count: stats.redirect_count,
520
+ header_data: stream.__send__(:header_data), body: nil,
521
+ default_charset: default_response_charset, timings: stats.timings)
522
+ end
523
+ end
524
+
525
+ def form_encode(data, headers)
526
+ return [data, headers] unless data.is_a?(Hash)
527
+ encoded = data.map { |k, v| urlencode(k.to_s) + '=' + urlencode(v.to_s) }.join('&')
528
+ [encoded, headers.merge('Content-Type' => 'application/x-www-form-urlencoded')]
529
+ end
530
+
400
531
  def build_url(url, query)
401
532
  base_url = self.base_url.to_s
402
533
  url = url.to_s
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Patron
4
+
5
+ # A client for server-sent events (`text/event-stream`), built on Session#stream. Parses the
6
+ # stream by the WHATWG event stream algorithm and reconnects the way an EventSource does:
7
+ # after the retry interval, sending Last-Event-ID.
8
+ #
9
+ # @example
10
+ # client = Patron::SSEClient.new("https://example.com/stream", headers: { "Authorization" => "Bearer ..." }, idle_timeout: 45)
11
+ # client.each_event do |event|
12
+ # puts event.type, event.data, event.id
13
+ # break if event.type == "done"
14
+ # end
15
+ # client.last_event_id # to persist between process runs
16
+ #
17
+ # A client holds one stream at a time and is meant for one thread or fiber; only #close may be
18
+ # called from another one.
19
+ class SSEClient
20
+ # One dispatched event. `type` is "message" unless the server sent an `event:` field, `data` is
21
+ # the `data:` lines joined with "\n", `id` the last event ID at the time, or nil.
22
+ Event = Data.define(:type, :data, :id)
23
+
24
+ # Raised for a response that is not an event stream: a status other than 200 or 204, or a
25
+ # Content-Type other than text/event-stream. Retrying it is up to the application.
26
+ class UnexpectedResponse < Patron::Error
27
+ # @return [Integer] the HTTP status
28
+ attr_reader :status
29
+
30
+ # @return [Hash] the response headers
31
+ attr_reader :headers
32
+
33
+ # @return [String] up to the first 4 KB of the response body
34
+ attr_reader :body
35
+
36
+ # @param status [Integer]
37
+ # @param headers [Hash]
38
+ # @param body [String]
39
+ def initialize(status, headers, body)
40
+ @status = status
41
+ @headers = headers
42
+ @body = body
43
+ super("Expected a text/event-stream with status 200, got status #{status} with Content-Type #{headers.find { |k, _| k.casecmp?("Content-Type") }&.last.inspect}")
44
+ end
45
+ end
46
+
47
+ # Raised when the stream could not be reopened within `max_reconnects` attempts
48
+ class ReconnectsExhausted < Patron::Error; end
49
+
50
+ # Raised when an event is not valid UTF-8, which an event stream must be. The stream ends there
51
+ # instead of reconnecting: a server that sends garbage would send it again.
52
+ class InvalidEncoding < Patron::Error; end
53
+
54
+ # The WHATWG event stream parser on its own: feed it the bytes of a stream and it yields
55
+ # Events. SSEClient runs one of these over its connections; {SSEClient.parse} runs one over
56
+ # any readable. Keeps the last event ID and the retry interval the stream asked for.
57
+ class Parser
58
+ BOM = "\xEF\xBB\xBF".b.freeze
59
+ LINE_END_RE = /[\r\n]/n
60
+ private_constant :BOM, :LINE_END_RE
61
+
62
+ # @return [String, nil] the ID of the last event dispatched
63
+ attr_reader :last_event_id
64
+
65
+ # @return [Float, nil] seconds from the last `retry:` field, nil when there was none
66
+ attr_reader :retry_interval
67
+
68
+ # @param last_event_id [String, nil] the ID to start from
69
+ def initialize(last_event_id: nil)
70
+ @last_event_id = last_event_id
71
+ @id_buffer = last_event_id
72
+ @retry_interval = nil
73
+ reset
74
+ end
75
+
76
+ # Forgets the bytes and the partial event of a stream that ended, for the next one. The
77
+ # last event ID and the retry interval stay.
78
+ #
79
+ # @return [nil]
80
+ def reset
81
+ @pending = String.new(encoding: Encoding::BINARY)
82
+ @bom_checked = false
83
+ @data = nil
84
+ @event_type = nil
85
+ end
86
+
87
+ # Takes the next bytes of the stream and yields every event they complete.
88
+ #
89
+ # @param bytes [String] a piece of the stream, of any length and at any boundary
90
+ # @yieldparam event [Patron::SSEClient::Event]
91
+ # @return [nil]
92
+ # @raise [InvalidEncoding] when an event is not valid UTF-8
93
+ def feed(bytes, &block)
94
+ @pending << bytes
95
+
96
+ unless @bom_checked
97
+ return if @pending.bytesize < BOM.bytesize && BOM.start_with?(@pending)
98
+ @pending = @pending.byteslice(BOM.bytesize, @pending.bytesize) if @pending.start_with?(BOM)
99
+ @bom_checked = true
100
+ end
101
+
102
+ process_lines(&block)
103
+ end
104
+
105
+ # Tells the parser the stream has ended. A "\r" held back for a "\n" that never came does end
106
+ # its line; an event the stream ends in the middle of is not dispatched.
107
+ #
108
+ # @yieldparam event [Patron::SSEClient::Event]
109
+ # @return [nil]
110
+ def finish(&block)
111
+ return unless @pending.end_with?("\r")
112
+ @pending << "\n"
113
+ process_lines(&block)
114
+ end
115
+
116
+ private
117
+
118
+ # Takes every complete line off the pending bytes. A "\r" at the very end may be the first
119
+ # half of a "\r\n" whose "\n" is still on the wire, so it waits for more
120
+ def process_lines(&block)
121
+ pos = 0
122
+ while (line_end = @pending.byteindex(LINE_END_RE, pos))
123
+ if @pending.getbyte(line_end) == 13 # \r
124
+ break if line_end == @pending.bytesize - 1
125
+ terminator = @pending.getbyte(line_end + 1) == 10 ? 2 : 1
126
+ else
127
+ terminator = 1
128
+ end
129
+ process_line(@pending.byteslice(pos, line_end - pos), &block)
130
+ pos = line_end + terminator
131
+ end
132
+ @pending = @pending.byteslice(pos, @pending.bytesize - pos) unless pos.zero?
133
+ nil
134
+ end
135
+
136
+ def process_line(line, &block)
137
+ return dispatch(&block) if line.empty?
138
+ return if line.start_with?(":")
139
+
140
+ field, value = line.split(":", 2)
141
+ value = value ? value.delete_prefix(" ") : ""
142
+ case field
143
+ when "event" then @event_type = value
144
+ when "data" then (@data ||= []) << value
145
+ when "id" then @id_buffer = (value.empty? ? nil : utf8(value)) unless value.include?("\0")
146
+ when "retry" then @retry_interval = Integer(value, 10) / 1000.0 if value.match?(/\A\d+\z/)
147
+ end
148
+ end
149
+
150
+ def dispatch
151
+ @last_event_id = @id_buffer
152
+ if @data
153
+ type = @event_type.nil? || @event_type.empty? ? "message" : utf8(@event_type)
154
+ event = Event.new(type: type, data: utf8(@data.join("\n")), id: @last_event_id)
155
+ @data = nil
156
+ @event_type = nil
157
+ yield event
158
+ else
159
+ @data = nil
160
+ @event_type = nil
161
+ end
162
+ end
163
+
164
+ # A browser would put U+FFFD where the bad bytes are; here they end the stream, so that no
165
+ # garbled event gets acted on
166
+ def utf8(str)
167
+ str = str.dup.force_encoding(Encoding::UTF_8)
168
+ raise InvalidEncoding, "The event stream is not valid UTF-8" unless str.valid_encoding?
169
+ str.freeze
170
+ end
171
+ end
172
+
173
+ BODY_EXCERPT_SIZE = 4096
174
+ CHUNK_SIZE = 16 * 1024
175
+ private_constant :BODY_EXCERPT_SIZE, :CHUNK_SIZE
176
+
177
+ # Parses an event stream from any readable and yields its events, for the streams SSEClient
178
+ # does not open itself: the response to a POST, a file, a socket. Reads with `readpartial`
179
+ # until EOFError and returns when the stream ends or the block breaks. Nothing about the
180
+ # response is checked here, that is the caller's job.
181
+ #
182
+ # @example An LLM completion
183
+ # session.post_stream("/v1/chat/completions", body.to_json, headers) do |stream|
184
+ # raise "status #{stream.status}" unless stream.status == 200
185
+ # Patron::SSEClient.parse(stream) do |event|
186
+ # break if event.data == "[DONE]"
187
+ # print JSON.parse(event.data).dig("choices", 0, "delta", "content")
188
+ # end
189
+ # end
190
+ #
191
+ # @param stream [Patron::ResponseStream, IO, #readpartial] where the bytes come from
192
+ # @param last_event_id [String, nil] the ID events carry until the stream sends one
193
+ # @yieldparam event [Patron::SSEClient::Event]
194
+ # @return [String, nil] the ID of the last event
195
+ # @raise [InvalidEncoding] when an event is not valid UTF-8
196
+ def self.parse(stream, last_event_id: nil, &block)
197
+ raise ArgumentError, "Patron::SSEClient.parse needs a block" unless block
198
+ parser = Parser.new(last_event_id: last_event_id)
199
+ buf = String.new(capacity: CHUNK_SIZE, encoding: Encoding::BINARY)
200
+ loop do
201
+ begin
202
+ stream.readpartial(CHUNK_SIZE, buf)
203
+ rescue EOFError
204
+ parser.finish(&block)
205
+ break
206
+ end
207
+ parser.feed(buf, &block)
208
+ end
209
+ parser.last_event_id
210
+ end
211
+
212
+ # @return [String, nil] the ID of the last event, sent as Last-Event-ID when reconnecting
213
+ attr_reader :last_event_id
214
+
215
+ # @param url [String] the stream URL, relative to the Session's base_url if it has one
216
+ # @param headers [Hash] extra request headers
217
+ # @param idle_timeout [Numeric] seconds of silence after which the stream is reopened; becomes
218
+ # the requests' read_timeout. Servers send comment lines to keep a quiet stream alive.
219
+ # @param retry [Numeric] seconds to wait before reconnecting, until the server sends a `retry:` field
220
+ # @param max_reconnects [Integer, nil] how many times in a row to reconnect without receiving an
221
+ # event in between, nil for no limit
222
+ # @param last_event_id [String, nil] the ID to resume from
223
+ # @param session [Patron::Session, nil] the Session to use, for TLS, proxy, authentication,
224
+ # cookies or base_url. Only `timeout` and `read_timeout` are set, per request.
225
+ def initialize(url, headers: {}, idle_timeout: 60, retry: 3, max_reconnects: nil, last_event_id: nil, session: nil)
226
+ @url = url
227
+ @headers = headers
228
+ @idle_timeout = idle_timeout
229
+ @retry_interval = binding.local_variable_get(:retry).to_f # `retry` is a keyword, so no plain local
230
+ @max_reconnects = max_reconnects
231
+ @last_event_id = last_event_id
232
+ @parser = Parser.new(last_event_id: last_event_id)
233
+ @session = session || Session.new
234
+ @closed = false
235
+ @wakeup = Thread::Queue.new
236
+ end
237
+
238
+ # Opens the stream and yields every event until the block breaks or raises, the server answers
239
+ # 204, or #close is called. Reconnects after a clean end of the stream, an idle timeout,
240
+ # Patron::ConnectionFailed and Patron::PartialFileError.
241
+ #
242
+ # @yieldparam event [Patron::SSEClient::Event]
243
+ # @return [nil]
244
+ # @raise [UnexpectedResponse] for a response that is not an event stream
245
+ # @raise [ReconnectsExhausted] when `max_reconnects` is exceeded
246
+ # @raise [InvalidEncoding] when an event is not valid UTF-8
247
+ def each_event(&block)
248
+ raise ArgumentError, "Patron::SSEClient#each_event needs a block" unless block
249
+ @reconnects = 0
250
+ until @closed
251
+ begin
252
+ return if receive_events(&block) == :no_content
253
+ rescue Patron::Aborted
254
+ return if @closed
255
+ raise
256
+ rescue Patron::TimeoutError, Patron::ConnectionFailed, Patron::PartialFileError
257
+ nil
258
+ end
259
+ break if @closed
260
+
261
+ @reconnects += 1
262
+ if @max_reconnects && @reconnects > @max_reconnects
263
+ raise ReconnectsExhausted, "Gave up on #{@url} after #{@max_reconnects} reconnects without an event"
264
+ end
265
+ @wakeup.pop(timeout: @retry_interval)
266
+ end
267
+ nil
268
+ end
269
+
270
+ # Stops #each_event, which then returns. Safe to call from another thread or fiber.
271
+ #
272
+ # @return [nil]
273
+ def close
274
+ @closed = true
275
+ @wakeup << :close
276
+ @session.interrupt
277
+ nil
278
+ end
279
+
280
+ # @return [Boolean] whether #close has been called
281
+ def closed? = @closed
282
+
283
+ private
284
+
285
+ def request_headers
286
+ headers = { "Accept" => "text/event-stream", "Cache-Control" => "no-cache" }.merge(@headers)
287
+ headers["Last-Event-ID"] = @last_event_id if @last_event_id
288
+ headers
289
+ end
290
+
291
+ def receive_events(&block)
292
+ @session.get_stream(@url, request_headers, timeout: 0, read_timeout: @idle_timeout) do |stream|
293
+ # A #close that came before this request started has been reset along with the transfer
294
+ return if @closed
295
+ return :no_content if stream.status == 204
296
+ unless stream.status == 200 && stream.content_type == "text/event-stream"
297
+ raise UnexpectedResponse.new(stream.status, stream.headers, stream.read(BODY_EXCERPT_SIZE) || +"")
298
+ end
299
+ parse(stream, &block)
300
+ end
301
+ :eof
302
+ end
303
+
304
+ def parse(stream, &block)
305
+ buf = String.new(capacity: CHUNK_SIZE, encoding: Encoding::BINARY)
306
+ @parser.reset
307
+ loop do
308
+ begin
309
+ stream.readpartial(CHUNK_SIZE, buf)
310
+ rescue EOFError
311
+ @parser.finish { |event| dispatch(event, &block) }
312
+ break
313
+ end
314
+ @parser.feed(buf) { |event| dispatch(event, &block) }
315
+ ensure
316
+ # An id: field counts even without data, so this cannot wait for an event
317
+ @last_event_id = @parser.last_event_id
318
+ @retry_interval = @parser.retry_interval if @parser.retry_interval
319
+ end
320
+ end
321
+
322
+ def dispatch(event)
323
+ @reconnects = 0
324
+ yield event
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Patron
4
+
5
+ # The receipt Session#stream returns once its block has ended: the same head readers as
6
+ # the ResponseStream had, plus what is only known once the body is over. Frozen and
7
+ # Ractor-shareable.
8
+ class TransferStats
9
+ include ResponseHead
10
+
11
+ # @return [Integer] body bytes received, as libcurl counts them (CURLINFO_SIZE_DOWNLOAD_T)
12
+ attr_reader :downloaded_bytes
13
+
14
+ # @return [Integer] request body bytes sent (CURLINFO_SIZE_UPLOAD_T)
15
+ attr_reader :uploaded_bytes
16
+
17
+ # @return [Hash{Symbol => Float}] the same transfer marks as Response#timings
18
+ attr_reader :timings
19
+
20
+ # @return [Hash] the trailers that followed a chunked or HTTP/2 body, in the same shape as
21
+ # #headers. Empty when there were none, or when the block left before the end of the body.
22
+ attr_reader :trailers
23
+
24
+ # @param url [String] the effective URL after redirects
25
+ # @param status [Integer] the HTTP status code
26
+ # @param redirect_count [Integer] how many redirects were followed
27
+ # @param header_data [String] the raw header lines of every response in the chain
28
+ # @param downloaded_bytes [Integer]
29
+ # @param uploaded_bytes [Integer]
30
+ # @param timings [Hash{Symbol => Float}]
31
+ # @param complete [Boolean] whether the body was received to its end
32
+ # @param trailer_data [String] the raw trailer lines
33
+ # @param default_charset [String, nil] the charset to assume when the Content-Type header names none
34
+ def initialize(url:, status:, redirect_count:, header_data:, downloaded_bytes:, uploaded_bytes:, timings:,
35
+ complete:, trailer_data: "", default_charset: nil)
36
+ @url = url
37
+ @status = status
38
+ @redirect_count = redirect_count
39
+ @downloaded_bytes = downloaded_bytes
40
+ @uploaded_bytes = uploaded_bytes
41
+ @timings = timings
42
+ @complete = complete
43
+ parse_head(header_data, default_charset)
44
+ @trailers = ResponseHead.headers_from_lines(trailer_lines(trailer_data))
45
+ Ractor.make_shareable(self)
46
+ end
47
+
48
+ # @return [Boolean] whether the body was received to its end. False when the block left
49
+ # early, raised, or the transfer failed.
50
+ def complete? = @complete
51
+
52
+ def inspect
53
+ "#<Patron::TransferStats @status_line='#{@status_line}' complete=#{@complete} downloaded_bytes=#{@downloaded_bytes}>"
54
+ end
55
+
56
+ private
57
+
58
+ def trailer_lines(trailer_data)
59
+ decode_header_data(trailer_data).each_line.map(&:strip).select { |line| line.match?(HeaderParser::HEADER_LINE_START_RE) }
60
+ end
61
+ end
62
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Patron
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/patron.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'patron/version'
4
4
  require 'patron/session'
5
+ require 'patron/sse_client'
5
6
 
6
7
  module Patron
7
8
  # @return [String] the version of the gem
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: patron
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aeryn Riley Dowling-Toland
8
8
  - Julik Tarkhanov
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-29 00:00:00.000000000 Z
11
+ date: 2026-11-04 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Ruby HTTP client library based on libcurl
14
14
  email:
@@ -31,7 +31,11 @@ files:
31
31
  - lib/patron/request.rb
32
32
  - lib/patron/response.rb
33
33
  - lib/patron/response_decoding.rb
34
+ - lib/patron/response_head.rb
35
+ - lib/patron/response_stream.rb
34
36
  - lib/patron/session.rb
37
+ - lib/patron/sse_client.rb
38
+ - lib/patron/transfer_stats.rb
35
39
  - lib/patron/util.rb
36
40
  - lib/patron/version.rb
37
41
  homepage: https://github.com/toland/patron