fast_curl 0.3.1 → 0.4.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: 32aca3a9aec734c69bc40aa9b84b519aeddc15defc3ad3a9e8f278f63d9f0ffb
4
+ data.tar.gz: 8037377b3d3e5ab02f4171c3f4ae56e6f42552a6cb5c6d7e2f5fd54921e2cc71
5
5
  SHA512:
6
- metadata.gz: 2629435cd5203198a39697a831f1ff2a3fe71c41798b98fbe40654ad53954b5fbcf010e2e2a9fdb209932f045c50684868ed5c5d66e9c30a5226dfe6ca71910e
7
- data.tar.gz: 576179e43d4203cf2bda1e3cb0cd19d58f4a98ad724a7c5ab8e1eaf8554f9f1294eba99cbe0f399bc64ba41e4ce60cc981be6a2b1409dcc600b0699c38562355
6
+ metadata.gz: 670eefe8b9fa597e947ae0858bda833d35d15914fbd62e3093483cdf8c2c68560c121586c4b01eec75f81d3a4f6d49d04829d5711180178247f051cb510c8cd6
7
+ data.tar.gz: 709792e880465cc09de983246936ce66a843f5b7899e69ac6f5a390cef35b4cdc8baaf1dbda36abe39627cc2fe457e994076329ab746887d000d1a089e649af7
data/README.md CHANGED
@@ -12,9 +12,9 @@ Ultra-fast parallel HTTP client for Ruby. C extension built on libcurl `curl_mul
12
12
 
13
13
  ## Installation
14
14
 
15
- **Requirements**: Ruby >= 3.1, libcurl
15
+ **Requirements**: Ruby >= 2.7, libcurl
16
16
 
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.
17
+ > **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
18
 
19
19
  ```ruby
20
20
  gem 'fast_curl'
@@ -50,16 +50,37 @@ end
50
50
 
51
51
  ### POST with body and headers
52
52
 
53
+ Be explicit about the encoding — `json:` and `form:` set the matching
54
+ `Content-Type` for you:
55
+
53
56
  ```ruby
54
57
  FastCurl.post([
55
58
  {
56
59
  url: "https://api.example.com/users",
57
60
  headers: { "Authorization" => "Bearer token" },
58
- body: { name: "John" }
61
+ json: { name: "John" } # application/json
62
+ },
63
+ {
64
+ url: "https://api.example.com/login",
65
+ form: { user: "john", pass: "x" } # application/x-www-form-urlencoded
66
+ },
67
+ {
68
+ url: "https://api.example.com/blob",
69
+ headers: { "Content-Type" => "application/xml" },
70
+ body: "<user/>" # sent as-is
59
71
  }
60
72
  ])
61
73
  ```
62
74
 
75
+ A raw String `body:` without an explicit `Content-Type` is sent as
76
+ `application/octet-stream`. A Hash `body:` is still encoded as JSON.
77
+
78
+ Query parameters can be passed separately:
79
+
80
+ ```ruby
81
+ FastCurl.get([{ url: "https://api.example.com/search", params: { q: "ruby", page: 2 } }])
82
+ ```
83
+
63
84
  ### First N responses (cancel the rest)
64
85
 
65
86
  ```ruby
@@ -78,13 +99,29 @@ FastCurl.stream_get(urls, connections: 50) do |index, response|
78
99
  end
79
100
  ```
80
101
 
81
- ### Retry functionality (v0.2.0+)
102
+ ### Retries
103
+
104
+ **Only idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS) are retried.**
105
+ Several retryable curl errors — `GOT_NOTHING`, `SEND_ERROR`, `RECV_ERROR`,
106
+ `PARTIAL_FILE` — can occur *after* the server has already accepted and processed
107
+ the request, so replaying a `POST` or `PATCH` may duplicate its side effects.
108
+ If you know the endpoint is safe to replay (e.g. it takes an idempotency key),
109
+ opt in with `retry_non_idempotent: true`.
110
+
111
+ `timeout` applies to a single attempt. Use `total_timeout` to bound the whole
112
+ call, including retries and backoff:
113
+
114
+ ```ruby
115
+ FastCurl.get(urls, timeout: 5, retries: 3, total_timeout: 10_000)
116
+ ```
117
+
118
+ Delays use exponential backoff with full jitter, starting from `retry_delay`.
82
119
 
83
120
  ```ruby
84
121
  # Automatic retry on network errors (timeout, connection issues)
85
122
  results = FastCurl.get([
86
123
  { url: "https://unreliable-api.com/data" }
87
- ], retries: 3, retry_delay: 1000) # 3 retries with 1s delay
124
+ ], retries: 3, retry_delay: 1000) # base delay 1s, doubling with jitter
88
125
 
89
126
  # Retry on specific HTTP status codes
90
127
  results = FastCurl.get([
@@ -109,14 +146,35 @@ end
109
146
 
110
147
  ## Response format
111
148
 
149
+ Every response — successful or not — has the same keys:
150
+
112
151
  ```ruby
113
152
  [index, {
114
- status: 200, # HTTP status code (0 on error)
115
- headers: { "Key" => "Value" },
116
- body: "response body"
153
+ status: 200, # HTTP status code, 0 on error
154
+ headers: { "content-type" => "application/json" },
155
+ body: "response body",
156
+ error: nil, # nil, or :curl_error / :invalid_request /
157
+ # :not_completed / :deadline_exceeded
158
+ error_code: nil, # CURLcode when error == :curl_error
159
+ effective_url: "https://...", # final URL after redirects
160
+ attempts: 1 # attempts made, including the first
117
161
  }]
118
162
  ```
119
163
 
164
+ Check `response[:error]` rather than `response[:status] == 200`; a status of `0`
165
+ always means the request never produced an HTTP response.
166
+
167
+ Header names are normalised to lower case (HTTP/2 sends them that way and
168
+ HTTP/1.1 may not), and lookups are case-insensitive:
169
+
170
+ ```ruby
171
+ response[:headers]["Content-Type"] # => "application/json"
172
+ response[:headers]["content-type"] # => "application/json"
173
+ ```
174
+
175
+ Repeated fields fold into one comma-separated String. `set-cookie` cannot be
176
+ folded and is **always** an Array, even for a single cookie.
177
+
120
178
  ## Available methods
121
179
 
122
180
  | Method | Description |
@@ -137,10 +195,32 @@ end
137
195
  | Option | Default | Description |
138
196
  |---|---|---|
139
197
  | `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 |
198
+ | `timeout` | 30 | Timeout for a single attempt, in seconds (1-300) |
199
+ | `connect_timeout` | 10000 | Connection phase timeout, in milliseconds |
200
+ | `total_timeout` | none | Wall-clock budget for the whole call, in milliseconds |
201
+ | `retries` | 1 | Retry attempts for idempotent methods (0-10) |
202
+ | `retry_delay` | 100 | Base backoff in milliseconds; doubles with jitter |
143
203
  | `retry_codes` | [] | HTTP status codes to retry on |
204
+ | `retry_non_idempotent` | false | Also retry POST and PATCH |
205
+ | `follow_redirects` | true | Follow `Location` headers |
206
+ | `max_redirects` | 5 | Redirect limit (0-100) |
207
+
208
+ DNS results and TLS sessions are cached process-wide, so repeated calls to the
209
+ same host skip resolution and can resume TLS. TCP connections are pooled only
210
+ within a single call — see Known limitations.
211
+
212
+ ## Known limitations
213
+
214
+ - TCP connections are not reused across separate calls; each call builds its own
215
+ `curl_multi` handle. Sharing libcurl's connection cache across concurrent
216
+ multi handles deadlocks or crashes, so only the DNS and TLS session caches are
217
+ shared.
218
+ - The whole response body is buffered in memory (100 MB cap per response);
219
+ `stream_execute` streams *responses*, not bodies.
220
+ - HTTP/2 multiplexing is enabled, but `connections` caps in-flight requests and
221
+ TCP connections with the same number, so multiplexing cannot be exploited
222
+ beyond that limit.
223
+ - No multipart, cookie jar, proxy or auth helpers yet.
144
224
 
145
225
  ## Performance
146
226
 
@@ -11,7 +11,10 @@
11
11
  #define FAST_CURL_HAVE_FIBER_SCHEDULER 1
12
12
  #endif
13
13
  #include <curl/curl.h>
14
+ #include <ctype.h>
14
15
  #include <limits.h>
16
+ #include <pthread.h>
17
+ #include <stdint.h>
15
18
  #include <stdlib.h>
16
19
  #include <string.h>
17
20
  #include <strings.h>
@@ -23,20 +26,24 @@
23
26
  #define FAST_CURL_NORETURN
24
27
  #endif
25
28
 
26
- #define MAX_RESPONSE_SIZE (100 * 1024 * 1024)
27
- #define MAX_REDIRECTS 5
28
- #define MAX_TIMEOUT 300
29
- #define MAX_RETRIES 10
30
- #define MAX_REQUESTS 10000
31
- #define MAX_CONNECTIONS 100
32
- #define MAX_RETRY_DELAY_MS 30000
33
- #define DEFAULT_RETRIES 1
34
- #define DEFAULT_RETRY_DELAY 0
35
- #define INITIAL_BUF_CAP 8192
36
- #define INITIAL_HEADER_CAP 16
37
- #define POLL_TIMEOUT_MS 50
38
- #define FIBER_POLL_TIMEOUT_MS 10
39
- #define HEADER_LINE_BUF_SIZE 512
29
+ #define MAX_RESPONSE_SIZE (100 * 1024 * 1024)
30
+ #define MAX_REDIRECTS 5
31
+ #define MAX_TIMEOUT 300
32
+ #define MAX_RETRIES 10
33
+ #define MAX_REQUESTS 10000
34
+ #define MAX_CONNECTIONS 100
35
+ #define MAX_RETRY_DELAY_MS 30000
36
+ #define DEFAULT_RETRIES 1
37
+ #define DEFAULT_RETRY_DELAY 100
38
+ #define DEFAULT_CONNECT_TIMEOUT_MS 10000L
39
+ #define MAX_CONNECT_TIMEOUT_MS 300000L
40
+ #define MAX_TOTAL_TIMEOUT_MS 3600000L
41
+ #define INITIAL_BUF_CAP 8192
42
+ #define INITIAL_HEADER_CAP 16
43
+ #define POLL_TIMEOUT_MS 50
44
+ #define POLL_SLICE_MS 2000
45
+ #define FIBER_POLL_TIMEOUT_MS 10
46
+ #define HEADER_LINE_BUF_SIZE 512
40
47
 
41
48
  static const CURLcode DEFAULT_RETRYABLE_CURLE[] = {
42
49
  CURLE_COULDNT_CONNECT, CURLE_OPERATION_TIMEDOUT, CURLE_SEND_ERROR, CURLE_RECV_ERROR,
@@ -57,6 +64,14 @@ typedef enum {
57
64
  KEY_RETRIES,
58
65
  KEY_RETRY_DELAY,
59
66
  KEY_RETRY_CODES,
67
+ KEY_CONNECT_TIMEOUT,
68
+ KEY_TOTAL_TIMEOUT,
69
+ KEY_RETRY_NON_IDEMPOTENT,
70
+ KEY_FOLLOW_REDIRECTS,
71
+ KEY_MAX_REDIRECTS,
72
+ KEY_EFFECTIVE_URL,
73
+ KEY_ERROR,
74
+ KEY_ATTEMPTS,
60
75
  KEY_LAST
61
76
  } key_id_t;
62
77
 
@@ -66,10 +81,111 @@ static VALUE fast_syms[KEY_LAST];
66
81
  #define SYM(key) fast_syms[key]
67
82
 
68
83
  static const char *const KEY_NAMES[KEY_LAST] = {
69
- "status", "headers", "body", "error_code", "url", "method",
70
- "timeout", "connections", "count", "retries", "retry_delay", "retry_codes",
84
+ "status",
85
+ "headers",
86
+ "body",
87
+ "error_code",
88
+ "url",
89
+ "method",
90
+ "timeout",
91
+ "connections",
92
+ "count",
93
+ "retries",
94
+ "retry_delay",
95
+ "retry_codes",
96
+ "connect_timeout",
97
+ "total_timeout",
98
+ "retry_non_idempotent",
99
+ "follow_redirects",
100
+ "max_redirects",
101
+ "effective_url",
102
+ "error",
103
+ "attempts",
71
104
  };
72
105
 
106
+ typedef enum {
107
+ ERR_CURL,
108
+ ERR_INVALID_REQUEST,
109
+ ERR_NOT_COMPLETED,
110
+ ERR_DEADLINE,
111
+ ERR_LAST
112
+ } error_kind_t;
113
+
114
+ static VALUE error_syms[ERR_LAST];
115
+
116
+ static const char *const ERROR_KIND_NAMES[ERR_LAST] = {
117
+ "curl_error",
118
+ "invalid_request",
119
+ "not_completed",
120
+ "deadline_exceeded",
121
+ };
122
+
123
+ static long long fast_now_ms(void) {
124
+ struct timespec ts;
125
+ #ifdef CLOCK_MONOTONIC
126
+ clock_gettime(CLOCK_MONOTONIC, &ts);
127
+ #else
128
+ clock_gettime(CLOCK_REALTIME, &ts);
129
+ #endif
130
+ return (long long)ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL;
131
+ }
132
+
133
+ static uint64_t fast_rand_state;
134
+
135
+ /* Small xorshift PRNG; only used to jitter retry delays. */
136
+ static uint32_t fast_rand(void) {
137
+ uint64_t x = fast_rand_state;
138
+ if (x == 0)
139
+ x = (uint64_t)fast_now_ms() | 1ULL;
140
+ x ^= x << 13;
141
+ x ^= x >> 7;
142
+ x ^= x << 17;
143
+ fast_rand_state = x;
144
+ return (uint32_t)(x >> 32);
145
+ }
146
+
147
+ /* Process-wide DNS and TLS session cache, so repeated calls to the same host
148
+ skip resolution and can resume TLS instead of doing a full handshake. */
149
+ static CURLSH *fast_share = NULL;
150
+ static pthread_mutex_t fast_share_locks[CURL_LOCK_DATA_LAST];
151
+
152
+ static void fast_share_lock(CURL *handle, curl_lock_data data, curl_lock_access access,
153
+ void *userptr) {
154
+ (void)handle;
155
+ (void)access;
156
+ (void)userptr;
157
+ if ((int)data > 0 && (int)data < CURL_LOCK_DATA_LAST)
158
+ pthread_mutex_lock(&fast_share_locks[data]);
159
+ }
160
+
161
+ static void fast_share_unlock(CURL *handle, curl_lock_data data, void *userptr) {
162
+ (void)handle;
163
+ (void)userptr;
164
+ if ((int)data > 0 && (int)data < CURL_LOCK_DATA_LAST)
165
+ pthread_mutex_unlock(&fast_share_locks[data]);
166
+ }
167
+
168
+ static void fast_share_init(void) {
169
+ for (int i = 0; i < CURL_LOCK_DATA_LAST; i++)
170
+ pthread_mutex_init(&fast_share_locks[i], NULL);
171
+
172
+ fast_share = curl_share_init();
173
+ if (!fast_share)
174
+ return;
175
+
176
+ curl_share_setopt(fast_share, CURLSHOPT_LOCKFUNC, fast_share_lock);
177
+ curl_share_setopt(fast_share, CURLSHOPT_UNLOCKFUNC, fast_share_unlock);
178
+ curl_share_setopt(fast_share, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS);
179
+ curl_share_setopt(fast_share, CURLSHOPT_SHARE, CURL_LOCK_DATA_SSL_SESSION);
180
+
181
+ /* CURL_LOCK_DATA_CONNECT is deliberately NOT shared. Sharing the connection
182
+ cache between multi handles running concurrently in different threads
183
+ deadlocks against CURLMOPT_MAX_TOTAL_CONNECTIONS, and removing that limit
184
+ turns the deadlock into a segfault inside curl_multi_perform (reproduced
185
+ on libcurl 8.5.0 with two threads x 25 requests). Cross-call TCP reuse
186
+ needs a persistent multi handle per thread, not a shared cache. */
187
+ }
188
+
73
189
  typedef struct {
74
190
  char *data;
75
191
  size_t len;
@@ -212,8 +328,19 @@ typedef struct {
212
328
  int active;
213
329
  CURLcode curl_result;
214
330
  long http_status;
331
+ int idempotent;
332
+ int attempts;
333
+ const char *setup_error;
334
+ int setup_error_fatal;
215
335
  } request_ctx_t;
216
336
 
337
+ static VALUE fast_validation_error = Qnil;
338
+ #define SET_SETUP_ERROR(ctx, msg, fatal) \
339
+ do { \
340
+ (ctx)->setup_error = (msg); \
341
+ (ctx)->setup_error_fatal = (fatal); \
342
+ } while (0)
343
+
217
344
  static inline void request_ctx_init(request_ctx_t *ctx, int index) {
218
345
  ctx->easy = NULL;
219
346
  ctx->index = index;
@@ -224,6 +351,10 @@ static inline void request_ctx_init(request_ctx_t *ctx, int index) {
224
351
  ctx->active = 0;
225
352
  ctx->curl_result = CURLE_OK;
226
353
  ctx->http_status = 0;
354
+ ctx->idempotent = 1;
355
+ ctx->attempts = 0;
356
+ ctx->setup_error = NULL;
357
+ ctx->setup_error_fatal = 0;
227
358
  }
228
359
 
229
360
  static void request_ctx_free(request_ctx_t *ctx) {
@@ -267,6 +398,8 @@ static int request_ctx_reset_for_retry(request_ctx_t *ctx) {
267
398
  ctx->active = 0;
268
399
  ctx->curl_result = CURLE_OK;
269
400
  ctx->http_status = 0;
401
+ ctx->setup_error = NULL;
402
+ ctx->setup_error_fatal = 0;
270
403
  return 1;
271
404
  }
272
405
 
@@ -291,8 +424,18 @@ typedef struct {
291
424
  long retry_delay_ms;
292
425
  int *retry_http_codes;
293
426
  int retry_http_count;
427
+ int retry_non_idempotent;
294
428
  } retry_config_t;
295
429
 
430
+ typedef struct {
431
+ long timeout_sec;
432
+ long connect_timeout_ms;
433
+ long total_timeout_ms;
434
+ long follow_redirects;
435
+ long max_redirects;
436
+ long long deadline_ms;
437
+ } request_options_t;
438
+
296
439
  static int contains_header_injection(const char *str, long len) {
297
440
  for (long i = 0; i < len; i++) {
298
441
  if (str[i] == '\r' || str[i] == '\n' || str[i] == '\0')
@@ -392,24 +535,37 @@ static void run_via_fiber_worker(VALUE scheduler, void *(*func)(void *), void *a
392
535
  }
393
536
  #endif
394
537
 
395
- static void headers_hash_store(VALUE headers_hash, VALUE key, VALUE val) {
538
+ static VALUE fast_cHeaders = Qnil;
539
+ static VALUE new_headers_hash(void) {
540
+ if (!NIL_P(fast_cHeaders))
541
+ return rb_obj_alloc(fast_cHeaders);
542
+ return rb_hash_new();
543
+ }
544
+
545
+ static void headers_hash_store(VALUE headers_hash, VALUE key, VALUE val, int always_array) {
396
546
  VALUE existing = rb_hash_aref(headers_hash, key);
397
547
 
398
548
  if (NIL_P(existing)) {
399
- rb_hash_aset(headers_hash, key, val);
400
- } else if (RB_TYPE_P(existing, T_ARRAY)) {
549
+ rb_hash_aset(headers_hash, key, always_array ? rb_ary_new_from_args(1, val) : val);
550
+ return;
551
+ }
552
+
553
+ if (RB_TYPE_P(existing, T_ARRAY)) {
401
554
  rb_ary_push(existing, val);
402
- } else {
403
- VALUE values = rb_ary_new_from_args(2, existing, val);
404
- rb_hash_aset(headers_hash, key, values);
555
+ return;
405
556
  }
557
+
558
+ VALUE joined = rb_str_dup(existing);
559
+ rb_str_cat_cstr(joined, ", ");
560
+ rb_str_append(joined, val);
561
+ rb_hash_aset(headers_hash, key, joined);
406
562
  }
407
563
 
408
564
  static VALUE build_response(request_ctx_t *ctx) {
409
565
  long status = 0;
410
566
  curl_easy_getinfo(ctx->easy, CURLINFO_RESPONSE_CODE, &status);
411
567
 
412
- VALUE headers_hash = rb_hash_new();
568
+ VALUE headers_hash = new_headers_hash();
413
569
  for (int i = 0; i < ctx->headers.count; i++) {
414
570
  const char *hdr = ctx->headers.entries[i].str;
415
571
  size_t hdr_len = ctx->headers.entries[i].len;
@@ -418,6 +574,12 @@ static VALUE build_response(request_ctx_t *ctx) {
418
574
  continue;
419
575
 
420
576
  VALUE key = rb_str_new(hdr, colon - hdr);
577
+ char *kp = RSTRING_PTR(key);
578
+ long klen = RSTRING_LEN(key);
579
+ for (long k = 0; k < klen; k++)
580
+ kp[k] = (char)tolower((unsigned char)kp[k]);
581
+ int always_array = (klen == 10 && memcmp(kp, "set-cookie", 10) == 0);
582
+
421
583
  const char *vs = colon + 1;
422
584
  const char *ve = hdr + hdr_len;
423
585
 
@@ -427,7 +589,7 @@ static VALUE build_response(request_ctx_t *ctx) {
427
589
  ve--;
428
590
 
429
591
  VALUE val = rb_str_new(vs, ve - vs);
430
- headers_hash_store(headers_hash, key, val);
592
+ headers_hash_store(headers_hash, key, val, always_array);
431
593
  }
432
594
 
433
595
  VALUE body_str =
@@ -437,19 +599,33 @@ static VALUE build_response(request_ctx_t *ctx) {
437
599
  rb_hash_aset(result, SYM(KEY_STATUS), LONG2NUM(status));
438
600
  rb_hash_aset(result, SYM(KEY_HEADERS), headers_hash);
439
601
  rb_hash_aset(result, SYM(KEY_BODY), body_str);
602
+ rb_hash_aset(result, SYM(KEY_ERROR), Qnil);
603
+ rb_hash_aset(result, SYM(KEY_ERROR_CODE), Qnil);
604
+ rb_hash_aset(result, SYM(KEY_ATTEMPTS), INT2NUM(ctx->attempts));
605
+
606
+ char *effective = NULL;
607
+ if (curl_easy_getinfo(ctx->easy, CURLINFO_EFFECTIVE_URL, &effective) == CURLE_OK && effective)
608
+ rb_hash_aset(result, SYM(KEY_EFFECTIVE_URL), rb_str_new_cstr(effective));
609
+ else
610
+ rb_hash_aset(result, SYM(KEY_EFFECTIVE_URL), Qnil);
611
+
440
612
  return result;
441
613
  }
442
614
 
443
- static VALUE build_error_response(const char *message) {
615
+ static VALUE build_error_response(const char *message, error_kind_t kind, int attempts) {
444
616
  VALUE r = rb_hash_new();
445
617
  rb_hash_aset(r, SYM(KEY_STATUS), INT2NUM(0));
446
618
  rb_hash_aset(r, SYM(KEY_HEADERS), Qnil);
447
619
  rb_hash_aset(r, SYM(KEY_BODY), rb_str_new_cstr(message));
620
+ rb_hash_aset(r, SYM(KEY_ERROR), error_syms[kind]);
621
+ rb_hash_aset(r, SYM(KEY_ERROR_CODE), Qnil);
622
+ rb_hash_aset(r, SYM(KEY_EFFECTIVE_URL), Qnil);
623
+ rb_hash_aset(r, SYM(KEY_ATTEMPTS), INT2NUM(attempts));
448
624
  return r;
449
625
  }
450
626
 
451
- static VALUE build_error_response_with_code(const char *message, int error_code) {
452
- VALUE r = build_error_response(message);
627
+ static VALUE build_error_response_with_code(const char *message, int error_code, int attempts) {
628
+ VALUE r = build_error_response(message, ERR_CURL, attempts);
453
629
  rb_hash_aset(r, SYM(KEY_ERROR_CODE), INT2NUM(error_code));
454
630
  return r;
455
631
  }
@@ -474,20 +650,33 @@ static int is_valid_url(const char *url) {
474
650
  return _r; \
475
651
  } while (0)
476
652
 
477
- static CURLcode setup_basic_options(CURL *easy, const char *url_str, long timeout_sec,
653
+ static CURLcode setup_basic_options(CURL *easy, const char *url_str, const request_options_t *opts,
478
654
  request_ctx_t *ctx) {
655
+ long timeout_ms = opts->timeout_sec * 1000L;
656
+
657
+ if (opts->deadline_ms > 0) {
658
+ long long remaining = opts->deadline_ms - fast_now_ms();
659
+ if (remaining < 1)
660
+ remaining = 1;
661
+ if (remaining < (long long)timeout_ms)
662
+ timeout_ms = (long)remaining;
663
+ }
664
+
479
665
  CURL_SETOPT_CHECK(easy, CURLOPT_URL, url_str);
480
666
  CURL_SETOPT_CHECK(easy, CURLOPT_WRITEFUNCTION, write_callback);
481
667
  CURL_SETOPT_CHECK(easy, CURLOPT_WRITEDATA, &ctx->body);
482
668
  CURL_SETOPT_CHECK(easy, CURLOPT_HEADERFUNCTION, header_callback);
483
669
  CURL_SETOPT_CHECK(easy, CURLOPT_HEADERDATA, &ctx->headers);
484
- CURL_SETOPT_CHECK(easy, CURLOPT_TIMEOUT, timeout_sec);
670
+ CURL_SETOPT_CHECK(easy, CURLOPT_TIMEOUT_MS, timeout_ms);
671
+ CURL_SETOPT_CHECK(easy, CURLOPT_CONNECTTIMEOUT_MS, opts->connect_timeout_ms);
485
672
  CURL_SETOPT_CHECK(easy, CURLOPT_NOSIGNAL, 1L);
486
- CURL_SETOPT_CHECK(easy, CURLOPT_FOLLOWLOCATION, 1L);
487
- CURL_SETOPT_CHECK(easy, CURLOPT_MAXREDIRS, MAX_REDIRECTS);
673
+ CURL_SETOPT_CHECK(easy, CURLOPT_FOLLOWLOCATION, opts->follow_redirects);
674
+ CURL_SETOPT_CHECK(easy, CURLOPT_MAXREDIRS, opts->max_redirects);
488
675
  CURL_SETOPT_CHECK(easy, CURLOPT_ACCEPT_ENCODING, "");
489
676
  CURL_SETOPT_CHECK(easy, CURLOPT_PRIVATE, (char *)ctx);
490
677
  CURL_SETOPT_CHECK(easy, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS);
678
+ if (fast_share)
679
+ CURL_SETOPT_CHECK(easy, CURLOPT_SHARE, fast_share);
491
680
  return CURLE_OK;
492
681
  }
493
682
 
@@ -518,12 +707,13 @@ typedef struct {
518
707
  int post;
519
708
  int nobody;
520
709
  int allows_body;
710
+ int idempotent;
521
711
  } http_method_t;
522
712
 
523
713
  static const http_method_t HTTP_METHODS[] = {
524
- {"GET", NULL, 0, 0, 0}, {"POST", NULL, 1, 0, 1}, {"PUT", "PUT", 0, 0, 1},
525
- {"DELETE", "DELETE", 0, 0, 1}, {"PATCH", "PATCH", 0, 0, 1}, {"HEAD", NULL, 0, 1, 0},
526
- {"OPTIONS", "OPTIONS", 0, 0, 0},
714
+ {"GET", NULL, 0, 0, 0, 1}, {"POST", NULL, 1, 0, 1, 0}, {"PUT", "PUT", 0, 0, 1, 1},
715
+ {"DELETE", "DELETE", 0, 0, 1, 1}, {"PATCH", "PATCH", 0, 0, 1, 0}, {"HEAD", NULL, 0, 1, 0, 1},
716
+ {"OPTIONS", "OPTIONS", 0, 0, 0, 1},
527
717
  };
528
718
 
529
719
  static const http_method_t *find_http_method(const char *name) {
@@ -544,29 +734,52 @@ static CURLcode apply_http_method(CURL *easy, const http_method_t *method) {
544
734
  return CURLE_OK;
545
735
  }
546
736
 
547
- static CURLcode setup_method_and_body(CURL *easy, VALUE method_value, VALUE body) {
548
- const char *name = NIL_P(method_value) ? "GET" : StringValueCStr(method_value);
549
- const http_method_t *method = find_http_method(name);
737
+ static int setup_method_and_body(request_ctx_t *ctx, VALUE method_value, VALUE body) {
738
+ const http_method_t *method;
550
739
  int has_body = !NIL_P(body);
740
+ char name[32];
741
+
742
+ if (NIL_P(method_value)) {
743
+ memcpy(name, "GET", 4);
744
+ } else {
745
+ if (!RB_TYPE_P(method_value, T_STRING)) {
746
+ SET_SETUP_ERROR(ctx, "Unsupported HTTP method", 1);
747
+ return 0;
748
+ }
749
+ long len = RSTRING_LEN(method_value);
750
+ if (len <= 0 || len >= (long)sizeof(name)) {
751
+ SET_SETUP_ERROR(ctx, "Unsupported HTTP method", 1);
752
+ return 0;
753
+ }
754
+ memcpy(name, RSTRING_PTR(method_value), (size_t)len);
755
+ name[len] = '\0';
756
+ }
757
+
758
+ method = find_http_method(name);
759
+ if (!method) {
760
+ SET_SETUP_ERROR(ctx, "Unsupported HTTP method", 1);
761
+ return 0;
762
+ }
763
+ if (has_body && !method->allows_body) {
764
+ SET_SETUP_ERROR(ctx, "This HTTP method must not include a body", 1);
765
+ return 0;
766
+ }
551
767
 
552
- if (!method)
553
- rb_raise(rb_eArgError, "Unsupported HTTP method: %s", name);
554
- if (has_body && !method->allows_body)
555
- rb_raise(rb_eArgError, "%s requests must not include a body", method->name);
768
+ ctx->idempotent = method->idempotent;
556
769
 
557
- CURLcode res = apply_http_method(easy, method);
558
- if (res != CURLE_OK)
559
- return res;
770
+ if (apply_http_method(ctx->easy, method) != CURLE_OK) {
771
+ SET_SETUP_ERROR(ctx, "Failed to configure HTTP method", 0);
772
+ return 0;
773
+ }
560
774
 
561
- if (has_body) {
562
- res = set_body(easy, body);
563
- if (res != CURLE_OK)
564
- return res;
775
+ if (has_body && set_body(ctx->easy, body) != CURLE_OK) {
776
+ SET_SETUP_ERROR(ctx, "Failed to configure request body", 0);
777
+ return 0;
565
778
  }
566
779
 
567
780
  RB_GC_GUARD(method_value);
568
781
  RB_GC_GUARD(body);
569
- return CURLE_OK;
782
+ return 1;
570
783
  }
571
784
 
572
785
  static void append_request_header(request_ctx_t *ctx, const char *buf) {
@@ -608,23 +821,42 @@ static void append_formatted_header(request_ctx_t *ctx, const char *key, long ke
608
821
  free(line);
609
822
  }
610
823
 
824
+ static VALUE header_part_to_str(VALUE v) {
825
+ if (RB_TYPE_P(v, T_STRING))
826
+ return v;
827
+ if (SYMBOL_P(v))
828
+ return rb_sym2str(v);
829
+ return rb_String(v);
830
+ }
831
+
611
832
  static int header_iter_cb(VALUE key, VALUE val, VALUE arg) {
612
833
  request_ctx_t *ctx = (request_ctx_t *)arg;
613
- VALUE key_str = rb_String(key);
614
- VALUE val_str = NIL_P(val) ? Qnil : rb_String(val);
615
- const char *k = RSTRING_PTR(key_str);
616
- long klen = RSTRING_LEN(key_str);
834
+ VALUE key_str, val_str;
835
+ const char *k;
836
+ long klen;
617
837
  const char *v = NULL;
618
838
  long vlen = 0;
619
839
 
620
- if (!is_valid_header_name(k, klen) || contains_header_injection(k, klen))
621
- rb_raise(rb_eArgError, "Invalid HTTP header name");
840
+ if (ctx->setup_error)
841
+ return ST_STOP;
842
+
843
+ key_str = header_part_to_str(key);
844
+ val_str = NIL_P(val) ? Qnil : header_part_to_str(val);
845
+ k = RSTRING_PTR(key_str);
846
+ klen = RSTRING_LEN(key_str);
847
+
848
+ if (!is_valid_header_name(k, klen) || contains_header_injection(k, klen)) {
849
+ SET_SETUP_ERROR(ctx, "Invalid HTTP header name", 1);
850
+ return ST_STOP;
851
+ }
622
852
 
623
853
  if (!NIL_P(val_str) && RSTRING_LEN(val_str) > 0) {
624
854
  v = RSTRING_PTR(val_str);
625
855
  vlen = RSTRING_LEN(val_str);
626
- if (contains_header_injection(v, vlen))
627
- rb_raise(rb_eArgError, "Invalid HTTP header value");
856
+ if (contains_header_injection(v, vlen)) {
857
+ SET_SETUP_ERROR(ctx, "Invalid HTTP header value", 1);
858
+ return ST_STOP;
859
+ }
628
860
  }
629
861
 
630
862
  append_formatted_header(ctx, k, klen, v, vlen);
@@ -634,40 +866,58 @@ static int header_iter_cb(VALUE key, VALUE val, VALUE arg) {
634
866
  return ST_CONTINUE;
635
867
  }
636
868
 
637
- static int setup_easy_handle(request_ctx_t *ctx, VALUE request, long timeout_sec) {
638
- Check_Type(request, T_HASH);
869
+ static int setup_easy_handle(request_ctx_t *ctx, VALUE request, const request_options_t *opts) {
870
+ if (!RB_TYPE_P(request, T_HASH)) {
871
+ SET_SETUP_ERROR(ctx, "Request must be a Hash", 1);
872
+ return 0;
873
+ }
639
874
 
640
875
  VALUE url = hash_aref_key(request, KEY_URL);
641
876
  VALUE method = hash_aref_key(request, KEY_METHOD);
642
877
  VALUE headers = hash_aref_key(request, KEY_HEADERS);
643
878
  VALUE body = hash_aref_key(request, KEY_BODY);
644
879
 
645
- if (NIL_P(url))
880
+ if (NIL_P(url)) {
881
+ SET_SETUP_ERROR(ctx, "Missing :url", 0);
646
882
  return 0;
883
+ }
884
+ if (!RB_TYPE_P(url, T_STRING) || memchr(RSTRING_PTR(url), '\0', RSTRING_LEN(url))) {
885
+ SET_SETUP_ERROR(ctx, "Invalid URL", 1);
886
+ return 0;
887
+ }
647
888
 
648
889
  const char *url_str = StringValueCStr(url);
649
- if (!is_valid_url(url_str))
650
- rb_raise(rb_eArgError, "Invalid URL: %s", url_str);
890
+ if (!is_valid_url(url_str)) {
891
+ SET_SETUP_ERROR(ctx, "Invalid URL (expected http:// or https://, max 2048 bytes)", 1);
892
+ return 0;
893
+ }
651
894
 
652
- CURLcode res = setup_basic_options(ctx->easy, url_str, timeout_sec, ctx);
653
- if (res != CURLE_OK)
895
+ if (setup_basic_options(ctx->easy, url_str, opts, ctx) != CURLE_OK) {
896
+ SET_SETUP_ERROR(ctx, "Failed to configure request", 0);
654
897
  return 0;
898
+ }
655
899
 
656
- res = setup_security_options(ctx->easy);
657
- if (res != CURLE_OK)
900
+ if (setup_security_options(ctx->easy) != CURLE_OK) {
901
+ SET_SETUP_ERROR(ctx, "Failed to configure TLS options", 0);
658
902
  return 0;
903
+ }
659
904
 
660
- res = setup_method_and_body(ctx->easy, method, body);
661
- if (res != CURLE_OK)
905
+ if (!setup_method_and_body(ctx, method, body))
662
906
  return 0;
663
907
 
664
908
  if (!NIL_P(headers)) {
665
- Check_Type(headers, T_HASH);
909
+ if (!RB_TYPE_P(headers, T_HASH)) {
910
+ SET_SETUP_ERROR(ctx, ":headers must be a Hash", 1);
911
+ return 0;
912
+ }
666
913
  rb_hash_foreach(headers, header_iter_cb, (VALUE)ctx);
914
+ if (ctx->setup_error)
915
+ return 0;
667
916
  if (ctx->req_headers) {
668
- res = curl_easy_setopt(ctx->easy, CURLOPT_HTTPHEADER, ctx->req_headers);
669
- if (res != CURLE_OK)
917
+ if (curl_easy_setopt(ctx->easy, CURLOPT_HTTPHEADER, ctx->req_headers) != CURLE_OK) {
918
+ SET_SETUP_ERROR(ctx, "Failed to set request headers", 0);
670
919
  return 0;
920
+ }
671
921
  }
672
922
  }
673
923
 
@@ -679,14 +929,25 @@ static int setup_easy_handle(request_ctx_t *ctx, VALUE request, long timeout_sec
679
929
  return 1;
680
930
  }
681
931
 
932
+ /* Poll until something actually completes, or the slice budget expires.
933
+ Previously this returned after every single 50ms poll, which under a fiber
934
+ scheduler meant a fresh OS thread twenty times a second. Cancellation still
935
+ breaks out immediately via curl_multi_wakeup. */
682
936
  static void *poll_without_gvl(void *arg) {
683
937
  multi_session_t *s = (multi_session_t *)arg;
684
- if (s->cancelled)
685
- return NULL;
938
+ long long started = fast_now_ms();
939
+ int before = s->still_running;
686
940
 
687
- int numfds = 0;
688
- curl_multi_poll(s->multi, NULL, 0, POLL_TIMEOUT_MS, &numfds);
689
- curl_multi_perform(s->multi, &s->still_running);
941
+ while (!s->cancelled) {
942
+ int numfds = 0;
943
+ curl_multi_poll(s->multi, NULL, 0, POLL_TIMEOUT_MS, &numfds);
944
+ curl_multi_perform(s->multi, &s->still_running);
945
+
946
+ if (s->still_running == 0 || s->still_running < before)
947
+ break;
948
+ if (fast_now_ms() - started >= POLL_SLICE_MS)
949
+ break;
950
+ }
690
951
  return NULL;
691
952
  }
692
953
 
@@ -709,9 +970,11 @@ static VALUE build_result_pair(int index, VALUE response) {
709
970
  return rb_ary_new_from_args(2, INT2NUM(index), response);
710
971
  }
711
972
 
712
- static int record_immediate_error(completion_ctx_t *cctx, int index, const char *message) {
973
+ static int record_immediate_error(completion_ctx_t *cctx, int index, const char *message,
974
+ int attempts) {
713
975
  if (cctx->stream || cctx->target > 0) {
714
- VALUE pair = build_result_pair(index, build_error_response(message));
976
+ VALUE pair =
977
+ build_result_pair(index, build_error_response(message, ERR_INVALID_REQUEST, attempts));
715
978
 
716
979
  if (cctx->stream)
717
980
  rb_yield(pair);
@@ -751,10 +1014,11 @@ static int process_completed(multi_session_t *session, completion_ctx_t *cctx) {
751
1014
  curl_easy_getinfo(ctx->easy, CURLINFO_RESPONSE_CODE, &ctx->http_status);
752
1015
 
753
1016
  if (cctx->stream || cctx->target > 0) {
754
- VALUE response = (msg->data.result == CURLE_OK)
755
- ? build_response(ctx)
756
- : build_error_response_with_code(
757
- curl_easy_strerror(msg->data.result), (int)msg->data.result);
1017
+ VALUE response =
1018
+ (msg->data.result == CURLE_OK)
1019
+ ? build_response(ctx)
1020
+ : build_error_response_with_code(curl_easy_strerror(msg->data.result),
1021
+ (int)msg->data.result, ctx->attempts);
758
1022
  VALUE pair = build_result_pair(ctx->index, response);
759
1023
 
760
1024
  if (cctx->stream)
@@ -782,30 +1046,34 @@ static int next_pending_index(multi_session_t *session) {
782
1046
  }
783
1047
 
784
1048
  static int activate_request(multi_session_t *session, VALUE requests, int idx, int *invalid,
785
- long timeout_sec) {
1049
+ const request_options_t *opts) {
786
1050
  request_ctx_t *ctx = &session->requests[idx];
787
1051
 
788
1052
  if (invalid[idx] || ctx->done)
789
1053
  return 0;
790
1054
 
791
- if (!request_ctx_prepare_easy(ctx))
1055
+ if (!request_ctx_prepare_easy(ctx)) {
1056
+ SET_SETUP_ERROR(ctx, "Failed to allocate a curl handle", 0);
792
1057
  return 0;
1058
+ }
793
1059
 
794
- if (!setup_easy_handle(ctx, rb_ary_entry(requests, idx), timeout_sec))
1060
+ if (!setup_easy_handle(ctx, rb_ary_entry(requests, idx), opts))
795
1061
  return 0;
796
1062
 
797
- CURLMcode mc = curl_multi_add_handle(session->multi, ctx->easy);
798
- if (mc != CURLM_OK)
1063
+ if (curl_multi_add_handle(session->multi, ctx->easy) != CURLM_OK) {
1064
+ SET_SETUP_ERROR(ctx, "Failed to schedule the request", 0);
799
1065
  return 0;
1066
+ }
800
1067
 
1068
+ ctx->attempts++;
801
1069
  ctx->active = 1;
802
1070
  ctx->done = 0;
803
1071
  session->active_count++;
804
1072
  return 1;
805
1073
  }
806
1074
 
807
- static int fill_slots(multi_session_t *session, VALUE requests, int *invalid, long timeout_sec,
808
- completion_ctx_t *cctx) {
1075
+ static int fill_slots(multi_session_t *session, VALUE requests, int *invalid,
1076
+ const request_options_t *opts, completion_ctx_t *cctx) {
809
1077
  while (session->active_count < session->max_connections) {
810
1078
  int idx = next_pending_index(session);
811
1079
  if (idx < 0)
@@ -813,11 +1081,14 @@ static int fill_slots(multi_session_t *session, VALUE requests, int *invalid, lo
813
1081
 
814
1082
  request_ctx_t *ctx = &session->requests[idx];
815
1083
 
816
- if (!activate_request(session, requests, idx, invalid, timeout_sec)) {
1084
+ if (!activate_request(session, requests, idx, invalid, opts)) {
817
1085
  invalid[idx] = 1;
818
1086
  ctx->done = 1;
819
1087
  ctx->active = 0;
820
- if (record_immediate_error(cctx, idx, "Invalid request configuration"))
1088
+ if (record_immediate_error(cctx, idx,
1089
+ ctx->setup_error ? ctx->setup_error
1090
+ : "Invalid request configuration",
1091
+ ctx->attempts))
821
1092
  return 1;
822
1093
  }
823
1094
  }
@@ -838,13 +1109,14 @@ static void prepare_pending(multi_session_t *session, int *indices, int count) {
838
1109
  }
839
1110
 
840
1111
  static void run_multi_loop(multi_session_t *session, completion_ctx_t *cctx, VALUE requests,
841
- int *invalid, long timeout_sec, int *indices, int indices_count) {
1112
+ int *invalid, const request_options_t *opts, int *indices,
1113
+ int indices_count) {
842
1114
  #ifdef FAST_CURL_HAVE_FIBER_SCHEDULER
843
1115
  VALUE scheduler = current_fiber_scheduler();
844
1116
  #endif
845
1117
  prepare_pending(session, indices, indices_count);
846
1118
 
847
- if (fill_slots(session, requests, invalid, timeout_sec, cctx))
1119
+ if (fill_slots(session, requests, invalid, opts, cctx))
848
1120
  return;
849
1121
 
850
1122
  curl_multi_perform(session->multi, &session->still_running);
@@ -852,7 +1124,10 @@ static void run_multi_loop(multi_session_t *session, completion_ctx_t *cctx, VAL
852
1124
  return;
853
1125
 
854
1126
  while (!session->cancelled && (session->active_count > 0 || pending_remaining(session))) {
855
- if (fill_slots(session, requests, invalid, timeout_sec, cctx))
1127
+ if (opts->deadline_ms > 0 && fast_now_ms() >= opts->deadline_ms)
1128
+ break;
1129
+
1130
+ if (fill_slots(session, requests, invalid, opts, cctx))
856
1131
  return;
857
1132
 
858
1133
  if (session->active_count == 0)
@@ -880,6 +1155,9 @@ static int is_default_retryable_curle(CURLcode code) {
880
1155
  }
881
1156
 
882
1157
  static int should_retry(request_ctx_t *ctx, retry_config_t *rc) {
1158
+ if (!ctx->idempotent && !rc->retry_non_idempotent)
1159
+ return 0;
1160
+
883
1161
  if (ctx->curl_result != CURLE_OK)
884
1162
  return is_default_retryable_curle(ctx->curl_result);
885
1163
 
@@ -930,6 +1208,26 @@ static void retry_config_init(retry_config_t *retry_cfg) {
930
1208
  retry_cfg->retry_delay_ms = DEFAULT_RETRY_DELAY;
931
1209
  retry_cfg->retry_http_codes = NULL;
932
1210
  retry_cfg->retry_http_count = 0;
1211
+ retry_cfg->retry_non_idempotent = 0;
1212
+ }
1213
+
1214
+ static long retry_backoff_ms(const retry_config_t *rc, int attempt) {
1215
+ long delay = rc->retry_delay_ms;
1216
+ long half;
1217
+
1218
+ if (delay <= 0)
1219
+ return 0;
1220
+
1221
+ for (int i = 0; i < attempt && delay < MAX_RETRY_DELAY_MS; i++)
1222
+ delay *= 2;
1223
+ if (delay > MAX_RETRY_DELAY_MS)
1224
+ delay = MAX_RETRY_DELAY_MS;
1225
+
1226
+ half = delay / 2;
1227
+ if (half > 0)
1228
+ delay = half + (long)(fast_rand() % (uint32_t)(half + 1));
1229
+
1230
+ return delay;
933
1231
  }
934
1232
 
935
1233
  static long parse_long_option(VALUE options, key_id_t key, const char *name, long min, long max,
@@ -986,8 +1284,16 @@ static void parse_retry_codes(VALUE options, retry_config_t *retry_cfg) {
986
1284
  retry_cfg->retry_http_codes[i] = NUM2INT(rb_ary_entry(codes, i));
987
1285
  }
988
1286
 
989
- static void parse_options(VALUE options, long *timeout, int *max_conn, retry_config_t *retry_cfg) {
990
- *timeout = 30;
1287
+ static void parse_options(VALUE options, request_options_t *opts, int *max_conn,
1288
+ retry_config_t *retry_cfg) {
1289
+ VALUE flag;
1290
+
1291
+ opts->timeout_sec = 30;
1292
+ opts->connect_timeout_ms = DEFAULT_CONNECT_TIMEOUT_MS;
1293
+ opts->total_timeout_ms = 0;
1294
+ opts->follow_redirects = 1;
1295
+ opts->max_redirects = MAX_REDIRECTS;
1296
+ opts->deadline_ms = 0;
991
1297
  *max_conn = 20;
992
1298
  retry_config_init(retry_cfg);
993
1299
 
@@ -995,7 +1301,24 @@ static void parse_options(VALUE options, long *timeout, int *max_conn, retry_con
995
1301
  return;
996
1302
 
997
1303
  Check_Type(options, T_HASH);
998
- *timeout = parse_long_option(options, KEY_TIMEOUT, "timeout", 1, MAX_TIMEOUT, *timeout, NULL);
1304
+ opts->timeout_sec =
1305
+ parse_long_option(options, KEY_TIMEOUT, "timeout", 1, MAX_TIMEOUT, opts->timeout_sec, NULL);
1306
+ opts->connect_timeout_ms =
1307
+ parse_long_option(options, KEY_CONNECT_TIMEOUT, "connect_timeout", 1,
1308
+ MAX_CONNECT_TIMEOUT_MS, opts->connect_timeout_ms, NULL);
1309
+ opts->total_timeout_ms = parse_long_option(options, KEY_TOTAL_TIMEOUT, "total_timeout", 1,
1310
+ MAX_TOTAL_TIMEOUT_MS, opts->total_timeout_ms, NULL);
1311
+ opts->max_redirects = parse_long_option(options, KEY_MAX_REDIRECTS, "max_redirects", 0, 100,
1312
+ opts->max_redirects, NULL);
1313
+
1314
+ flag = hash_aref_key(options, KEY_FOLLOW_REDIRECTS);
1315
+ if (!NIL_P(flag))
1316
+ opts->follow_redirects = RTEST(flag) ? 1L : 0L;
1317
+
1318
+ flag = hash_aref_key(options, KEY_RETRY_NON_IDEMPOTENT);
1319
+ if (!NIL_P(flag))
1320
+ retry_cfg->retry_non_idempotent = RTEST(flag) ? 1 : 0;
1321
+
999
1322
  *max_conn = parse_int_option(options, KEY_CONNECTIONS, "connections", 1, MAX_CONNECTIONS,
1000
1323
  *max_conn, NULL);
1001
1324
  retry_cfg->max_retries = parse_int_option(options, KEY_RETRIES, "retries", 0, MAX_RETRIES,
@@ -1074,7 +1397,7 @@ typedef struct {
1074
1397
  multi_session_t *session;
1075
1398
  int *invalid;
1076
1399
  retry_config_t *retry_cfg;
1077
- long timeout_sec;
1400
+ request_options_t *opts;
1078
1401
  } execute_args_t;
1079
1402
 
1080
1403
  static VALUE internal_execute_body(VALUE arg) {
@@ -1083,7 +1406,8 @@ static VALUE internal_execute_body(VALUE arg) {
1083
1406
  multi_session_t *session = ea->session;
1084
1407
  int *invalid = ea->invalid;
1085
1408
  retry_config_t *retry_cfg = ea->retry_cfg;
1086
- long timeout_sec = ea->timeout_sec;
1409
+ request_options_t *opts = ea->opts;
1410
+ int deadline_hit = 0;
1087
1411
  int count = session->count;
1088
1412
  int target = ea->target;
1089
1413
  int stream = ea->stream;
@@ -1102,10 +1426,18 @@ static VALUE internal_execute_body(VALUE arg) {
1102
1426
  rb_ary_store(cctx.results, i, Qnil);
1103
1427
  }
1104
1428
 
1105
- run_multi_loop(session, &cctx, requests, invalid, timeout_sec, NULL, count);
1429
+ if (opts->total_timeout_ms > 0)
1430
+ opts->deadline_ms = fast_now_ms() + opts->total_timeout_ms;
1431
+
1432
+ run_multi_loop(session, &cctx, requests, invalid, opts, NULL, count);
1106
1433
 
1107
1434
  if (!stream && target <= 0 && retry_cfg->max_retries > 0) {
1108
1435
  for (int attempt = 0; attempt < retry_cfg->max_retries; attempt++) {
1436
+ if (opts->deadline_ms > 0 && fast_now_ms() >= opts->deadline_ms) {
1437
+ deadline_hit = 1;
1438
+ break;
1439
+ }
1440
+
1109
1441
  int *retry_indices = malloc(sizeof(int) * (size_t)count);
1110
1442
  if (!retry_indices)
1111
1443
  rb_raise(rb_eNoMemError, "failed to allocate retry index array");
@@ -1123,7 +1455,18 @@ static VALUE internal_execute_body(VALUE arg) {
1123
1455
  break;
1124
1456
  }
1125
1457
 
1126
- retry_delay_sleep(retry_cfg->retry_delay_ms);
1458
+ long backoff = retry_backoff_ms(retry_cfg, attempt);
1459
+ if (opts->deadline_ms > 0) {
1460
+ long long left = opts->deadline_ms - fast_now_ms();
1461
+ if (left <= 0) {
1462
+ deadline_hit = 1;
1463
+ free(retry_indices);
1464
+ break;
1465
+ }
1466
+ if (backoff > (long)left)
1467
+ backoff = (long)left;
1468
+ }
1469
+ retry_delay_sleep(backoff);
1127
1470
 
1128
1471
  int runnable_count = 0;
1129
1472
  for (int r = 0; r < retry_count; r++) {
@@ -1133,6 +1476,7 @@ static VALUE internal_execute_body(VALUE arg) {
1133
1476
  if (!request_ctx_reset_for_retry(rc)) {
1134
1477
  invalid[idx] = 1;
1135
1478
  rc->done = 1;
1479
+ rc->setup_error = "Failed to allocate a curl handle for the retry";
1136
1480
  continue;
1137
1481
  }
1138
1482
 
@@ -1141,7 +1485,7 @@ static VALUE internal_execute_body(VALUE arg) {
1141
1485
 
1142
1486
  if (runnable_count > 0) {
1143
1487
  cctx.completed = 0;
1144
- run_multi_loop(session, &cctx, requests, invalid, timeout_sec, retry_indices,
1488
+ run_multi_loop(session, &cctx, requests, invalid, opts, retry_indices,
1145
1489
  runnable_count);
1146
1490
  }
1147
1491
 
@@ -1155,14 +1499,20 @@ static VALUE internal_execute_body(VALUE arg) {
1155
1499
  VALUE response;
1156
1500
 
1157
1501
  if (invalid[i]) {
1158
- response = build_error_response("Invalid request configuration");
1502
+ response = build_error_response(rc->setup_error ? rc->setup_error
1503
+ : "Invalid request configuration",
1504
+ ERR_INVALID_REQUEST, rc->attempts);
1159
1505
  } else if (!rc->done) {
1160
- response = build_error_response("Request was not completed");
1506
+ response =
1507
+ deadline_hit || (opts->deadline_ms > 0 && fast_now_ms() >= opts->deadline_ms)
1508
+ ? build_error_response("Total timeout exceeded", ERR_DEADLINE, rc->attempts)
1509
+ : build_error_response("Request was not completed", ERR_NOT_COMPLETED,
1510
+ rc->attempts);
1161
1511
  } else if (rc->curl_result == CURLE_OK) {
1162
1512
  response = build_response(rc);
1163
1513
  } else {
1164
1514
  response = build_error_response_with_code(curl_easy_strerror(rc->curl_result),
1165
- (int)rc->curl_result);
1515
+ (int)rc->curl_result, rc->attempts);
1166
1516
  }
1167
1517
 
1168
1518
  rb_ary_store(cctx.results, i, build_result_pair(i, response));
@@ -1262,10 +1612,31 @@ static VALUE internal_execute(VALUE requests, VALUE options, int target, int str
1262
1612
  if (target > 0 && target > count)
1263
1613
  target = count;
1264
1614
 
1265
- long timeout_sec;
1615
+ request_options_t opts;
1266
1616
  int max_conn;
1267
1617
  retry_config_t retry_cfg;
1268
- parse_options(options, &timeout_sec, &max_conn, &retry_cfg);
1618
+ parse_options(options, &opts, &max_conn, &retry_cfg);
1619
+
1620
+ for (int i = 0; i < count; i++) {
1621
+ request_ctx_t probe;
1622
+ request_ctx_init(&probe, i);
1623
+ if (!request_ctx_prepare_easy(&probe)) {
1624
+ request_ctx_free(&probe);
1625
+ continue;
1626
+ }
1627
+ setup_easy_handle(&probe, rb_ary_entry(requests, i), &opts);
1628
+ int fatal = probe.setup_error_fatal;
1629
+ const char *message = probe.setup_error;
1630
+ char buffer[128];
1631
+ if (fatal && message) {
1632
+ snprintf(buffer, sizeof(buffer), "%s (request %d)", message, i);
1633
+ }
1634
+ request_ctx_free(&probe);
1635
+ if (fatal) {
1636
+ VALUE klass = NIL_P(fast_validation_error) ? rb_eArgError : fast_validation_error;
1637
+ rb_raise(klass, "%s", buffer);
1638
+ }
1639
+ }
1269
1640
 
1270
1641
  if (stream || target > 0) {
1271
1642
  if (retry_cfg.retries_explicit && retry_cfg.max_retries > 0 && stream)
@@ -1279,7 +1650,7 @@ static VALUE internal_execute(VALUE requests, VALUE options, int target, int str
1279
1650
 
1280
1651
  multi_session_t session;
1281
1652
  int *invalid = NULL;
1282
- multi_session_init(&session, curl_multi_init(), count, max_conn, timeout_sec);
1653
+ multi_session_init(&session, curl_multi_init(), count, max_conn, opts.timeout_sec);
1283
1654
 
1284
1655
  cleanup_ctx_t cleanup = {.session = &session, .invalid = NULL, .retry_cfg = &retry_cfg};
1285
1656
 
@@ -1304,7 +1675,7 @@ static VALUE internal_execute(VALUE requests, VALUE options, int target, int str
1304
1675
  .session = &session,
1305
1676
  .invalid = invalid,
1306
1677
  .retry_cfg = &retry_cfg,
1307
- .timeout_sec = timeout_sec,
1678
+ .opts = &opts,
1308
1679
  };
1309
1680
 
1310
1681
  #ifdef FAST_CURL_HAVE_FIBER_SCHEDULER
@@ -1326,7 +1697,6 @@ static VALUE internal_execute(VALUE requests, VALUE options, int target, int str
1326
1697
  (VALUE)&cleanup);
1327
1698
  }
1328
1699
  #endif
1329
-
1330
1700
  return rb_ensure(internal_execute_body, (VALUE)&ea, cleanup_session, (VALUE)&cleanup);
1331
1701
  }
1332
1702
 
@@ -1366,6 +1736,7 @@ static VALUE rb_fast_curl_stream_execute(int argc, VALUE *argv, VALUE self) {
1366
1736
 
1367
1737
  void Init_fast_curl(void) {
1368
1738
  curl_global_init(CURL_GLOBAL_ALL);
1739
+ fast_share_init();
1369
1740
 
1370
1741
  for (int i = 0; i < KEY_LAST; i++) {
1371
1742
  fast_ids[i] = rb_intern(KEY_NAMES[i]);
@@ -1373,8 +1744,18 @@ void Init_fast_curl(void) {
1373
1744
  rb_gc_register_address(&fast_syms[i]);
1374
1745
  }
1375
1746
 
1747
+ for (int i = 0; i < ERR_LAST; i++) {
1748
+ error_syms[i] = ID2SYM(rb_intern(ERROR_KIND_NAMES[i]));
1749
+ rb_gc_register_address(&error_syms[i]);
1750
+ }
1751
+
1376
1752
  VALUE mFastCurl = rb_define_module("FastCurl");
1377
1753
 
1754
+ if (rb_const_defined_at(mFastCurl, rb_intern("Headers"))) {
1755
+ fast_cHeaders = rb_const_get_at(mFastCurl, rb_intern("Headers"));
1756
+ rb_gc_register_address(&fast_cHeaders);
1757
+ }
1758
+
1378
1759
  rb_define_module_function(mFastCurl, "execute", rb_fast_curl_execute, -1);
1379
1760
  rb_define_module_function(mFastCurl, "first_execute", rb_fast_curl_first_execute, -1);
1380
1761
  rb_define_module_function(mFastCurl, "stream_execute", rb_fast_curl_stream_execute, -1);
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FastCurl
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/fast_curl.rb CHANGED
@@ -1,13 +1,75 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'uri'
4
5
  require_relative "fast_curl/version"
5
- require_relative "fast_curl/fast_curl"
6
6
 
7
7
  module FastCurl
8
8
  class Error < StandardError; end
9
9
  class TimeoutError < Error; end
10
10
 
11
+ class Headers < Hash
12
+ def [](key)
13
+ super(normalize(key))
14
+ end
15
+
16
+ def []=(key, value)
17
+ super(normalize(key), value)
18
+ end
19
+ alias store []=
20
+
21
+ def fetch(key, *args, &block)
22
+ super(normalize(key), *args, &block)
23
+ end
24
+
25
+ def key?(key)
26
+ super(normalize(key))
27
+ end
28
+ alias has_key? key?
29
+ alias include? key?
30
+ alias member? key?
31
+
32
+ def delete(key, &block)
33
+ super(normalize(key), &block)
34
+ end
35
+
36
+ def dig(key, *rest)
37
+ super(normalize(key), *rest)
38
+ end
39
+
40
+ def values_at(*keys)
41
+ super(*keys.map { |key| normalize(key) })
42
+ end
43
+
44
+ def assoc(key)
45
+ super(normalize(key))
46
+ end
47
+
48
+ def merge(*others, &block)
49
+ others.inject(dup) { |acc, other| acc.merge!(other, &block) }
50
+ end
51
+
52
+ def merge!(*others)
53
+ others.each do |other|
54
+ other.each do |key, value|
55
+ self[key] = block_given? && key?(key) ? yield(normalize(key), self[key], value) : value
56
+ end
57
+ end
58
+ self
59
+ end
60
+ alias update merge!
61
+
62
+ private
63
+
64
+ def normalize(key)
65
+ key.is_a?(String) || key.is_a?(Symbol) ? key.to_s.downcase : key
66
+ end
67
+ end
68
+ end
69
+
70
+ require_relative "fast_curl/fast_curl"
71
+
72
+ module FastCurl
11
73
  DEFAULT_OPTIONS = {
12
74
  connections: 20,
13
75
  timeout: 30
@@ -16,6 +78,10 @@ module FastCurl
16
78
  METHODS = %i[get post put delete patch].freeze
17
79
  BODY_METHODS = %i[post put patch].freeze
18
80
 
81
+ JSON_TYPE = "application/json"
82
+ FORM_TYPE = "application/x-www-form-urlencoded"
83
+ BINARY_TYPE = "application/octet-stream"
84
+
19
85
  class << self
20
86
  METHODS.each do |method|
21
87
  define_method(method) do |requests, **options|
@@ -35,14 +101,56 @@ module FastCurl
35
101
 
36
102
  def build_requests(requests, method)
37
103
  requests.map do |req|
38
- r = { url: req[:url], method: method.to_s.upcase }
39
- r[:headers] = req[:headers] if req[:headers]
40
- if req[:body] && BODY_METHODS.include?(method)
41
- r[:body] = req[:body].is_a?(Hash) ? req[:body].to_json : req[:body].to_s
42
- r[:headers] = (r[:headers] || {}).merge("Content-Type" => "application/json") if req[:body].is_a?(Hash)
104
+ r = { url: build_url(req), method: method.to_s.upcase }
105
+ headers = req[:headers] ? req[:headers].dup : nil
106
+
107
+ if BODY_METHODS.include?(method)
108
+ body, content_type = build_body(req)
109
+ if body
110
+ r[:body] = body
111
+ if content_type && !content_type?(headers)
112
+ headers ||= {}
113
+ headers["Content-Type"] = content_type
114
+ end
115
+ end
43
116
  end
117
+
118
+ r[:headers] = headers if headers && !headers.empty?
44
119
  r
45
120
  end
46
121
  end
122
+
123
+ def build_url(req)
124
+ url = req[:url]
125
+ params = req[:params]
126
+ return url if params.nil? || params.empty? || !url.is_a?(String)
127
+
128
+ separator = url.include?("?") ? "&" : "?"
129
+ "#{url}#{separator}#{URI.encode_www_form(params)}"
130
+ end
131
+
132
+ # Returns [body_string, default_content_type].
133
+ # json: {...} or "..." -> application/json
134
+ # form: {...} -> application/x-www-form-urlencoded
135
+ # body: "..." -> application/octet-stream (set your own type)
136
+ # body: {...} -> application/json (kept for backwards compatibility)
137
+ def build_body(req)
138
+ if req.key?(:json)
139
+ value = req[:json]
140
+ [value.is_a?(String) ? value : value.to_json, JSON_TYPE]
141
+ elsif req.key?(:form)
142
+ value = req[:form]
143
+ [value.is_a?(String) ? value : URI.encode_www_form(value), FORM_TYPE]
144
+ elsif req.key?(:body) && !req[:body].nil?
145
+ value = req[:body]
146
+ value.is_a?(Hash) ? [value.to_json, JSON_TYPE] : [value.to_s, BINARY_TYPE]
147
+ end
148
+ end
149
+
150
+ def content_type?(headers)
151
+ return false unless headers
152
+
153
+ headers.any? { |k, _| k.to_s.casecmp("content-type").zero? }
154
+ end
47
155
  end
48
156
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast_curl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - roman-haidarov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-24 00:00:00.000000000 Z
11
+ date: 2026-07-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -140,7 +140,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
140
140
  - !ruby/object:Gem::Version
141
141
  version: '0'
142
142
  requirements: []
143
- rubygems_version: 3.3.27
143
+ rubygems_version: 3.4.22
144
144
  signing_key:
145
145
  specification_version: 4
146
146
  summary: Ultra-fast parallel HTTP client as Ruby C extension on libcurl multi