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 +4 -4
- data/CHANGELOG.md +70 -1
- data/README.md +157 -8
- data/lib/requests/adapters.rb +116 -22
- data/lib/requests/api.rb +10 -5
- data/lib/requests/cookies.rb +92 -19
- data/lib/requests/hpack.rb +241 -0
- data/lib/requests/http2.rb +469 -0
- data/lib/requests/sessions.rb +75 -19
- data/lib/requests/utils.rb +18 -0
- data/lib/requests/version.rb +1 -1
- data/lib/requests.rb +2 -0
- metadata +10 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b4146e6fbead1e412259712bcd0939b7971d542bd94a41007e210c9c51de37c
|
|
4
|
+
data.tar.gz: bbc39cd987b00d948be87f9b02251d2e058016123c6ad2fda16724d921575f24
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 658ca79dbadae76f08300daa6ef77b5302bf8c8b2a42877659b490ffdc8359cc07f4c61ff79b56e181f3a4173d9474f5447170420198ee3bb8ded744404f58d1
|
|
7
|
+
data.tar.gz: 37e529b01d2a6406e86332e1ce10835c1b124b7076ceba696da237c215a0e3dab48a570e526dd75b614a95bce426bd1ce87c2a76035867d78fd45f33fa2fef5e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,75 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
[](https://rubygems.org/gems/requests_ruby)
|
|
6
6
|
[](https://www.ruby-lang.org)
|
|
7
|
+
[](https://rubygems.org/gems/requests_ruby)
|
|
7
8
|
[](LICENSE)
|
|
8
9
|
[](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:
|
|
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.
|
|
302
|
-
| `Requests.
|
|
303
|
-
| `Requests.
|
|
304
|
-
|
|
|
305
|
-
|
|
306
|
-
|
|
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)
|
data/lib/requests/adapters.rb
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 =
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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.
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
resp.raise_for_status
|
|
23
|
-
resp.save_to(to)
|
|
28
|
+
Session.new.download(url, to: to, **kw)
|
|
24
29
|
end
|
|
25
30
|
end
|
data/lib/requests/cookies.rb
CHANGED
|
@@ -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
|
-
@
|
|
12
|
+
@s = {}
|
|
8
13
|
end
|
|
9
14
|
def [](n)
|
|
10
|
-
@c
|
|
15
|
+
e = @s.values.reverse.find { |c| c.name == n }
|
|
16
|
+
e && e.value
|
|
11
17
|
end
|
|
12
18
|
def []=(n, v)
|
|
13
|
-
|
|
19
|
+
set(n, v)
|
|
14
20
|
end
|
|
15
21
|
def get(name, default = nil)
|
|
16
|
-
|
|
22
|
+
v = self[name]
|
|
23
|
+
v.nil? ? default : v
|
|
17
24
|
end
|
|
18
|
-
def set(name, value)
|
|
19
|
-
@
|
|
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.
|
|
31
|
+
@s.delete_if { |_, c| c.name == name }
|
|
24
32
|
end
|
|
25
33
|
def clear
|
|
26
|
-
@
|
|
34
|
+
@s.clear
|
|
27
35
|
end
|
|
28
36
|
def to_h
|
|
29
|
-
|
|
37
|
+
h = {}
|
|
38
|
+
@s.each_value { |c| h[c.name] = c.value }
|
|
39
|
+
h
|
|
30
40
|
end
|
|
31
41
|
def to_a
|
|
32
|
-
|
|
42
|
+
to_h.to_a
|
|
33
43
|
end
|
|
34
44
|
def each(&b)
|
|
35
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|