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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9fe1ac5162e7ee75918ceb165caf4c9472c55a8f7261e6dacb54855f1c037799
|
|
4
|
+
data.tar.gz: 339a2034c51711350be141b6a6812afa220e5209dd2449b84e7a84a9e4051b5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f758953f5bfd94011887a9b824d30e996c99ecb263fc96df2f2955ccda81b88a5fb588b1533acc8bea386ab9f5f30a50fb922dc658ba65d69abbdeb57fbef08
|
|
7
|
+
data.tar.gz: 9d1ee100c09241ace6d0879ece310d26a74214d929541858f8877abf31ab61c11a770af93d09bd4e36e315eb16283284772cd37a991cd90b819b52989b35eecf
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
### Unreleased
|
|
2
2
|
|
|
3
|
+
### 1.1.0
|
|
4
|
+
|
|
5
|
+
* `Session#stream(action, url, headers, **options) { |stream| }` and the `get_stream`, `put_stream`,
|
|
6
|
+
`post_stream` (a Hash is form-encoded like `post`) and `patch_stream` helpers yield the response as soon
|
|
7
|
+
as its head has arrived, and return a `Patron::TransferStats` once the block is done. The body is received
|
|
8
|
+
by reading from the stream, which drives the transfer in the calling thread or fiber, so a Fiber
|
|
9
|
+
scheduler, `Session#interrupt`, `Timeout` and `Thread#raise` all work mid-body
|
|
10
|
+
* `Patron::ResponseStream` has the head (`status`, `headers`, `content_length`, `content_type`, ...) and the
|
|
11
|
+
IO-like readers `read`, `readpartial`, `each`, `each_line`, `drain!`, `eof?` and `close`. `read` and
|
|
12
|
+
`readpartial` fill a String you pass in place, so `IO.copy_stream(stream, destination)` does not allocate
|
|
13
|
+
per chunk. Leaving the block early closes the connection; `drain!` keeps it
|
|
14
|
+
* `Patron::TransferStats`, the receipt: the same head readers plus `downloaded_bytes`, `uploaded_bytes`,
|
|
15
|
+
`timings`, `trailers` and `complete?`. Frozen and Ractor-shareable
|
|
16
|
+
* `Session#read_timeout` / `read_timeout:` bounds the wait for the next piece of a response, for streamed and
|
|
17
|
+
buffered requests alike, raising `Patron::TimeoutError`. Unlike `timeout` it does not bound the whole transfer
|
|
18
|
+
* `Response` gains `content_length` and `content_type` (the media type without parameters)
|
|
19
|
+
* `get_file` (and `request(:get, ..., file: path)`) is built on `stream` and `IO.copy_stream`, and returns the
|
|
20
|
+
same `Response` with a nil body as before. A `Request` with a `file_name` handed to `handle_request` as a GET
|
|
21
|
+
raises `ArgumentError` now
|
|
22
|
+
* `Patron::SSEClient`, a server-sent events client built on `stream`: WHATWG event stream parsing, reconnects
|
|
23
|
+
after the server's `retry:` interval with `Last-Event-ID`, an idle timeout, `close` from another thread or
|
|
24
|
+
fiber, `UnexpectedResponse` for anything that is not an event stream and `InvalidEncoding` for an event that
|
|
25
|
+
is not UTF-8. `Patron::SSEClient.parse(readable) { |event| }` runs the parser over any stream, such as the
|
|
26
|
+
body of a POST inside a `post_stream` block. Closes #158
|
|
27
|
+
* Under a Fiber scheduler, `Session#interrupt` and `SSEClient#close` called from another fiber on the same
|
|
28
|
+
thread take effect at once: `Patron::Aborted` is raised into the waiting fiber through the scheduler, the way
|
|
29
|
+
`Async::Task#stop` does it. They used to land within a second
|
|
30
|
+
* Under a Fiber scheduler on Linux, a request right after an aborted one on the same Session could time out
|
|
31
|
+
connecting: the new socket got the old descriptor number and the same IO wrapper, which io-event's epoll
|
|
32
|
+
selector took for still registered. The wrappers are now made afresh for every request
|
|
33
|
+
* webmock's adapter overrides `handle_request` and does not intercept streams, nor `get_file` now
|
|
34
|
+
* `Response#charset` and `#decoded_body` find the charset in a lowercase `content-type` header too, as sent by HTTP/2 servers and Puma 8
|
|
35
|
+
* Test suite: Puma 8, Rack 3 lowercase header names in the test app
|
|
36
|
+
* A request body that responds to `read` (a StringIO, an `IO.pipe` reader, a `Zlib::GzipReader`) is streamed
|
|
37
|
+
to the server through libcurl's read callback, sized from its `size`, from a `Content-Length` header, or sent
|
|
38
|
+
chunked. It is rewound with `seek` when libcurl has to send it again on a 307/308 redirect, and a body that
|
|
39
|
+
cannot be rewound raises `Patron::Error` with `curl_code` 65 instead of being silently sent empty.
|
|
40
|
+
It is read with `readpartial` when it has one, so a pipe or a socket goes out as the writer writes to it
|
|
41
|
+
rather than in 64KB pieces
|
|
42
|
+
* `put_file`, `post_file` and File bodies are sent again when a 307/308 redirect or an auth round-trip needs
|
|
43
|
+
the body once more; such a request used to fail with "necessary data rewind wasn't possible"
|
|
44
|
+
* A StringIO request body is sent as its contents; it used to be sent as its `inspect` string
|
|
45
|
+
|
|
3
46
|
### 1.0.0
|
|
4
47
|
|
|
5
48
|
Patron 1.0 is a rewrite of the C extension for modern Ruby and modern libcurl. It follows Ivo Anjo's
|
data/README.md
CHANGED
|
@@ -76,6 +76,21 @@ You can ship custom headers with a single request:
|
|
|
76
76
|
sess.post("/foo/stuff", "some data", {"Content-Type" => "text/plain"})
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
A request body can be a String, a File (uploaded straight from disk by libcurl) or anything that
|
|
80
|
+
responds to `readpartial` or `read`, which is streamed. With `readpartial` a pipe or a socket goes out
|
|
81
|
+
as soon as something is written to it. A body without a `size` goes out with chunked transfer-encoding,
|
|
82
|
+
unless you pass a `Content-Length` header:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
reader, writer = IO.pipe
|
|
86
|
+
Thread.new { 3.times { |i| writer.write("line #{i}\n") }; writer.close }
|
|
87
|
+
sess.put("/foo/log", reader)
|
|
88
|
+
sess.post("/foo/stuff", StringIO.new("some data"))
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
If the body has to be sent again (a 307 redirect, say) Patron rewinds it with `seek`,
|
|
92
|
+
and a body that can't be rewound, like a pipe, fails the request with a `Patron::Error`.
|
|
93
|
+
|
|
79
94
|
libcurl's transfer timings are available on the response, in seconds since the request started:
|
|
80
95
|
|
|
81
96
|
```ruby
|
|
@@ -105,6 +120,88 @@ This depends on the TLS library libcurl is built against: with GnuTLS these surf
|
|
|
105
120
|
Client certificates are tested with the OpenSSL and LibreSSL backends - libcurl's Secure Transport backend
|
|
106
121
|
can't load a PEM certificate from a file at all.
|
|
107
122
|
|
|
123
|
+
## Streaming responses
|
|
124
|
+
|
|
125
|
+
`Session#stream` (and `get_stream`, `post_stream`, `put_stream`, `patch_stream`) yields the response as
|
|
126
|
+
soon as its head has arrived, and the block receives the body by reading from it. Nothing is buffered
|
|
127
|
+
meanwhile: every read drives libcurl until some bytes have arrived. `timeout` still bounds the whole
|
|
128
|
+
transfer, so set it to 0 and use `read_timeout` to bound the wait for each next piece:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
stats = sess.get_stream("/events", { "Accept" => "text/event-stream" }, timeout: 0, read_timeout: 30) do |stream|
|
|
132
|
+
raise "Unexpected #{stream.status}" unless stream.ok? && stream.content_type == "text/event-stream"
|
|
133
|
+
stream.each_line do |line|
|
|
134
|
+
puts line.force_encoding(Encoding::UTF_8)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
stats.complete? # => true when the body was received to its end
|
|
138
|
+
stats.timings # the same Hash as Response#timings
|
|
139
|
+
stats.trailers # chunked or HTTP/2 trailers, {} when none
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`read(length, buffer)` and `readpartial(length, buffer)` fill a String you own, so copying a body
|
|
143
|
+
somewhere allocates nothing per chunk. `IO.copy_stream` uses exactly that, into a File or anything
|
|
144
|
+
with `write`:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
sess.get_stream("/large.bin") do |stream|
|
|
148
|
+
raise "Too big" if stream.content_length.to_i > 1024 * 1024 * 1024
|
|
149
|
+
File.open("large.bin", "wb") { |file| IO.copy_stream(stream, file) }
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Leaving the block early (`break`, `return`, an exception) aborts the transfer and closes its connection;
|
|
154
|
+
call `stream.drain!` first to receive and discard the rest and keep the connection. Using the stream after
|
|
155
|
+
its block raises `IOError`. The block is mandatory, and the `Session` is busy while it runs. Under a Fiber
|
|
156
|
+
scheduler use `each` and `each_line` with a block rather than an external `Enumerator`.
|
|
157
|
+
|
|
158
|
+
### Server-sent events
|
|
159
|
+
|
|
160
|
+
`Patron::SSEClient` reads a `text/event-stream` on top of `stream`, and reconnects the way a browser's
|
|
161
|
+
EventSource does: after the retry interval (the server's `retry:` field wins), sending `Last-Event-ID`.
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
client = Patron::SSEClient.new(
|
|
165
|
+
"https://example.com/stream",
|
|
166
|
+
headers: { "Authorization" => "Bearer ..." },
|
|
167
|
+
idle_timeout: 45, # seconds of silence before reconnecting
|
|
168
|
+
retry: 3, # seconds between reconnects until the server sends a retry field
|
|
169
|
+
max_reconnects: nil, # nil: forever
|
|
170
|
+
last_event_id: nil, # resume from a stored id
|
|
171
|
+
session: nil # or a configured Patron::Session: TLS, proxy, auth, cookies, base_url
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
client.each_event do |event|
|
|
175
|
+
event.type # "message" unless the server sent an event: field
|
|
176
|
+
event.data # data: lines joined with "\n", UTF-8
|
|
177
|
+
event.id
|
|
178
|
+
break if event.type == "done"
|
|
179
|
+
end
|
|
180
|
+
client.last_event_id # to persist between process runs
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
A 204 ends `each_event`. Any other status than 200, or another Content-Type, raises
|
|
184
|
+
`Patron::SSEClient::UnexpectedResponse` with the `status`, `headers` and first 4 KB of `body`, since retrying
|
|
185
|
+
a 503 is the application's call. An event that is not valid UTF-8 raises `Patron::SSEClient::InvalidEncoding`
|
|
186
|
+
and ends the stream, where a browser would substitute U+FFFD. `client.close` from another thread or fiber makes
|
|
187
|
+
`each_event` return.
|
|
188
|
+
|
|
189
|
+
The client only issues GETs, like an `EventSource`. For an event stream that answers a POST, which is how the
|
|
190
|
+
LLM completion APIs stream, run the same parser over the body yourself:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
session.post_stream("/v1/chat/completions", body.to_json, headers) do |stream|
|
|
194
|
+
raise "status #{stream.status}" unless stream.status == 200
|
|
195
|
+
Patron::SSEClient.parse(stream) do |event|
|
|
196
|
+
break if event.data == "[DONE]"
|
|
197
|
+
print JSON.parse(event.data).dig("choices", 0, "delta", "content")
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`Patron::SSEClient.parse` takes anything with `readpartial`, so a file or a socket works as well as a
|
|
203
|
+
`ResponseStream`, and returns the last event ID it saw.
|
|
204
|
+
|
|
108
205
|
## Threading
|
|
109
206
|
|
|
110
207
|
A `Patron::Session` performs one request at a time and must not be shared between threads: each `Session`
|
|
@@ -150,7 +247,8 @@ end
|
|
|
150
247
|
```
|
|
151
248
|
|
|
152
249
|
Each fiber needs its own `Session`, just like each thread does. `Async::Task#stop` and
|
|
153
|
-
`Task#with_timeout` interrupt a request in flight the same way `Thread#kill` and `Timeout` do
|
|
250
|
+
`Task#with_timeout` interrupt a request in flight the same way `Thread#kill` and `Timeout` do, and
|
|
251
|
+
`Session#interrupt` and `SSEClient#close` from another fiber reach the waiting one at once.
|
|
154
252
|
|
|
155
253
|
## Ractors
|
|
156
254
|
|