fast_curl 0.3.1 → 0.5.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: ebf05021f9b72fb51019dd2a89a0ca91ef6e0f7c060f1447f137f245a5aac52e
4
- data.tar.gz: e0f3d17b12a4ce18ce28cb832d594d7716719edc9513213348228b06ce12104d
3
+ metadata.gz: 576cbc25d05c1a0857f8f762c3ed3744ad84c65457669c63bd5da62d904e72a0
4
+ data.tar.gz: b13fcb38e809a48ac3c85deb14a5ea56dbedb37803cad3cb3c2218a7bf88d483
5
5
  SHA512:
6
- metadata.gz: 2629435cd5203198a39697a831f1ff2a3fe71c41798b98fbe40654ad53954b5fbcf010e2e2a9fdb209932f045c50684868ed5c5d66e9c30a5226dfe6ca71910e
7
- data.tar.gz: 576179e43d4203cf2bda1e3cb0cd19d58f4a98ad724a7c5ab8e1eaf8554f9f1294eba99cbe0f399bc64ba41e4ce60cc981be6a2b1409dcc600b0699c38562355
6
+ metadata.gz: c3fed870f48939b5d016c9951d1c2503941496706af5fa0d157c964269a925190187e1b3378dcd8a43dea5c7a7d4c56bbf8edf0923f325fd74323b0166d97361
7
+ data.tar.gz: 6f13f0839354222ed5ff8ede5acf39137904226ddad7f860b8b08e3f2b3dcff6fda6b65d21ed8324673f4fda1cc24e8cf51b13a32a6681740bc012507e36072c
data/README.md CHANGED
@@ -8,13 +8,14 @@ Ultra-fast parallel HTTP client for Ruby. C extension built on libcurl `curl_mul
8
8
  - **GVL release** — `rb_thread_call_without_gvl` during I/O, other Ruby threads keep running
9
9
  - **Fiber scheduler compatible** — works inside `Async do ... end` without blocking other fibers
10
10
  - **Three modes**: execute (all), first_execute (first N), stream_execute (yield as ready)
11
+ - **Lazy Enumerable sources** — bounded request preparation for large or infinite streams
11
12
  - **Zero dependencies** — only libcurl (available everywhere)
12
13
 
13
14
  ## Installation
14
15
 
15
- **Requirements**: Ruby >= 3.1, libcurl
16
+ **Requirements**: Ruby >= 2.7, libcurl
16
17
 
17
- > **Why Ruby 3.1?** The C extension uses `rb_fiber_scheduler_current`, `rb_fiber_scheduler_block` and `rb_fiber_scheduler_unblock` to properly yield control to the Fiber Scheduler during I/O. These APIs are stable starting from Ruby 3.1. Without them, there is no correct way for a C extension to cooperate with the scheduler — earlier approaches (`rb_thread_schedule`) hold the GVL and block other fibers.
18
+ > **Fiber Scheduler support requires Ruby >= 3.1.** The C extension uses `rb_fiber_scheduler_current`, `rb_fiber_scheduler_block` and `rb_fiber_scheduler_unblock` to yield control to the Fiber Scheduler during I/O; these APIs are stable from Ruby 3.1. On 2.7 and 3.0 the extension builds and runs correctly, but that code is compiled out — so a request made inside a scheduler blocks the whole thread and **no sibling fiber runs until it finishes**. Other OS threads are unaffected, since the GVL is still released. If you use `async`, use Ruby >= 3.1.
18
19
 
19
20
  ```ruby
20
21
  gem 'fast_curl'
@@ -50,16 +51,65 @@ end
50
51
 
51
52
  ### POST with body and headers
52
53
 
54
+ Be explicit about the encoding — `json:` and `form:` set the matching
55
+ `Content-Type` for you:
56
+
53
57
  ```ruby
54
58
  FastCurl.post([
55
59
  {
56
60
  url: "https://api.example.com/users",
57
61
  headers: { "Authorization" => "Bearer token" },
58
- body: { name: "John" }
62
+ json: { name: "John" } # application/json
63
+ },
64
+ {
65
+ url: "https://api.example.com/login",
66
+ form: { user: "john", pass: "x" } # application/x-www-form-urlencoded
67
+ },
68
+ {
69
+ url: "https://api.example.com/blob",
70
+ headers: { "Content-Type" => "application/xml" },
71
+ body: "<user/>" # sent as-is
59
72
  }
60
73
  ])
61
74
  ```
62
75
 
76
+ A raw String `body:` without an explicit `Content-Type` is sent as
77
+ `application/octet-stream`. A Hash `body:` is still encoded as JSON.
78
+
79
+ Query parameters can be passed separately:
80
+
81
+ ```ruby
82
+ FastCurl.get([{ url: "https://api.example.com/search", params: { q: "ruby", page: 2 } }])
83
+ ```
84
+
85
+ ### Lazy / bounded Enumerable sources
86
+
87
+ `Array` keeps the existing fast path. Any other object responding to `#each` is
88
+ consumed lazily, so large request sets do not need to be materialized first:
89
+
90
+ ```ruby
91
+ requests = Enumerator.new do |y|
92
+ 1_000_000.times do |i|
93
+ y << { url: "https://api.example.com/items/#{i}" }
94
+ end
95
+ end
96
+
97
+ FastCurl.stream_get(requests, connections: 20, buffer: 20) do |index, response|
98
+ puts "#{index}: #{response[:status]}"
99
+ end
100
+ ```
101
+
102
+ For lazy sources, at most `connections + buffer` requests are retained by
103
+ `fast_curl`. The source itself may have produced one additional item before
104
+ backpressure is applied, so a generator can observe a maximum look-ahead of
105
+ `connections + buffer + 1`. `buffer` defaults to `connections`.
106
+
107
+ `FastCurl.get` still returns all results in input order, so its result array is
108
+ necessarily O(N). Use `stream_get` when the whole pipeline must stay bounded.
109
+ Source exceptions and stream callback exceptions unwind the native multi loop
110
+ and release active curl handles; an `ensure` in the source is also unwound on
111
+ early completed `first_*` calls.
112
+
63
113
  ### First N responses (cancel the rest)
64
114
 
65
115
  ```ruby
@@ -70,6 +120,19 @@ result = FastCurl.first_get([
70
120
  ], count: 1)
71
121
  ```
72
122
 
123
+ `accept:` can keep the race running until a response satisfies a predicate:
124
+
125
+ ```ruby
126
+ result = FastCurl.first_get(
127
+ mirrors,
128
+ connections: 3,
129
+ accept: ->(response) { response[:status].between?(200, 299) }
130
+ )
131
+ ```
132
+
133
+ The predicate receives the response Hash. Rejected responses do not count
134
+ toward `count`.
135
+
73
136
  ### Stream responses as they arrive
74
137
 
75
138
  ```ruby
@@ -78,13 +141,29 @@ FastCurl.stream_get(urls, connections: 50) do |index, response|
78
141
  end
79
142
  ```
80
143
 
81
- ### Retry functionality (v0.2.0+)
144
+ ### Retries
145
+
146
+ **Only idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS) are retried.**
147
+ Several retryable curl errors — `GOT_NOTHING`, `SEND_ERROR`, `RECV_ERROR`,
148
+ `PARTIAL_FILE` — can occur *after* the server has already accepted and processed
149
+ the request, so replaying a `POST` or `PATCH` may duplicate its side effects.
150
+ If you know the endpoint is safe to replay (e.g. it takes an idempotency key),
151
+ opt in with `retry_non_idempotent: true`.
152
+
153
+ `timeout` applies to a single attempt. Use `total_timeout` to bound the whole
154
+ call, including retries and backoff:
155
+
156
+ ```ruby
157
+ FastCurl.get(urls, timeout: 5, retries: 3, total_timeout: 10_000)
158
+ ```
159
+
160
+ Delays use exponential backoff with full jitter, starting from `retry_delay`.
82
161
 
83
162
  ```ruby
84
163
  # Automatic retry on network errors (timeout, connection issues)
85
164
  results = FastCurl.get([
86
165
  { url: "https://unreliable-api.com/data" }
87
- ], retries: 3, retry_delay: 1000) # 3 retries with 1s delay
166
+ ], retries: 3, retry_delay: 1000) # base delay 1s, doubling with jitter
88
167
 
89
168
  # Retry on specific HTTP status codes
90
169
  results = FastCurl.get([
@@ -109,14 +188,35 @@ end
109
188
 
110
189
  ## Response format
111
190
 
191
+ Every response — successful or not — has the same keys:
192
+
112
193
  ```ruby
113
194
  [index, {
114
- status: 200, # HTTP status code (0 on error)
115
- headers: { "Key" => "Value" },
116
- body: "response body"
195
+ status: 200, # HTTP status code, 0 on error
196
+ headers: { "content-type" => "application/json" },
197
+ body: "response body",
198
+ error: nil, # nil, or :curl_error / :invalid_request /
199
+ # :not_completed / :deadline_exceeded
200
+ error_code: nil, # CURLcode when error == :curl_error
201
+ effective_url: "https://...", # final URL after redirects
202
+ attempts: 1 # attempts made, including the first
117
203
  }]
118
204
  ```
119
205
 
206
+ Check `response[:error]` rather than `response[:status] == 200`; a status of `0`
207
+ always means the request never produced an HTTP response.
208
+
209
+ Header names are normalised to lower case (HTTP/2 sends them that way and
210
+ HTTP/1.1 may not), and lookups are case-insensitive:
211
+
212
+ ```ruby
213
+ response[:headers]["Content-Type"] # => "application/json"
214
+ response[:headers]["content-type"] # => "application/json"
215
+ ```
216
+
217
+ Repeated fields fold into one comma-separated String. `set-cookie` cannot be
218
+ folded and is **always** an Array, even for a single cookie.
219
+
120
220
  ## Available methods
121
221
 
122
222
  | Method | Description |
@@ -137,10 +237,34 @@ end
137
237
  | Option | Default | Description |
138
238
  |---|---|---|
139
239
  | `connections` | 20 | Max parallel connections |
140
- | `timeout` | 30 | Per-request timeout in seconds |
141
- | `retries` | 1 | Number of retry attempts (0-10) |
142
- | `retry_delay` | 0 | Delay between retries in milliseconds |
240
+ | `buffer` | `connections` | Lazy-source prefetch window; ignored for already-materialized Arrays |
241
+ | `timeout` | 30 | Timeout for a single attempt, in seconds (1-300) |
242
+ | `connect_timeout` | 10000 | Connection phase timeout, in milliseconds |
243
+ | `total_timeout` | none | Wall-clock budget for the whole call, in milliseconds |
244
+ | `retries` | 1 | Retry attempts for idempotent methods (0-10) |
245
+ | `retry_delay` | 100 | Base backoff in milliseconds; doubles with jitter |
143
246
  | `retry_codes` | [] | HTTP status codes to retry on |
247
+ | `retry_non_idempotent` | false | Also retry POST and PATCH |
248
+ | `follow_redirects` | true | Follow `Location` headers |
249
+ | `max_redirects` | 5 | Redirect limit (0-100) |
250
+ | `accept` | none | `first_*` predicate receiving the response Hash |
251
+
252
+ DNS results and TLS sessions are cached process-wide, so repeated calls to the
253
+ same host skip resolution and can resume TLS. TCP connections are pooled only
254
+ within a single call — see Known limitations.
255
+
256
+ ## Known limitations
257
+
258
+ - TCP connections are not reused across separate calls; each call builds its own
259
+ `curl_multi` handle. Sharing libcurl's connection cache across concurrent
260
+ multi handles deadlocks or crashes, so only the DNS and TLS session caches are
261
+ shared.
262
+ - The whole response body is buffered in memory (100 MB cap per response);
263
+ `stream_execute` streams *responses*, not bodies.
264
+ - HTTP/2 multiplexing is enabled, but `connections` caps in-flight requests and
265
+ TCP connections with the same number, so multiplexing cannot be exploited
266
+ beyond that limit.
267
+ - No multipart, cookie jar, proxy or auth helpers yet.
144
268
 
145
269
  ## Performance
146
270