wreq-rb 0.5.1 → 0.6.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: 6d63638f43eea672e9acfc2139578cd1882cef041e485b175601e2ae0fe4eafd
4
- data.tar.gz: faed66610fcc28504892313a597252ce86ac83740f7955f9bb7533e1caf093f5
3
+ metadata.gz: 1bca79b5e35cf19fc381b13d659f78f7219c7acca0f8f725b1bf16e9fb596ddb
4
+ data.tar.gz: 88febad79edce1aa64074bdbf2141aa16bcf7a6c71659d5b40d71a55ee98366f
5
5
  SHA512:
6
- metadata.gz: e96400bbbfec7f685310d0c35baba63596b01696e53389ff7bcb9b8c74dbec1f2d08e779e162db6aea069d3b5c75b4b2d005b34eb549f5d82e3e8752275d0de4
7
- data.tar.gz: f2ffa8a382c4cd62980db0ab4320ffe14f1d1aa35667e5041488231aa262badb7a0272009266d9f74db3db6506a3284c36ecf06d2611742e0ea46a4cd3cb8bbd
6
+ metadata.gz: a425fce629bd306ea843bfc713e0f2a5f1e1cea95abbed768bac61f7f0772c41a46c2248657eb28eea275ebcb7e3825294d45fa2d0f9207e115f1fb28eb28a62
7
+ data.tar.gz: 994ba15b8de88df27f9d20d1d74505eec4f19bc9731a8ab221dd145b04145d5626d7ace35f7652aa8429346749a28a64e3e38b2b0d42180c5298414f97b0e422
data/Cargo.lock CHANGED
@@ -4056,7 +4056,7 @@ dependencies = [
4056
4056
 
4057
4057
  [[package]]
4058
4058
  name = "wreq_rb"
4059
- version = "0.5.1"
4059
+ version = "0.6.0"
4060
4060
  dependencies = [
4061
4061
  "bytes",
4062
4062
  "http",
data/README.md CHANGED
@@ -114,6 +114,34 @@ All methods are available on both `Wreq` (module-level) and `Wreq::Client` (inst
114
114
  | `head(url, **opts)` | HEAD request |
115
115
  | `options(url, **opts)` | OPTIONS request |
116
116
 
117
+ ### Batch Requests
118
+
119
+ `Wreq::Client#request_batch` runs many requests concurrently inside a **single**
120
+ GVL release, multiplexed over the client's connection pool. This avoids one Ruby
121
+ thread per request.
122
+
123
+ ```ruby
124
+ client = Wreq::Client.new(redirect: false, pool_max_idle_per_host: 32)
125
+
126
+ responses = client.request_batch(urls, concurrency: 128)
127
+ ```
128
+
129
+ Each element of the array is either a URL string or a hash:
130
+
131
+ ```ruby
132
+ client.request_batch([
133
+ "https://example.com/a", # GET
134
+ { method: :put, url: "https://example.com/b", body: "hi" }
135
+ ], concurrency: 32)
136
+ ```
137
+
138
+ - Results are returned **in input order**.
139
+ - Each element is a `Wreq::Response` **or** a `Wreq::Error` — a single failed
140
+ request never discards the rest of the batch. Errors are returned, not raised.
141
+ - `concurrency` caps the number of in-flight requests and defaults to `16`.
142
+ - `cancel` and Ruby thread interrupts abort the whole batch, raising
143
+ `Wreq::Error` with `"request interrupted"`.
144
+
117
145
  ### Cancelling Requests
118
146
 
119
147
  Call `cancel` on a client to interrupt all in-flight requests immediately:
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "wreq_rb"
3
- version = "0.5.1"
3
+ version = "0.6.0"
4
4
  edition = "2021"
5
5
  publish = false
6
6
 
@@ -2,6 +2,7 @@ use std::ffi::c_void;
2
2
  use std::panic::{self, AssertUnwindSafe};
3
3
  use std::ptr;
4
4
  use std::any::Any;
5
+ use std::sync::Arc;
5
6
  use std::time::Duration;
6
7
 
7
8
  use magnus::{
@@ -9,13 +10,15 @@ use magnus::{
9
10
  try_convert::TryConvert, Value,
10
11
  };
11
12
  use tokio::runtime::Runtime;
13
+ use tokio::sync::Semaphore;
14
+ use tokio::task::JoinSet;
12
15
  use tokio_util::sync::CancellationToken;
13
16
  use std::net::IpAddr;
14
17
  use wreq::header::{HeaderMap, HeaderName, HeaderValue, OrigHeaderMap};
15
18
  use wreq::tls::TlsVersion;
16
19
  use wreq_util::{Emulation as BrowserEmulation, Platform as EmulationPlatform, Profile as BrowserProfile};
17
20
 
18
- use crate::error::{generic_error, to_magnus_error};
21
+ use crate::error::{generic_error, to_magnus_error, wreq_error};
19
22
  use crate::response::Response;
20
23
 
21
24
  // --------------------------------------------------------------------------
@@ -137,6 +140,62 @@ async fn execute_request(req: wreq::RequestBuilder) -> Result<ResponseData, wreq
137
140
  Ok(ResponseData { status, headers, body, url, version, content_length, transfer_size })
138
141
  }
139
142
 
143
+ // --------------------------------------------------------------------------
144
+ // Batch execution
145
+ // --------------------------------------------------------------------------
146
+
147
+ const DEFAULT_BATCH_CONCURRENCY: usize = 16;
148
+
149
+ /// Per-item batch result as pure Rust types (no Ruby objects). Errors are kept
150
+ /// as messages so one failure never discards the rest of the batch.
151
+ enum BatchItem {
152
+ Ok(ResponseData),
153
+ Err(String),
154
+ }
155
+
156
+ /// Outcome of a whole batch performed outside the GVL.
157
+ enum BatchOutcome {
158
+ Done(Vec<BatchItem>),
159
+ Interrupted,
160
+ }
161
+
162
+ /// Run every request concurrently with at most `concurrency` in flight,
163
+ /// returning results in input order.
164
+ async fn execute_batch(reqs: Vec<wreq::RequestBuilder>, concurrency: usize) -> Vec<BatchItem> {
165
+ let permits = Arc::new(Semaphore::new(concurrency));
166
+ let mut set: JoinSet<(usize, BatchItem)> = JoinSet::new();
167
+
168
+ for (idx, req) in reqs.into_iter().enumerate() {
169
+ let permits = Arc::clone(&permits);
170
+ set.spawn(async move {
171
+ let _permit = match permits.acquire_owned().await {
172
+ Ok(p) => p,
173
+ Err(_) => return (idx, BatchItem::Err("batch semaphore closed".to_owned())),
174
+ };
175
+ let item = match execute_request(req).await {
176
+ Ok(data) => BatchItem::Ok(data),
177
+ Err(e) => BatchItem::Err(e.to_string()),
178
+ };
179
+ (idx, item)
180
+ });
181
+ }
182
+
183
+ let mut slots: Vec<Option<BatchItem>> = Vec::new();
184
+ slots.resize_with(set.len(), || None);
185
+ while let Some(joined) = set.join_next().await {
186
+ // A JoinError means the task panicked or was aborted; its slot is left
187
+ // empty and filled with a generic error below.
188
+ if let Ok((idx, item)) = joined {
189
+ slots[idx] = Some(item);
190
+ }
191
+ }
192
+
193
+ slots
194
+ .into_iter()
195
+ .map(|slot| slot.unwrap_or_else(|| BatchItem::Err("request task failed".to_owned())))
196
+ .collect()
197
+ }
198
+
140
199
  // --------------------------------------------------------------------------
141
200
  // Emulation helpers
142
201
  // --------------------------------------------------------------------------
@@ -439,6 +498,120 @@ impl Client {
439
498
  };
440
499
  Ok(Response::new(data.status, data.headers, data.body, data.url, data.version, data.content_length, data.transfer_size))
441
500
  }
501
+
502
+ /// Wreq::Client#request_batch(specs) or #request_batch(specs, options)
503
+ fn request_batch(&self, args: &[Value]) -> Result<RArray, magnus::Error> {
504
+ if args.is_empty() {
505
+ return Err(generic_error("an array of requests is required"));
506
+ }
507
+ let specs = RArray::try_convert(args[0])?;
508
+
509
+ let opts: Option<RHash> = if args.len() > 1 {
510
+ Some(RHash::try_convert(args[1])?)
511
+ } else {
512
+ None
513
+ };
514
+
515
+ let concurrency = match opts.as_ref() {
516
+ Some(o) => hash_get_usize(o, "concurrency")?.unwrap_or(DEFAULT_BATCH_CONCURRENCY),
517
+ None => DEFAULT_BATCH_CONCURRENCY,
518
+ };
519
+ if concurrency == 0 {
520
+ return Err(generic_error("concurrency must be >= 1"));
521
+ }
522
+
523
+ // All Ruby -> Rust conversion happens here, while we still hold the GVL.
524
+ let mut reqs: Vec<wreq::RequestBuilder> = Vec::with_capacity(specs.len());
525
+ for spec in specs.into_iter() {
526
+ reqs.push(self.build_request(spec, opts.as_ref())?);
527
+ }
528
+
529
+ let ruby = unsafe { Ruby::get_unchecked() };
530
+ if reqs.is_empty() {
531
+ return Ok(ruby.ary_new());
532
+ }
533
+
534
+ let client_token = self.cancel_token.lock().unwrap_or_else(|e| e.into_inner()).clone();
535
+
536
+ // One GVL release covering the whole batch.
537
+ let outcome: BatchOutcome = unsafe {
538
+ without_gvl(|thread_token| {
539
+ runtime().block_on(async {
540
+ tokio::select! {
541
+ biased;
542
+ _ = thread_token.cancelled() => BatchOutcome::Interrupted,
543
+ _ = client_token.cancelled() => BatchOutcome::Interrupted,
544
+ items = execute_batch(reqs, concurrency) => BatchOutcome::Done(items),
545
+ }
546
+ })
547
+ })
548
+ };
549
+
550
+ let items = match outcome {
551
+ BatchOutcome::Done(items) => items,
552
+ BatchOutcome::Interrupted => return Err(generic_error("request interrupted")),
553
+ };
554
+
555
+ // Back under the GVL: Ruby objects may be created again.
556
+ let results = ruby.ary_new_capa(items.len());
557
+ for item in items {
558
+ match item {
559
+ BatchItem::Ok(d) => {
560
+ let resp = Response::new(
561
+ d.status, d.headers, d.body, d.url, d.version, d.content_length,
562
+ d.transfer_size,
563
+ );
564
+ results.push(ruby.obj_wrap(resp))?;
565
+ }
566
+ BatchItem::Err(msg) => {
567
+ let err: Value = wreq_error().funcall("new", (msg,))?;
568
+ results.push(err)?;
569
+ }
570
+ }
571
+ }
572
+ Ok(results)
573
+ }
574
+
575
+ /// Convert a single batch spec into a RequestBuilder. Accepted forms:
576
+ /// "https://example.com" -> GET
577
+ /// { method:, url:, **opts }
578
+ fn build_request(
579
+ &self,
580
+ spec: Value,
581
+ shared: Option<&RHash>,
582
+ ) -> Result<wreq::RequestBuilder, magnus::Error> {
583
+ let mut method = wreq::Method::GET;
584
+ let url: String;
585
+ let mut item_opts: Option<RHash> = None;
586
+
587
+ if let Some(hash) = RHash::from_value(spec) {
588
+ url = hash_get_string(&hash, "url")?
589
+ .ok_or_else(|| generic_error("each request hash requires a :url"))?;
590
+ if let Some(val) = hash_get_value(&hash, "method")? {
591
+ method = value_to_method(val)?;
592
+ }
593
+ item_opts = Some(hash);
594
+ } else {
595
+ url = TryConvert::try_convert(spec)?;
596
+ }
597
+
598
+ let mut req = self.inner.request(method, &url);
599
+ if let Some(shared) = shared {
600
+ req = apply_request_options(req, shared)?;
601
+ }
602
+ if let Some(item_opts) = item_opts {
603
+ req = apply_request_options(req, &item_opts)?;
604
+ }
605
+ Ok(req)
606
+ }
607
+ }
608
+
609
+ /// Parse a String or Symbol like "post" / :post into an HTTP method.
610
+ fn value_to_method(val: Value) -> Result<wreq::Method, magnus::Error> {
611
+ let name: String = val.funcall("to_s", ())?;
612
+ name.to_uppercase()
613
+ .parse()
614
+ .map_err(|_| generic_error(format!("invalid HTTP method: {}", name)))
442
615
  }
443
616
 
444
617
  fn apply_request_options(
@@ -692,6 +865,7 @@ pub fn init(_ruby: &magnus::Ruby, module: &magnus::RModule) -> Result<(), magnus
692
865
  client_class.define_method("delete", method!(Client::delete, -1))?;
693
866
  client_class.define_method("head", method!(Client::head, -1))?;
694
867
  client_class.define_method("options", method!(Client::options, -1))?;
868
+ client_class.define_method("request_batch", method!(Client::request_batch, -1))?;
695
869
  client_class.define_method("cancel", method!(Client::cancel, 0))?;
696
870
 
697
871
  module.define_module_function("get", function!(wreq_get, -1))?;
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wreq
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wreq-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yicheng Zhou
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-06-09 00:00:00.000000000 Z
12
+ date: 2026-08-28 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rb_sys