requests_ruby 1.0.2 → 1.0.4

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: 39c4cbcba9b1ee122bcd4ee5168716a9c7aed48b3ed463e1db32a3b2382789ad
4
- data.tar.gz: b7fbe77ea7b887ba479fbf51aa186d350fa30f7b27135bb51a80d790ca0d335b
3
+ metadata.gz: 8b4146e6fbead1e412259712bcd0939b7971d542bd94a41007e210c9c51de37c
4
+ data.tar.gz: bbc39cd987b00d948be87f9b02251d2e058016123c6ad2fda16724d921575f24
5
5
  SHA512:
6
- metadata.gz: 51632dd4038d0d55285a1b08b4ad2909855a29635237f087c10982a06d769dfecafefa899930e3a2c5fd329d498db41a2eb7e8a1ece0fe707f41f41c8d58b39b
7
- data.tar.gz: d5bec1101e2e23e63d5a093b9fee6b2b044014970346cb0b70e7b52440d7e5f4dd20b2159e3082490648297cfd155e95c0e70de723e64284d0992439e2173969
6
+ metadata.gz: 658ca79dbadae76f08300daa6ef77b5302bf8c8b2a42877659b490ffdc8359cc07f4c61ff79b56e181f3a4173d9474f5447170420198ee3bb8ded744404f58d1
7
+ data.tar.gz: 37e529b01d2a6406e86332e1ce10835c1b124b7076ceba696da237c215a0e3dab48a570e526dd75b614a95bce426bd1ce87c2a76035867d78fd45f33fa2fef5e
data/CHANGELOG.md CHANGED
@@ -1,6 +1,75 @@
1
1
  # Changelog
2
2
 
3
- ## 2.1.0
3
+ ## 1.0.4
4
+
5
+ Adds real HTTP/2 support and rounds off a handful of gaps that showed up
6
+ against Python's `requests` (and `requests`+`urllib3`) feature set.
7
+
8
+ ### Added
9
+ - **HTTP/2 support**, implemented from scratch on top of stdlib
10
+ `Socket`/`OpenSSL` (ALPN negotiation, HPACK header compression with
11
+ Huffman coding, frame multiplexing, flow control) - no external gems.
12
+ `Requests::Http2Adapter` is a drop-in `HTTPAdapter` replacement:
13
+ `Requests::Session.new(http2: true)`, `session.http2!`, or per-call
14
+ `http2: true` on any request/`download`. Transparently falls back to
15
+ HTTP/1.1 for plain `http://` URLs, proxied requests, or servers that
16
+ don't negotiate `h2` over ALPN.
17
+ - `Session#trust_env` (default `true`) - proxies are now picked up from
18
+ `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` (and lowercase variants) when no
19
+ explicit `proxies:` is set, matching python `requests`' default
20
+ behavior. Set `session.trust_env = false` to opt out.
21
+ - Retry backoff now honors a server's `Retry-After` header (seconds or
22
+ HTTP-date) when retrying a status in `status_forcelist`, instead of
23
+ always using the fixed `backoff_factor * attempt` formula.
24
+ - `Session#head!` / `#options!` and module-level `Requests.get!` /
25
+ `#post!` / `#put!` / `#patch!` / `#delete!` / `#head!` / `#options!` -
26
+ the raise-on-error helpers now cover every verb at both levels.
27
+ - `Requests.session(**opts)` now forwards keyword args (e.g.
28
+ `Requests.session(http2: true)`) instead of always building a bare
29
+ `Session.new`.
30
+
31
+ ### Fixed
32
+ - Resolved a leftover unresolved git merge conflict in this file.
33
+
34
+ ## 1.0.3
35
+
36
+ Focused on the two things people actually hit walls on with a stdlib-only
37
+ client: downloading big files without blowing up memory, and cookies that
38
+ behave like cookies instead of a flat global hash.
39
+
40
+ ### Added
41
+ - **Real connection pooling.** `HTTPAdapter` now keeps the underlying
42
+ `Net::HTTP` connection open and reuses it for subsequent requests to the
43
+ same host/port/proxy instead of doing a fresh TCP+TLS handshake every
44
+ single call. Connections are dropped and reopened automatically if the
45
+ server closes them or an error occurs. `Session#close` now actually
46
+ closes every pooled connection instead of being a no-op.
47
+ - **`Session#download(url, to:, ...)` / `Requests.download(url, to:, ...)`
48
+ now streams the response straight to disk in chunks** instead of
49
+ buffering the whole file in memory first. Options: `chunk_size:`,
50
+ `resume: true` (sends a `Range` header and appends to a partially
51
+ downloaded file), `progress: ->(downloaded, total) { ... }`.
52
+ Handles gzip/deflate on the fly, one chunk at a time.
53
+ - **`Requests::Jar` is now a real, domain-and-path-aware cookie jar**
54
+ instead of a flat name => value hash. Cookies set by one host are no
55
+ longer sent to every other host through the same session. Supports
56
+ `secure`, `http_only`, `expires`/`Max-Age`, explicit `set(name, value,
57
+ domain:, path:, secure:, http_only:, expires:)`, `for_domain(host)`,
58
+ and `for_url(url)`.
59
+ - `Jar#save(path)` / `Requests::Jar.load(path)` - persist a cookie jar to
60
+ a JSON file and load it back later (survives process restarts, useful
61
+ for scripts that log in once and reuse the session cookies).
62
+ - `HTTPAdapter#status_forcelist` - retry on specific response status codes
63
+ (e.g. `[502, 503, 504]`), on top of the existing connection-error retries.
64
+ - `Session#put!` / `#patch!` / `#delete!` to match the existing `get!` /
65
+ `post!` raise-on-error helpers.
66
+
67
+ ### Fixed
68
+ - A custom adapter passed to `Session#mount` with its own `max_retries`,
69
+ `backoff_factor`, or `status_forcelist` no longer gets silently
70
+ overwritten back to the session's defaults on the next request.
71
+
72
+ ## 1.0.2
4
73
 
5
74
  Big cleanup pass. Split the single `lib/requests.rb` file into a proper
6
75
  `lib/requests/*` structure, fixed several real bugs, and filled in a bunch
data/README.md CHANGED
@@ -4,6 +4,7 @@ A zero-dependency, stdlib-only HTTP client for Ruby, modeled closely on Python's
4
4
 
5
5
  [![Gem Version](https://img.shields.io/badge/gem-requests__ruby-red?logo=rubygems)](https://rubygems.org/gems/requests_ruby)
6
6
  [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%202.7-CC342D?logo=ruby)](https://www.ruby-lang.org)
7
+ [![Gem Downloads](https://img.shields.io/gem/dt/requests_ruby)](https://rubygems.org/gems/requests_ruby)
7
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
9
  [![Zero Dependencies](https://img.shields.io/badge/dependencies-zero-brightgreen)](requests_ruby.gemspec)
9
10
 
@@ -27,6 +28,9 @@ A zero-dependency, stdlib-only HTTP client for Ruby, modeled closely on Python's
27
28
  - [Cookies](#cookies)
28
29
  - [Hooks](#hooks)
29
30
  - [Streaming & downloads](#streaming--downloads)
31
+ - [Downloading big files in chunks](#downloading-big-files-in-chunks)
32
+ - [Connection pooling](#connection-pooling)
33
+ - [HTTP/2](#http2)
30
34
  - [Custom transport adapters](#custom-transport-adapters)
31
35
  - [Status codes](#status-codes)
32
36
  - [API reference](#-api-reference)
@@ -219,13 +223,18 @@ Requests.get(url, timeout: [3, 10]) # [connect, read]
219
223
  # retry transient connection errors on idempotent methods (GET/HEAD/OPTIONS/PUT/DELETE)
220
224
  s = Requests::Session.new
221
225
  s.retries = 3
222
- s.backoff_factor = 0.5 # sleeps 0.5s, 1s, 1.5s between attempts
226
+ s.backoff_factor = 0.5 # sleeps 0.5s, 1s, 1.5s between attempts, unless...
227
+ s.status_forcelist = [429, 502, 503, 504] # ...also retry these response codes
223
228
  s.get(url)
224
229
 
225
230
  # or per-request
226
231
  Requests.get(url, retries: 3)
227
232
  ```
228
233
 
234
+ A response in `status_forcelist` that comes back with a `Retry-After`
235
+ header (seconds or an HTTP date) is retried after that delay instead of
236
+ the `backoff_factor` formula.
237
+
229
238
  ### Proxies & SSL
230
239
 
231
240
  ```ruby
@@ -234,6 +243,12 @@ Requests.get(url, verify: false) # skip cert verification (careful
234
243
  Requests.get(url, verify: '/path/to/ca.pem') # custom CA bundle
235
244
  ```
236
245
 
246
+ By default (`session.trust_env = true`), a `Session` without an explicit
247
+ `proxies:` picks one up from the `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY`
248
+ environment variables (lowercase names also work), same as python
249
+ `requests`. Set `session.trust_env = false` to ignore the environment
250
+ entirely.
251
+
237
252
  A bundled `cacert.pem` (Mozilla's CA bundle) ships with the gem and is used by default. Override it globally with the `REQUESTS_CA_FILE` environment variable.
238
253
 
239
254
  ### Cookies
@@ -247,6 +262,24 @@ s.cookies.set('theme', 'dark')
247
262
  s.get(url) # sent automatically on every request through this session
248
263
  ```
249
264
 
265
+ `Requests::Jar` is a real cookie jar as of 1.0.3 — it tracks domain, path,
266
+ `secure`, `http_only` and expiry per cookie, same as a browser would, so a
267
+ cookie a session picked up from `api.example.com` won't leak into a request
268
+ to some unrelated host through the same session:
269
+
270
+ ```ruby
271
+ s = Requests::Session.new
272
+ s.get('https://api.example.com/login') # server sets a cookie scoped to api.example.com
273
+ s.get('https://other-service.example') # that cookie is NOT sent here
274
+
275
+ # set one explicitly with full control
276
+ s.cookies.set('session_id', 'abc123', domain: 'api.example.com', path: '/', secure: true, http_only: true)
277
+
278
+ # save/restore a session's cookies across process runs
279
+ s.cookies.save('cookies.json')
280
+ s.cookies = Requests::Jar.load('cookies.json')
281
+ ```
282
+
250
283
  ### Hooks
251
284
 
252
285
  ```ruby
@@ -266,7 +299,73 @@ r.iter_lines { |line| ... }
266
299
  Requests.download('https://example.com/file.zip', to: 'file.zip')
267
300
  ```
268
301
 
269
- > ℹ️ Note: unlike Python's `requests`, the body is fully read before `iter_content`/`iter_lines` yield anything — this library deliberately stays stdlib-only, and true lazy streaming would need extra machinery to keep a `net/http` connection open across method calls. See [Known limitations](#-known-limitations).
302
+ > ℹ️ Note: `iter_content`/`iter_lines` on a regular `Response` still read the whole body first — this library stays stdlib-only, and true lazy streaming for arbitrary responses would need extra machinery to keep a `net/http` connection open across method calls. See [Known limitations](#-known-limitations). For actual large files, use `download` below — that one *is* real chunk-by-chunk streaming straight to disk.
303
+
304
+ ### Downloading big files in chunks
305
+
306
+ As of 1.0.3, `download` doesn't buffer the file in memory at all — it reads
307
+ from the socket in chunks and writes each one straight to disk, decoding
308
+ gzip/deflate on the fly. This is the one to reach for instead of
309
+ `Requests.get(url).save_to(path)` once files get past a few MB.
310
+
311
+ ```ruby
312
+ Requests.download('https://example.com/big-file.zip', to: 'big-file.zip')
313
+
314
+ # custom chunk size + a progress callback
315
+ Requests.download('https://example.com/big-file.zip', to: 'big-file.zip',
316
+ chunk_size: 65536,
317
+ progress: ->(downloaded, total) { print "\r#{downloaded}/#{total}" })
318
+
319
+ # resume an interrupted download (sends a Range header, appends to the file)
320
+ Requests.download('https://example.com/big-file.zip', to: 'big-file.zip', resume: true)
321
+
322
+ # same thing on a Session, so it reuses the pooled connection and session auth/headers
323
+ s = Requests::Session.new
324
+ s.headers['Authorization'] = 'Bearer xyz'
325
+ s.download('https://example.com/private-file.zip', to: 'private-file.zip')
326
+ ```
327
+
328
+ ### Connection pooling
329
+
330
+ As of 1.0.3, `HTTPAdapter` keeps the underlying `Net::HTTP` connection open
331
+ and reuses it for further requests to the same host/port/proxy instead of
332
+ paying for a new TCP+TLS handshake on every single call — this is the same
333
+ idea as `urllib3`'s connection pool under python's `requests`. It's
334
+ automatic and requires nothing from you:
335
+
336
+ ```ruby
337
+ s = Requests::Session.new
338
+ s.get('https://api.example.com/a') # opens + keeps the connection
339
+ s.get('https://api.example.com/b') # reuses it, no new handshake
340
+ s.close # closes every pooled connection when you're done
341
+ ```
342
+
343
+ If a pooled connection goes stale (the server closed it, a timeout, whatever)
344
+ it's transparently dropped and reopened on the next request; combined with
345
+ `retries`/`backoff_factor` this makes long-lived sessions much more resilient
346
+ than opening a fresh connection per call.
347
+
348
+ ### HTTP/2
349
+
350
+ As of 1.0.4, `Requests::Http2Adapter` speaks HTTP/2 directly over
351
+ `Socket`/`OpenSSL` - ALPN negotiation, HPACK header compression, and
352
+ stream multiplexing/flow control, all stdlib, no external gem:
353
+
354
+ ```ruby
355
+ s = Requests::Session.new(http2: true)
356
+ s.get('https://api.example.com/a') # negotiates h2 over ALPN, reuses the connection
357
+ s.get('https://api.example.com/b') # same connection, new stream
358
+
359
+ # or opt in per call on a plain session
360
+ Requests.get('https://api.example.com', http2: true)
361
+ resp = Requests::Session.new.tap(&:http2!).get('https://api.example.com')
362
+ ```
363
+
364
+ If the server doesn't advertise `h2` during the TLS handshake, or the URL
365
+ is plain `http://`, or a proxy is configured, requests on that host
366
+ transparently fall back to the regular `HTTPAdapter` (HTTP/1.1) - you get
367
+ the same `Response` object either way and don't need to know which
368
+ transport actually served the request.
270
369
 
271
370
  ### Custom transport adapters
272
371
 
@@ -298,12 +397,58 @@ Requests.codes.im_a_teapot # 418, yes really
298
397
  |---|---|---|
299
398
  | `Requests.get/post/put/patch/delete/head/options` | `session.get/post/put/patch/delete/head/options` | `status_code`, `status` |
300
399
  | `Requests.request(method, url, **opts)` | `session.request(method, url, **opts)` | `ok?`, `redirect?` |
301
- | `Requests.session` new `Session` | `session.get!/post!` (raise on error) | `text`, `content`, `json` |
302
- | `Requests.download(url, to:)` | `session.mount(prefix, adapter)` | `headers`, `cookies` |
303
- | `Requests.codes` | `session.hooks[:response]` | `elapsed`, `history`, `url` |
304
- | | `session.headers`, `.cookies`, `.auth`, `.proxies`, `.retries` | `raise_for_status`, `save_to`, `links` |
305
-
306
- Every request-level method accepts: `params`, `data`, `json`, `headers`, `cookies`, `files`, `auth`, `timeout`, `allow_redirects`, `proxies`, `verify`, `cert`, `hooks`, `retries`.
400
+ | `Requests.get!/post!/put!/patch!/delete!/head!/options!` (raise on error) | `session.get!/post!/put!/patch!/delete!/head!/options!` (raise on error) | `text`, `content`, `json` |
401
+ | `Requests.session(**opts)` → new `Session` | `session.mount(prefix, adapter)`, `session.close` | `headers`, `cookies` |
402
+ | `Requests.download(url, to:, chunk_size:, resume:, progress:)` | `session.download(url, to:, ...)` (same, but reuses the session's pool/auth/headers) | `elapsed`, `history`, `url` |
403
+ | `Requests.codes` | `session.http2!`, `Session.new(http2: true)`, or `http2: true` per request | `raise_for_status`, `save_to`, `links` |
404
+ | | `session.hooks[:response]` | |
405
+ | | `session.headers`, `.cookies`, `.auth`, `.proxies`, `.trust_env`, `.retries`, `.backoff_factor`, `.status_forcelist` | |
406
+ | | `Requests::Jar#save(path)` / `Requests::Jar.load(path)` | |
407
+ | | `HTTPAdapter.new(max_retries:, backoff_factor:, status_forcelist:)`, `Http2Adapter.new(...)` | |
408
+
409
+ Every request-level method accepts: `params`, `data`, `json`, `headers`, `cookies`, `files`, `auth`, `timeout`, `allow_redirects`, `proxies`, `verify`, `cert`, `hooks`, `retries`, `http2`.
410
+
411
+ ## Python parity cheat sheet
412
+
413
+ | Python `requests` | `requests_ruby` |
414
+ |---|---|
415
+ | `requests.get(url, params={...})` | `Requests.get(url, params: {...})` |
416
+ | `requests.post(url, json={...})` | `Requests.post(url, json: {...})` |
417
+ | `r.status_code` | `r.status_code` |
418
+ | `r.ok` | `r.ok?` |
419
+ | `r.text` / `r.content` | `r.text` / `r.content` |
420
+ | `r.json()` | `r.json` |
421
+ | `r.raise_for_status()` | `r.raise_for_status` |
422
+ | `r.headers['x']` | `r.headers['x']` (case-insensitive both ways) |
423
+ | `s = requests.Session()` | `s = Requests::Session.new` |
424
+ | `s.mount('https://', adapter)` | `s.mount('https://', adapter)` |
425
+ | `requests.auth.HTTPBasicAuth(u, p)` | `Requests::BasicAuth.new(u, p)` |
426
+ | `requests.exceptions.Timeout` | `Requests::Timeoutable` (module, catches both) |
427
+ | `r.iter_content(chunk_size=...)` | `r.iter_content(chunk_size: ...)` |
428
+ | `s.cookies.get_dict()` | `s.cookies.to_h` |
429
+ | n/a | `Requests.download(url, to:, resume:, progress:)` — no python equivalent, this one's ours |
430
+
431
+ ## Known limitations
432
+
433
+ - `iter_content`/`iter_lines` on a regular response read the whole body up
434
+ front rather than lazily streaming it off the socket — `download` (added
435
+ in 1.0.3) is the one that streams for real, use that for big files.
436
+ - HTTP/2 (1.0.4) only applies to direct `https://` connections - there's
437
+ no `h2c` (cleartext HTTP/2) support, and requests through a proxy always
438
+ use HTTP/1.1. No automatic NTLM/Kerberos/OAuth either — bring your own
439
+ `auth:` object (anything with `#call(headers)` works) if you need those.
440
+ - The HTTP/2 adapter applies `timeout:` as one coarse deadline around the
441
+ whole request/response exchange rather than resetting it on every
442
+ individual socket read the way the HTTP/1.1 adapter's `Net::HTTP`
443
+ read-timeout does.
444
+ - `verify: '/path/to/ca.pem'` accepts a single bundle file, not a
445
+ directory of certs (`Net::HTTP` itself only takes `ca_file` or
446
+ `ca_path`, and this gem only wires up the former today).
447
+ - The connection pool added in 1.0.3 (HTTP/1.1 and HTTP/2 alike) is per-
448
+ `Session`/per-adapter instance and isn't thread-safe for concurrent
449
+ requests on the *same* `Session` object — use one `Session` per thread,
450
+ or one connection per thread, same advice as most connection-pooling
451
+ HTTP clients.
307
452
 
308
453
 
309
454
  ## 🔧 Development
@@ -326,6 +471,10 @@ Bug reports and pull requests are welcome. A few ground rules:
326
471
  2. Add a test in `spec/requests_spec.rb` for anything you fix or add.
327
472
  3. Keep the Python-parity naming where it makes sense, but don't force it where it doesn't fit Ruby idiom (`?`-suffixed predicates, etc.).
328
473
 
474
+ ## Support the project
475
+
476
+ If requests_ruby helped you, consider starring the repository ⭐
477
+
329
478
  ## License
330
479
 
331
480
  [MIT](LICENSE)
@@ -5,6 +5,7 @@ require 'openssl'
5
5
  require 'uri'
6
6
  require 'zlib'
7
7
  require 'stringio'
8
+ require 'time'
8
9
 
9
10
  module Requests
10
11
  class HTTPAdapter
@@ -16,24 +17,72 @@ module Requests
16
17
  'DELETE' => Net::HTTP::Delete,
17
18
  'HEAD' => Net::HTTP::Head,
18
19
  'OPTIONS' => Net::HTTP::Options}.freeze
19
- attr_accessor :max_retries, :backoff_factor
20
- def initialize(max_retries: 0, backoff_factor: 0)
20
+ attr_accessor :max_retries, :backoff_factor, :status_forcelist
21
+ def initialize(max_retries: 0, backoff_factor: 0, status_forcelist: [])
21
22
  @max_retries = max_retries
22
23
  @backoff_factor = backoff_factor
24
+ @status_forcelist = status_forcelist
25
+ @pool = {}
23
26
  end
24
27
  def send_once(meth, url, hdrs, body, timeout, proxies, verify, cert, auth)
25
28
  attempt = 0
26
- begin
27
- do_send(meth, url, hdrs, body, timeout, proxies, verify, cert, auth)
28
- rescue Requests::ConnectionError, Requests::ConnectTimeout => e
29
- attempt += 1
30
- if attempt <= @max_retries && idempotent?(meth)
29
+ loop do
30
+ begin
31
+ r = do_send(meth, url, hdrs, body, timeout, proxies, verify, cert, auth)
32
+ if @status_forcelist.include?(r.code.to_i) && attempt < @max_retries
33
+ attempt += 1
34
+ sleep(retry_delay(r, attempt))
35
+ next
36
+ end
37
+ return r
38
+ rescue Requests::ConnectionError, Requests::ConnectTimeout => e
39
+ attempt += 1
40
+ raise e unless attempt <= @max_retries && idempotent?(meth)
31
41
  sleep(@backoff_factor * attempt) if @backoff_factor.to_f > 0
32
- retry
33
42
  end
34
- raise e
35
43
  end
36
44
  end
45
+ def close_pool
46
+ @pool.each_value { |h| (h.finish if h.started?) rescue nil }
47
+ @pool.clear
48
+ end
49
+ def stream_to(meth, url, hdrs, body, timeout, proxies, verify, cert, auth, io, chunk_size: 65536, progress: nil)
50
+ u = URI.parse(url)
51
+ auth.call(hdrs) if auth.respond_to?(:call) && !auth.is_a?(Requests::DigestAuth)
52
+ open_t, read_t = split_timeout(timeout)
53
+ http = conn_for(u, proxies, verify, cert, open_t, read_t)
54
+ klass = METHS[meth] || Net::HTTP::Get
55
+ req = klass.new(u.request_uri)
56
+ hdrs.each { |k, v| req[k] = v }
57
+ req.body = body if body
58
+ net_resp = nil
59
+ total = 0
60
+ begin
61
+ http.request(req) do |nr|
62
+ net_resp = nr
63
+ dec = stream_decoder(nr)
64
+ nr.read_body do |chunk|
65
+ piece = dec == :raw ? chunk : dec.inflate(chunk)
66
+ io.write(piece)
67
+ total += piece.bytesize
68
+ progress.call(total, nr.content_length) if progress
69
+ end
70
+ end
71
+ rescue Net::OpenTimeout
72
+ drop_conn(u, proxies)
73
+ raise Requests::ConnectTimeout, "connect timeout: #{url}"
74
+ rescue Net::ReadTimeout
75
+ drop_conn(u, proxies)
76
+ raise Requests::ReadTimeout, "read timeout: #{url}"
77
+ rescue OpenSSL::SSL::SSLError => e
78
+ drop_conn(u, proxies)
79
+ raise Requests::SSLError, e.message
80
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE, SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT => e
81
+ drop_conn(u, proxies)
82
+ raise Requests::ConnectionError, e.message
83
+ end
84
+ net_resp
85
+ end
37
86
  def decode_body(net_resp)
38
87
  raw = net_resp.body
39
88
  return '' if raw.nil?
@@ -56,33 +105,78 @@ module Requests
56
105
  def idempotent?(meth)
57
106
  %w[GET HEAD OPTIONS PUT DELETE].include?(meth.to_s.upcase)
58
107
  end
108
+ def retry_delay(resp, attempt)
109
+ ra = resp['retry-after']
110
+ if ra
111
+ return ra.to_i if ra =~ /\A\d+\z/
112
+ begin
113
+ d = Time.httpdate(ra) - Time.now
114
+ return d.positive? ? d : 0
115
+ rescue ArgumentError
116
+ nil
117
+ end
118
+ end
119
+ @backoff_factor.to_f > 0 ? @backoff_factor * attempt : 0
120
+ end
59
121
  def do_send(meth, url, hdrs, body, timeout, proxies, verify, cert, auth)
60
122
  u = URI.parse(url)
61
123
  auth.call(hdrs) if auth.respond_to?(:call) && !auth.is_a?(Requests::DigestAuth)
62
- popt = proxy_opts(u, proxies)
63
124
  open_t, read_t = split_timeout(timeout)
64
- http = Net::HTTP.new(u.host, u.port, *popt)
65
- configure_ssl(http, u, verify, cert)
66
- http.open_timeout = open_t if open_t
67
- http.read_timeout = read_t if read_t
125
+ http = conn_for(u, proxies, verify, cert, open_t, read_t)
68
126
  begin
69
- http.start do |h|
70
- klass = METHS[meth] || Net::HTTP::Get
71
- req = klass.new(u.request_uri)
72
- hdrs.each { |k, v| req[k] = v }
73
- req.body = body if body
74
- h.request(req)
75
- end
127
+ klass = METHS[meth] || Net::HTTP::Get
128
+ req = klass.new(u.request_uri)
129
+ hdrs.each { |k, v| req[k] = v }
130
+ req.body = body if body
131
+ http.request(req)
76
132
  rescue Net::OpenTimeout
133
+ drop_conn(u, proxies)
77
134
  raise Requests::ConnectTimeout, "connect timeout: #{url}"
78
135
  rescue Net::ReadTimeout
136
+ drop_conn(u, proxies)
79
137
  raise Requests::ReadTimeout, "read timeout: #{url}"
80
138
  rescue OpenSSL::SSL::SSLError => e
139
+ drop_conn(u, proxies)
81
140
  raise Requests::SSLError, e.message
82
- rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Errno::ECONNRESET => e
141
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE, SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT => e
142
+ drop_conn(u, proxies)
83
143
  raise Requests::ConnectionError, e.message
84
144
  end
85
145
  end
146
+ def conn_for(u, proxies, verify, cert, ot, rt)
147
+ key = pool_key(u, proxies)
148
+ h = @pool[key]
149
+ if h && h.started?
150
+ h.open_timeout = ot if ot
151
+ h.read_timeout = rt if rt
152
+ return h
153
+ end
154
+ popt = proxy_opts(u, proxies)
155
+ h = Net::HTTP.new(u.host, u.port, *popt)
156
+ configure_ssl(h, u, verify, cert)
157
+ h.open_timeout = ot if ot
158
+ h.read_timeout = rt if rt
159
+ h.keep_alive_timeout = 30
160
+ h.start
161
+ @pool[key] = h
162
+ h
163
+ end
164
+ def drop_conn(u, proxies)
165
+ key = pool_key(u, proxies)
166
+ h = @pool.delete(key)
167
+ (h.finish if h && h.started?) rescue nil
168
+ end
169
+ def pool_key(u, proxies)
170
+ p = proxy_opts(u, proxies)
171
+ "#{u.scheme}|#{u.host}|#{u.port}|#{p.join(',')}"
172
+ end
173
+ def stream_decoder(nr)
174
+ case nr['content-encoding'].to_s.downcase
175
+ when 'gzip', 'x-gzip' then Zlib::Inflate.new(32 + Zlib::MAX_WBITS)
176
+ when 'deflate' then Zlib::Inflate.new
177
+ else :raw
178
+ end
179
+ end
86
180
  def configure_ssl(http, uri, verify, cert)
87
181
  return unless uri.scheme == 'https'
88
182
  http.use_ssl = true
data/lib/requests/api.rb CHANGED
@@ -14,12 +14,17 @@ module Requests
14
14
  kw[:allow_redirects] = kw.fetch(:allow_redirects, false)
15
15
  request('HEAD', url, **kw)
16
16
  end
17
- def self.session
18
- Session.new
17
+ def self.get!(url, **kw); get(url, **kw).raise_for_status; end
18
+ def self.post!(url, **kw); post(url, **kw).raise_for_status; end
19
+ def self.put!(url, **kw); put(url, **kw).raise_for_status; end
20
+ def self.patch!(url, **kw); patch(url, **kw).raise_for_status; end
21
+ def self.delete!(url, **kw); delete(url, **kw).raise_for_status; end
22
+ def self.head!(url, **kw); head(url, **kw).raise_for_status; end
23
+ def self.options!(url, **kw); options(url, **kw).raise_for_status; end
24
+ def self.session(**kw)
25
+ Session.new(**kw)
19
26
  end
20
27
  def self.download(url, to:, **kw)
21
- resp = get(url, **kw)
22
- resp.raise_for_status
23
- resp.save_to(to)
28
+ Session.new.download(url, to: to, **kw)
24
29
  end
25
30
  end
@@ -1,55 +1,128 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+ require 'json'
5
+
3
6
  module Requests
4
7
  class Jar
5
8
  include Enumerable
9
+ Ck = Struct.new(:name, :value, :domain, :path, :secure, :http_only, :expires)
10
+
6
11
  def initialize
7
- @c = {}
12
+ @s = {}
8
13
  end
9
14
  def [](n)
10
- @c[n]
15
+ e = @s.values.reverse.find { |c| c.name == n }
16
+ e && e.value
11
17
  end
12
18
  def []=(n, v)
13
- @c[n] = v
19
+ set(n, v)
14
20
  end
15
21
  def get(name, default = nil)
16
- @c.fetch(name, default)
22
+ v = self[name]
23
+ v.nil? ? default : v
17
24
  end
18
- def set(name, value)
19
- @c[name] = value
25
+ def set(name, value, domain: '', path: '/', secure: false, http_only: false, expires: nil)
26
+ @s[ck(domain, path, name)] = Ck.new(name, value, domain, path, secure, http_only, expires)
20
27
  self
21
28
  end
29
+ alias_method :set_cookie, :set
22
30
  def delete(name)
23
- @c.delete(name)
31
+ @s.delete_if { |_, c| c.name == name }
24
32
  end
25
33
  def clear
26
- @c.clear
34
+ @s.clear
27
35
  end
28
36
  def to_h
29
- @c.dup
37
+ h = {}
38
+ @s.each_value { |c| h[c.name] = c.value }
39
+ h
30
40
  end
31
41
  def to_a
32
- @c.to_a
42
+ to_h.to_a
33
43
  end
34
44
  def each(&b)
35
- @c.each(&b)
45
+ return enum_for(:each) unless block_given?
46
+ @s.each_value { |c| yield c.name, c.value }
47
+ end
48
+ def all
49
+ @s.values
36
50
  end
37
51
  def empty?
38
- @c.empty?
52
+ @s.empty?
53
+ end
54
+ def for_url(url)
55
+ u = safe_uri(url)
56
+ now = Time.now
57
+ @s.values.select do |c|
58
+ next false if c.expires && c.expires < now
59
+ next false if c.secure && u.scheme != 'https'
60
+ h = u.host.to_s
61
+ dm = c.domain.to_s.empty? || h == c.domain || h.end_with?(".#{c.domain}")
62
+ pp = u.path.to_s
63
+ pm = pp.empty? ? c.path == '/' : pp.start_with?(c.path)
64
+ dm && pm
65
+ end
66
+ end
67
+ def for_domain(host)
68
+ @s.values.select { |c| c.domain.to_s.empty? || host.to_s == c.domain || host.to_s.end_with?(".#{c.domain}") }
39
69
  end
40
- def to_header
41
- @c.map { |k, v| "#{k}=#{v}" }.join('; ')
70
+ def to_header(url = nil)
71
+ list = url ? for_url(url) : @s.values.select { |c| !(c.expires && c.expires < Time.now) }
72
+ list.map { |c| "#{c.name}=#{c.value}" }.join('; ')
42
73
  end
43
- def update(net_resp)
44
- lines = net_resp.get_fields('set-cookie') || []
45
- lines.each do |l|
46
- kv = l.split(';').first
74
+ def update(net_resp, url = nil)
75
+ u = url ? safe_uri(url) : nil
76
+ (net_resp.get_fields('set-cookie') || []).each do |line|
77
+ parts = line.split(';').map(&:strip)
78
+ kv = parts.shift
47
79
  next unless kv
48
80
  k, v = kv.split('=', 2)
49
81
  next unless k
50
- @c[k.strip] = v.to_s.strip
82
+ at = {}
83
+ parts.each do |p|
84
+ pk, pv = p.split('=', 2)
85
+ at[pk.to_s.downcase] = pv
86
+ end
87
+ dom = at['domain'].to_s.sub(/\A\./, '')
88
+ dom = u.host.to_s if dom.empty? && u
89
+ exp = nil
90
+ if at['max-age']
91
+ exp = (Time.now + at['max-age'].to_i rescue nil)
92
+ elsif at['expires']
93
+ exp = (Time.parse(at['expires']) rescue nil)
94
+ end
95
+ set(k, v.to_s, domain: dom, path: at['path'] || '/', secure: at.key?('secure'),
96
+ http_only: at.key?('httponly'), expires: exp)
51
97
  end
52
98
  end
99
+ def save(path)
100
+ data = @s.values.map do |c|
101
+ { name: c.name, value: c.value, domain: c.domain, path: c.path, secure: c.secure,
102
+ http_only: c.http_only, expires: c.expires && c.expires.iso8601 }
103
+ end
104
+ File.write(path, JSON.generate(data))
105
+ path
106
+ end
107
+ def self.load(path)
108
+ j = new
109
+ arr = JSON.parse(File.read(path))
110
+ arr.each do |c|
111
+ j.set(c['name'], c['value'], domain: c['domain'].to_s, path: c['path'] || '/',
112
+ secure: !!c['secure'], http_only: !!c['http_only'],
113
+ expires: (c['expires'] ? Time.parse(c['expires']) : nil))
114
+ end
115
+ j
116
+ end
117
+ private
118
+ def ck(domain, path, name)
119
+ "#{domain}|#{path}|#{name}"
120
+ end
121
+ def safe_uri(url)
122
+ URI.parse(url)
123
+ rescue
124
+ URI.parse('')
125
+ end
53
126
  end
54
127
  CookieJar = Jar
55
128
  end