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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +99 -1
- data/ext/patron/session_ext.c +697 -71
- data/lib/patron/request.rb +26 -5
- data/lib/patron/response.rb +2 -54
- data/lib/patron/response_decoding.rb +1 -18
- data/lib/patron/response_head.rb +109 -0
- data/lib/patron/response_stream.rb +217 -0
- data/lib/patron/session.rb +150 -19
- data/lib/patron/sse_client.rb +327 -0
- data/lib/patron/transfer_stats.rb +62 -0
- data/lib/patron/version.rb +1 -1
- data/lib/patron.rb +1 -0
- metadata +6 -2
data/lib/patron/session.rb
CHANGED
|
@@ -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
|
|
212
|
-
#
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
data/lib/patron/version.rb
CHANGED
data/lib/patron.rb
CHANGED
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.
|
|
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-
|
|
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
|