net-hippie 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3fdff6728d13d3fece5e05908cc04e9c0999909ab392f73c9dc67a3203bbfb6
4
- data.tar.gz: e3e94c49e148b6931b543b011cf10da959d36d6b8f0f6b2dd1f4620e5ff21521
3
+ metadata.gz: f1f315d747c0e48d92a0a25bb9395fe80f472757782d8341b43f833462f5338e
4
+ data.tar.gz: d71b266d4e60f3ca16f6eed02c0ac2d42b10ab6c60a445a2c349351b75bad4f8
5
5
  SHA512:
6
- metadata.gz: 1f1ec4190b76c30c45efa6c00198326eb08f4e80280557dc42a6afb3c70166e80e624986ac14823f1d3f6a24ba16afb923f5ead14420fd34fdc5f88620f82a90
7
- data.tar.gz: 0331df5f99e4b75ba58185c9dbba971e97f1233f3c0625dc0d7a6948e41cd2b3cc63bebeb4c7bba08767c677a72a1e2e3aee6df2d45afa54c2b0701096a97dc4
6
+ metadata.gz: 524460bd6fd5a982db5025418ebb9449cfee70876df3338230f06eb97fdf2bedae4c591d987b3c06fca7f363355d6acf96ceecaf9c3299915eacecd578a5a5d6
7
+ data.tar.gz: 7b885d2bcdb7298bad431ad2fdf51a67b7fd356caa0cc01ff8557cbf85bc7e9eb575e655e95789f53795a5f8acd16c3b98c25b2d831ec9410a5a8d5e03aa6c8d
data/.rubocop.yml CHANGED
@@ -1,3 +1,7 @@
1
+ plugins:
2
+ - rubocop-minitest
3
+ - rubocop-rake
4
+
1
5
  AllCops:
2
6
  Exclude:
3
7
  - 'coverage/**/*'
@@ -6,7 +10,7 @@ AllCops:
6
10
  - 'tmp/**/*'
7
11
  - 'vendor/**/*'
8
12
  NewCops: enable
9
- TargetRubyVersion: 3.2
13
+ TargetRubyVersion: 4.0
10
14
 
11
15
  Gemspec/DevelopmentDependencies:
12
16
  EnforcedStyle: gemspec
data/CHANGELOG.md CHANGED
@@ -1,5 +1,3 @@
1
- Version 1.3.0
2
-
3
1
  # Changelog
4
2
  All notable changes to this project will be documented in this file.
5
3
 
@@ -8,6 +6,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
6
 
9
7
  ## [Unreleased]
10
8
 
9
+ ## [1.5.0] - 2026-02-01
10
+
11
+ ### Changed
12
+
13
+ - Prefer IPv4 addresses in DNS resolution to avoid connectivity issues
14
+ - Increase default `keep_alive_timeout` to 30 seconds for better connection reuse
15
+ - Set TLS `min_version` to TLS 1.2 by default for improved security
16
+ - Improve retry logging format with structured error details
17
+
18
+ ### Added
19
+
20
+ - Add thread-safe connection pooling with LRU eviction
21
+ - Add DNS pre-resolution with configurable timeout to prevent indefinite hangs
22
+ - Add DNS caching with TTL support
23
+ - Add persistent HTTP sessions to avoid `Connection: close` overhead
24
+ - Add `head` and `options` HTTP methods
25
+ - Add `close_all` method to release all pooled connections
26
+ - Add `reset_default_client!` method to reset the default client
27
+ - Add configuration options: `keep_alive_timeout`, `max_retries`, `min_version`, `continue_timeout`, `ignore_eof`, `max_connections`, `dns_timeout`, `dns_ttl`
28
+ - Parse TLS certificates once at initialization for performance
29
+
30
+ ### Fixed
31
+
32
+ - Fix connection leak when racing threads create duplicate connections
33
+
11
34
  ## [1.4.0] - 2025-10-08
12
35
  ### Added
13
36
  - Streaming response support via block parameter
@@ -100,31 +123,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
100
123
  - with\_retry.
101
124
  - authorization header helpers
102
125
 
103
- [Unreleased]: https://github.com/xlgmokha/net-hippie/compare/v1.4.0...HEAD
104
- [1.4.0]: https://github.com/xlgmokha/net-hippie/compare/v1.3.0...v1.4.0
105
- [1.3.0]: https://github.com/xlgmokha/net-hippie/compare/v1.2.0...v1.3.0
106
- [1.2.0]: https://github.com/xlgmokha/net-hippie/compare/v1.1.1...v1.2.0
107
- [1.1.1]: https://github.com/xlgmokha/net-hippie/compare/v1.1.0...v1.1.1
108
- [1.1.0]: https://github.com/xlgmokha/net-hippie/compare/v1.0.1...v1.1.0
109
- [1.0.1]: https://github.com/xlgmokha/net-hippie/compare/v1.0.0...v1.0.1
110
- [1.0.0]: https://github.com/xlgmokha/net-hippie/compare/v0.3.2...v1.0.0
111
- [0.3.2]: https://github.com/xlgmokha/net-hippie/compare/v0.3.1...v0.3.2
112
- [0.3.1]: https://github.com/xlgmokha/net-hippie/compare/v0.3.0...v0.3.1
113
- [0.3.0]: https://github.com/xlgmokha/net-hippie/compare/v0.2.7...v0.3.0
114
- [0.2.7]: https://github.com/xlgmokha/net-hippie/compare/v0.2.6...v0.2.7
115
- [0.2.6]: https://github.com/xlgmokha/net-hippie/compare/v0.2.5...v0.2.6
116
- [0.2.5]: https://github.com/xlgmokha/net-hippie/compare/v0.2.4...v0.2.5
117
- [0.2.4]: https://github.com/xlgmokha/net-hippie/compare/v0.2.3...v0.2.4
118
- [0.2.3]: https://github.com/xlgmokha/net-hippie/compare/v0.2.2...v0.2.3
119
- [0.2.2]: https://github.com/xlgmokha/net-hippie/compare/v0.2.1...v0.2.2
120
- [0.2.1]: https://github.com/xlgmokha/net-hippie/compare/v0.2.0...v0.2.1
121
- [0.2.0]: https://github.com/xlgmokha/net-hippie/compare/v0.1.9...v0.2.0
122
- [0.1.9]: https://github.com/xlgmokha/net-hippie/compare/v0.1.8...v0.1.9
123
- [0.1.8]: https://github.com/xlgmokha/net-hippie/compare/v0.1.7...v0.1.8
124
- [0.1.7]: https://github.com/xlgmokha/net-hippie/compare/v0.1.6...v0.1.7
125
- [0.1.6]: https://github.com/xlgmokha/net-hippie/compare/v0.1.5...v0.1.6
126
- [0.1.5]: https://github.com/xlgmokha/net-hippie/compare/v0.1.4...v0.1.5
127
- [0.1.4]: https://github.com/xlgmokha/net-hippie/compare/v0.1.3...v0.1.4
128
- [0.1.3]: https://github.com/xlgmokha/net-hippie/compare/v0.1.2...v0.1.3
129
- [0.1.2]: https://github.com/xlgmokha/net-hippie/compare/v0.1.1...v0.1.2
130
- [0.1.1]: https://github.com/xlgmokha/net-hippie/compare/v0.1.0...v0.1.1
126
+ [Unreleased]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v1.5.0...HEAD
127
+ [1.5.0]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v1.4.0...v1.5.0
128
+ [1.4.0]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v1.3.0...v1.4.0
129
+ [1.3.0]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v1.2.0...v1.3.0
130
+ [1.2.0]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v1.1.1...v1.2.0
131
+ [1.1.1]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v1.1.0...v1.1.1
132
+ [1.1.0]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v1.0.1...v1.1.0
133
+ [1.0.1]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v1.0.0...v1.0.1
134
+ [1.0.0]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.3.2...v1.0.0
135
+ [0.3.2]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.3.1...v0.3.2
136
+ [0.3.1]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.3.0...v0.3.1
137
+ [0.3.0]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.2.7...v0.3.0
138
+ [0.2.7]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.2.6...v0.2.7
139
+ [0.2.6]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.2.5...v0.2.6
140
+ [0.2.5]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.2.4...v0.2.5
141
+ [0.2.4]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.2.3...v0.2.4
142
+ [0.2.3]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.2.2...v0.2.3
143
+ [0.2.2]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.2.1...v0.2.2
144
+ [0.2.1]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.2.0...v0.2.1
145
+ [0.2.0]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.1.9...v0.2.0
146
+ [0.1.9]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.1.8...v0.1.9
147
+ [0.1.8]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.1.7...v0.1.8
148
+ [0.1.7]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.1.6...v0.1.7
149
+ [0.1.6]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.1.5...v0.1.6
150
+ [0.1.5]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.1.4...v0.1.5
151
+ [0.1.4]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.1.3...v0.1.4
152
+ [0.1.3]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.1.2...v0.1.3
153
+ [0.1.2]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.1.1...v0.1.2
154
+ [0.1.1]: https://src.mokhan.ca/xlgmokha/net-hippie/compare/v0.1.0...v0.1.1
data/README.md CHANGED
@@ -1,9 +1,5 @@
1
1
  # Net::Hippie
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/net-hippie.svg)](https://rubygems.org/gems/net-hippie)
4
- [![Build Status](https://github.com/xlgmokha/net-hippie/actions/workflows/ci.yml/badge.svg))](https://github.com/xlgmokha/net-hippie/actions)
5
-
6
-
7
3
  `Net::Hippie` is a light weight wrapper around `net/http` that defaults to
8
4
  sending `JSON` messages.
9
5
 
@@ -136,7 +132,7 @@ push git commits and tags, and push the `.gem` file to [rubygems.org](https://ru
136
132
 
137
133
  ## Contributing
138
134
 
139
- Bug reports and pull requests are welcome on GitHub at https://github.com/xlgmokha/net-hippie.
135
+ Patches are welcome at https://src.mokhan.ca/xlgmokha/net-hippie.
140
136
 
141
137
  ## License
142
138
 
@@ -1,15 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'net/hippie/dns_cache'
4
+ require 'net/hippie/tls_parser'
5
+
3
6
  module Net
4
7
  module Hippie
5
- # A simple client for connecting with http resources.
8
+ # HTTP client with connection pooling, DNS caching, and retry logic.
6
9
  class Client
10
+ include TlsParser
11
+
7
12
  DEFAULT_HEADERS = {
8
13
  'Accept' => 'application/json',
9
14
  'Content-Type' => 'application/json',
10
- 'User-Agent' => "net/hippie #{Net::Hippie::VERSION}"
15
+ 'User-Agent' => "net/hippie #{VERSION}"
11
16
  }.freeze
12
17
 
18
+ JITTER = Random.new.freeze
19
+
13
20
  attr_reader :mapper, :logger, :follow_redirects
14
21
 
15
22
  def initialize(options = {})
@@ -17,102 +24,103 @@ module Net
17
24
  @mapper = options.fetch(:mapper, ContentTypeMapper.new)
18
25
  @logger = options.fetch(:logger, Net::Hippie.logger)
19
26
  @follow_redirects = options.fetch(:follow_redirects, 0)
20
- @default_headers = options.fetch(:headers, DEFAULT_HEADERS)
21
- @connections = Hash.new do |hash, key|
22
- scheme, host, port = key
23
- hash[key] = Connection.new(scheme, host, port, options)
24
- end
25
- end
26
-
27
- def execute(uri, request, limit: follow_redirects, &block)
28
- connection = connection_for(uri)
29
- return execute_with_block(connection, request, &block) if block_given?
30
-
31
- response = connection.run(request)
32
- follow_redirect?(response, limit) ? follow_redirect(connection, response, limit) : response
27
+ @default_headers = options.fetch(:headers, DEFAULT_HEADERS).freeze
28
+ configure_pool(options)
29
+ configure_tls(options)
33
30
  end
34
31
 
35
- def get(uri, headers: {}, body: {}, &block)
36
- run(uri, Net::HTTP::Get, headers, body, &block)
32
+ %i[get post put patch delete].each do |method|
33
+ define_method(method) do |uri, headers: {}, body: {}, stream: false, &block|
34
+ run(uri, Net::HTTP.const_get(method.capitalize), headers, body, stream, &block)
35
+ end
37
36
  end
38
37
 
39
- def patch(uri, headers: {}, body: {}, &block)
40
- run(uri, Net::HTTP::Patch, headers, body, &block)
38
+ %i[head options].each do |method|
39
+ define_method(method) do |uri, headers: {}, &block|
40
+ run(uri, Net::HTTP.const_get(method.capitalize), headers, {}, false, &block)
41
+ end
41
42
  end
42
43
 
43
- def post(uri, headers: {}, body: {}, &block)
44
- run(uri, Net::HTTP::Post, headers, body, &block)
45
- end
44
+ def execute(uri, request, limit: follow_redirects, stream: false, &block)
45
+ conn = connection_for(uri)
46
+ return conn.run(request) { |res| res.read_body(&block) } if stream && block
46
47
 
47
- def put(uri, headers: {}, body: {}, &block)
48
- run(uri, Net::HTTP::Put, headers, body, &block)
49
- end
48
+ return execute_with_block(conn, request, &block) if block
50
49
 
51
- def delete(uri, headers: {}, body: {}, &block)
52
- run(uri, Net::HTTP::Delete, headers, body, &block)
50
+ response = conn.run(request)
51
+ limit.positive? && response.is_a?(Net::HTTPRedirection) ? follow_redirect(uri, response, limit) : response
53
52
  end
54
53
 
55
- # attempt 1 -> delay 0.1 second
56
- # attempt 2 -> delay 0.2 second
57
- # attempt 3 -> delay 0.4 second
58
- # attempt 4 -> delay 0.8 second
59
- # attempt 5 -> delay 1.6 second
60
- # attempt 6 -> delay 3.2 second
61
- # attempt 7 -> delay 6.4 second
62
- # attempt 8 -> delay 12.8 second
63
54
  def with_retry(retries: 3)
64
- retries = 0 if retries.nil? || retries.negative?
55
+ retries = [retries.to_i, 0].max
56
+ 0.upto(retries) do |attempt|
57
+ return yield self
58
+ rescue *CONNECTION_ERRORS => error
59
+ raise if attempt == retries
65
60
 
66
- 0.upto(retries) do |n|
67
- attempt(n, retries) do
68
- return yield self
69
- end
61
+ sleep_with_backoff(attempt, retries, error)
70
62
  end
71
63
  end
72
64
 
65
+ def close_all
66
+ @pool.close_all
67
+ @dns_cache.clear
68
+ end
69
+
73
70
  private
74
71
 
75
- attr_reader :default_headers
72
+ def configure_pool(options)
73
+ @dns_ttl = options.fetch(:dns_ttl, 300)
74
+ @dns_cache = DnsCache.new(
75
+ timeout: options.fetch(:dns_timeout, 5), ttl: @dns_ttl, logger: @logger
76
+ )
77
+ @pool = ConnectionPool.new(max_size: options.fetch(:max_connections, 100), dns_ttl: @dns_ttl)
78
+ end
76
79
 
77
- def execute_with_block(connection, request, &block)
78
- block.arity == 2 ? yield(request, connection.run(request)) : connection.run(request, &block)
80
+ def configure_tls(options)
81
+ @tls_cert = parse_cert(options[:certificate])
82
+ @tls_key = parse_key(options[:key], options[:passphrase])
83
+ @continue_timeout = options[:continue_timeout]
84
+ @ignore_eof = options.fetch(:ignore_eof, true)
79
85
  end
80
86
 
81
- def follow_redirect?(response, limit)
82
- limit.positive? && response.is_a?(Net::HTTPRedirection)
87
+ def run(uri, method, headers, body, stream, &block)
88
+ uri = URI.parse(uri.to_s) unless uri.is_a?(URI::Generic)
89
+ execute(uri, build_request(method, uri, headers, body), stream: stream, &block)
83
90
  end
84
91
 
85
- def follow_redirect(connection, response, limit)
86
- url = connection.build_url_for(response['location'])
87
- request = request_for(Net::HTTP::Get, url)
88
- execute(url, request, limit: limit - 1)
92
+ def build_request(type, uri, headers, body)
93
+ merged = headers.empty? ? @default_headers : @default_headers.merge(headers)
94
+ path = uri.respond_to?(:request_uri) ? uri.request_uri : uri.path
95
+ type.new(path, merged).tap { |req| req.body = @mapper.map_from(merged, body) unless body.empty? }
89
96
  end
90
97
 
91
- def attempt(attempt, max)
92
- yield
93
- rescue *CONNECTION_ERRORS => error
94
- raise error if attempt == max
98
+ def execute_with_block(conn, request, &block)
99
+ block.arity == 2 ? yield(request, conn.run(request)) : conn.run(request, &block)
100
+ end
95
101
 
96
- delay = ((2**attempt) * 0.1) + Random.rand(0.05) # delay + jitter
97
- logger&.warn("`#{error.message}` #{attempt + 1}/#{max} Delay: #{delay}s")
98
- sleep delay
102
+ def connection_for(uri)
103
+ uri = URI.parse(uri.to_s) unless uri.is_a?(URI::Generic)
104
+ key = [uri.scheme, uri.host, uri.port]
105
+ @pool.checkout(key) { Connection.new(uri.scheme, uri.host, uri.port, @dns_cache.resolve(uri.host), conn_opts) }
99
106
  end
100
107
 
101
- def request_for(type, uri, headers: {}, body: {})
102
- final_headers = default_headers.merge(headers)
103
- type.new(URI.parse(uri.to_s), final_headers).tap do |x|
104
- x.body = mapper.map_from(final_headers, body) unless body.empty?
105
- end
108
+ def sleep_with_backoff(attempt, max_retries, error)
109
+ delay = ((2**attempt) * 0.1) + JITTER.rand(0.05)
110
+ logger&.warn("[Hippie] #{error.class}: #{error.message} | Retry #{attempt + 1}/#{max_retries}")
111
+ sleep delay
106
112
  end
107
113
 
108
- def run(uri, http_method, headers, body, &block)
109
- request = request_for(http_method, uri, headers: headers, body: body)
110
- execute(uri, request, &block)
114
+ def conn_opts
115
+ @options.merge(
116
+ tls_cert: @tls_cert, tls_key: @tls_key,
117
+ continue_timeout: @continue_timeout, ignore_eof: @ignore_eof
118
+ )
111
119
  end
112
120
 
113
- def connection_for(uri)
114
- uri = URI.parse(uri.to_s)
115
- @connections[[uri.scheme, uri.host, uri.port]]
121
+ def follow_redirect(original_uri, response, limit)
122
+ redirect_uri = original_uri.merge(response['location'])
123
+ execute(redirect_uri, build_request(Net::HTTP::Get, redirect_uri, {}, {}), limit: limit - 1)
116
124
  end
117
125
  end
118
126
  end
@@ -2,27 +2,33 @@
2
2
 
3
3
  module Net
4
4
  module Hippie
5
- # A connection to a specific host
5
+ # Persistent HTTP connection with automatic reconnection.
6
6
  class Connection
7
- def initialize(scheme, host, port, options = {})
8
- http = Net::HTTP.new(host, port)
9
- http.read_timeout = options.fetch(:read_timeout, 10)
10
- http.open_timeout = options.fetch(:open_timeout, 10)
11
- http.use_ssl = scheme == 'https'
12
- http.verify_mode = options.fetch(:verify_mode, Net::Hippie.verify_mode)
13
- http.set_debug_output(options[:logger]) if options[:logger]
14
- apply_client_tls_to(http, options)
15
- @http = http
7
+ RETRYABLE_ERRORS = [EOFError, Errno::ECONNABORTED, Errno::ECONNRESET, Errno::EPIPE, IOError].freeze
8
+
9
+ def initialize(scheme, host, port, ipaddr, options = {})
10
+ @mutex = Mutex.new
11
+ @created_at = Time.now
12
+ @http = build_http(scheme, host, port, ipaddr, options)
16
13
  end
17
14
 
18
15
  def run(request, &block)
19
- if block_given?
20
- @http.request(request, &block)
21
- else
22
- @http.request(request)
16
+ @mutex.synchronize do
17
+ ensure_started
18
+ execute(request, &block)
23
19
  end
24
20
  end
25
21
 
22
+ def stale?(ttl)
23
+ (Time.now - @created_at) > ttl
24
+ end
25
+
26
+ def close
27
+ @mutex.synchronize { @http.finish if @http.started? }
28
+ rescue IOError
29
+ nil
30
+ end
31
+
26
32
  def build_url_for(path)
27
33
  return path if path.start_with?('http')
28
34
 
@@ -31,15 +37,51 @@ module Net
31
37
 
32
38
  private
33
39
 
34
- def apply_client_tls_to(http, options)
35
- return if options[:certificate].nil? || options[:key].nil?
40
+ def build_http(scheme, host, port, ipaddr, options)
41
+ Net::HTTP.new(host, port).tap do |http|
42
+ configure_timeouts(http, options)
43
+ configure_ssl(http, scheme, options)
44
+ configure_tls_client(http, options)
45
+ http.ipaddr = ipaddr if ipaddr
46
+ end
47
+ end
36
48
 
37
- http.cert = OpenSSL::X509::Certificate.new(options[:certificate])
38
- http.key = private_key(options[:key], options[:passphrase])
49
+ def configure_timeouts(http, options)
50
+ http.open_timeout = options.fetch(:open_timeout, 10)
51
+ http.read_timeout = options.fetch(:read_timeout, 10)
52
+ http.write_timeout = options.fetch(:write_timeout, 10)
53
+ http.keep_alive_timeout = options.fetch(:keep_alive_timeout, 30)
54
+ http.max_retries = options.fetch(:max_retries, 1)
55
+ http.continue_timeout = options[:continue_timeout] if options[:continue_timeout]
56
+ http.ignore_eof = options.fetch(:ignore_eof, true)
57
+ end
58
+
59
+ def configure_ssl(http, scheme, options)
60
+ http.use_ssl = scheme == 'https'
61
+ return unless http.use_ssl?
62
+
63
+ http.min_version = options.fetch(:min_version, :TLS1_2)
64
+ http.verify_mode = options.fetch(:verify_mode, Net::Hippie.verify_mode)
65
+ http.set_debug_output(options[:logger]) if options[:logger]
66
+ end
67
+
68
+ def configure_tls_client(http, options)
69
+ http.cert = options[:tls_cert] if options[:tls_cert]
70
+ http.key = options[:tls_key] if options[:tls_key]
39
71
  end
40
72
 
41
- def private_key(key, passphrase, type = OpenSSL::PKey::RSA)
42
- passphrase ? type.new(key, passphrase) : type.new(key)
73
+ def ensure_started
74
+ @http.start unless @http.started?
75
+ end
76
+
77
+ def execute(request, retried: false, &block)
78
+ block ? @http.request(request, &block) : @http.request(request)
79
+ rescue *RETRYABLE_ERRORS
80
+ raise if retried
81
+
82
+ @http.finish if @http.started?
83
+ @http.start
84
+ execute(request, retried: true, &block)
43
85
  end
44
86
  end
45
87
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Hippie
5
+ # Thread-safe connection pool with LRU eviction.
6
+ class ConnectionPool
7
+ def initialize(max_size: 100, dns_ttl: 300)
8
+ @max_size = max_size
9
+ @dns_ttl = dns_ttl
10
+ @connections = {}
11
+ @monitor = Monitor.new
12
+ end
13
+
14
+ def checkout(key, &block)
15
+ reuse(key) || create(key, &block)
16
+ end
17
+
18
+ def close_all
19
+ @monitor.synchronize do
20
+ @connections.each_value(&:close)
21
+ @connections.clear
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def reuse(key)
28
+ @monitor.synchronize do
29
+ return nil unless @connections.key?(key)
30
+
31
+ conn = @connections.delete(key)
32
+ return @connections[key] = conn unless conn.stale?(@dns_ttl)
33
+
34
+ conn.close
35
+ nil
36
+ end
37
+ end
38
+
39
+ def create(key)
40
+ conn = yield
41
+ @monitor.synchronize do
42
+ existing = reuse(key)
43
+ if existing
44
+ conn.close
45
+ return existing
46
+ end
47
+ evict_lru if @connections.size >= @max_size
48
+ @connections[key] = conn
49
+ end
50
+ end
51
+
52
+ def evict_lru
53
+ key, conn = @connections.first
54
+ conn.close
55
+ @connections.delete(key)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Hippie
5
+ # Thread-safe DNS resolution cache with TTL.
6
+ class DnsCache
7
+ def initialize(timeout: 5, ttl: 300, logger: nil)
8
+ @timeout = timeout
9
+ @ttl = ttl
10
+ @logger = logger
11
+ @cache = {}
12
+ @monitor = Monitor.new
13
+ end
14
+
15
+ def resolve(hostname)
16
+ cached = get(hostname)
17
+ return cached if cached
18
+
19
+ ip = Net::Hippie.resolve(hostname, timeout: @timeout)
20
+ set(hostname, ip)
21
+ ip
22
+ rescue Timeout::Error, Resolv::ResolvError => error
23
+ @logger&.warn("[Hippie] DNS resolution failed for #{hostname}: #{error.message}")
24
+ nil
25
+ end
26
+
27
+ def clear
28
+ @monitor.synchronize { @cache.clear }
29
+ end
30
+
31
+ private
32
+
33
+ def get(hostname)
34
+ @monitor.synchronize do
35
+ entry = @cache[hostname]
36
+ entry[:ip] if entry && (Time.now - entry[:time]) < @ttl
37
+ end
38
+ end
39
+
40
+ def set(hostname, ip_addr)
41
+ @monitor.synchronize { @cache[hostname] = { ip: ip_addr, time: Time.now } }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Net
4
+ module Hippie
5
+ # Parses TLS certificates and keys from various formats.
6
+ module TlsParser
7
+ def parse_cert(cert)
8
+ return cert if cert.is_a?(OpenSSL::X509::Certificate) || cert.nil?
9
+
10
+ OpenSSL::X509::Certificate.new(cert)
11
+ end
12
+
13
+ def parse_key(key, passphrase)
14
+ return key if key.is_a?(OpenSSL::PKey::PKey) || key.nil?
15
+
16
+ passphrase ? OpenSSL::PKey::RSA.new(key, passphrase) : OpenSSL::PKey::RSA.new(key)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Net
4
4
  module Hippie
5
- VERSION = '1.4.0'
5
+ VERSION = '1.5.0'
6
6
  end
7
7
  end
data/lib/net/hippie.rb CHANGED
@@ -3,69 +3,83 @@
3
3
  require 'base64'
4
4
  require 'json'
5
5
  require 'logger'
6
+ require 'monitor'
6
7
  require 'net/http'
7
8
  require 'openssl'
9
+ require 'resolv'
10
+ require 'timeout'
8
11
 
9
- require 'net/hippie/version'
10
12
  require 'net/hippie/client'
11
13
  require 'net/hippie/connection'
14
+ require 'net/hippie/connection_pool'
12
15
  require 'net/hippie/content_type_mapper'
16
+ require 'net/hippie/version'
13
17
 
14
18
  module Net
15
- # net/http for hippies.
19
+ # High-performance HTTP client with connection pooling and DNS caching.
16
20
  module Hippie
17
21
  CONNECTION_ERRORS = [
18
22
  EOFError,
19
23
  Errno::ECONNREFUSED,
20
24
  Errno::ECONNRESET,
21
- Errno::ECONNRESET,
22
25
  Errno::EHOSTUNREACH,
23
26
  Errno::EINVAL,
27
+ Errno::EPIPE,
28
+ Errno::ECONNABORTED,
29
+ Errno::ETIMEDOUT,
30
+ IOError,
24
31
  Net::OpenTimeout,
25
32
  Net::ProtocolError,
26
33
  Net::ReadTimeout,
34
+ Net::WriteTimeout,
27
35
  OpenSSL::OpenSSLError,
28
36
  OpenSSL::SSL::SSLError,
29
37
  SocketError,
30
38
  Timeout::Error
31
39
  ].freeze
32
40
 
33
- def self.logger
34
- @logger ||= Logger.new(nil)
35
- end
41
+ BASIC_PREFIX = 'Basic '
42
+ BEARER_PREFIX = 'Bearer '
36
43
 
37
- def self.logger=(logger)
38
- @logger = logger
39
- end
44
+ class << self
45
+ attr_writer :logger, :verify_mode
40
46
 
41
- def self.verify_mode
42
- @verify_mode ||= OpenSSL::SSL::VERIFY_PEER
43
- end
47
+ def logger
48
+ @logger ||= Logger.new(nil)
49
+ end
44
50
 
45
- def self.verify_mode=(mode)
46
- @verify_mode = mode
47
- end
51
+ def verify_mode
52
+ @verify_mode ||= OpenSSL::SSL::VERIFY_PEER
53
+ end
48
54
 
49
- def self.basic_auth(username, password)
50
- "Basic #{::Base64.strict_encode64("#{username}:#{password}")}"
51
- end
55
+ def basic_auth(username, password)
56
+ BASIC_PREFIX + ::Base64.strict_encode64("#{username}:#{password}")
57
+ end
52
58
 
53
- def self.bearer_auth(token)
54
- "Bearer #{token}"
55
- end
59
+ def bearer_auth(token)
60
+ BEARER_PREFIX + token
61
+ end
56
62
 
57
- def self.method_missing(symbol, *args)
58
- default_client.with_retry(retries: 3) do |client|
59
- client.public_send(symbol, *args)
60
- end || super
61
- end
63
+ def default_client
64
+ @default_client ||= Client.new(follow_redirects: 3, logger: logger)
65
+ end
62
66
 
63
- def self.respond_to_missing?(name, _include_private = false)
64
- Client.public_instance_methods.include?(name.to_sym) || super
65
- end
67
+ def reset_default_client!
68
+ @default_client = nil
69
+ end
70
+
71
+ %i[get post put patch delete head options].each do |method|
72
+ define_method(method) do |*args, **kwargs, &block|
73
+ default_client.with_retry(retries: 3) { |c| c.public_send(method, *args, **kwargs, &block) }
74
+ end
75
+ end
66
76
 
67
- def self.default_client
68
- @default_client ||= Client.new(follow_redirects: 3, logger: logger)
77
+ def resolve(hostname, timeout: 5)
78
+ Timeout.timeout(timeout) do
79
+ addresses = Resolv.getaddresses(hostname)
80
+ addresses.find { |a| a.match?(/^\d+\.\d+\.\d+\.\d+$/) } || addresses.first
81
+ end
82
+ end
69
83
  end
70
84
  end
71
85
  end
data/net-hippie.gemspec CHANGED
@@ -7,17 +7,18 @@ require 'net/hippie/version'
7
7
  Gem::Specification.new do |spec|
8
8
  spec.name = 'net-hippie'
9
9
  spec.version = Net::Hippie::VERSION
10
- spec.authors = ['mo']
10
+ spec.authors = ['mo khan']
11
11
  spec.email = ['mo@mokhan.ca']
12
12
 
13
13
  spec.summary = 'net/http for hippies. ☮️ '
14
14
  spec.description = 'net/http for hippies. ☮️ '
15
- spec.homepage = 'https://rubygems.org/gems/net-hippie'
15
+ spec.homepage = 'https://src.mokhan.ca/xlgmokha/net-hippie'
16
16
  spec.license = 'MIT'
17
- spec.metadata = {
18
- 'source_code_uri' => 'https://github.com/xlgmokha/net-hippie',
19
- 'rubygems_mfa_required' => 'true'
20
- }
17
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
18
+ spec.metadata['homepage_uri'] = spec.homepage
19
+ spec.metadata['source_code_uri'] = 'https://git.mokhan.ca/xlgmokha/net-hippie.git'
20
+ spec.metadata['changelog_uri'] = 'https://src.mokhan.ca/xlgmokha/net-hippie/blob/main/CHANGELOG.md.html'
21
+ spec.metadata['rubygems_mfa_required'] = 'true'
21
22
 
22
23
  spec.files = `git ls-files -z`.split("\x0").reject do |f|
23
24
  f.match(%r{^(test|spec|features)/})
@@ -25,16 +26,21 @@ Gem::Specification.new do |spec|
25
26
  spec.bindir = 'exe'
26
27
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
27
28
  spec.require_paths = ['lib']
28
- spec.required_ruby_version = Gem::Requirement.new('>= 3.2.0')
29
+ spec.required_ruby_version = Gem::Requirement.new('>= 4.0.0')
29
30
 
30
31
  spec.add_dependency 'base64', '~> 0.1'
31
32
  spec.add_dependency 'json', '~> 2.0'
32
33
  spec.add_dependency 'logger', '~> 1.0'
33
- spec.add_dependency 'net-http', '~> 0.6'
34
- spec.add_dependency 'openssl', '~> 3.0'
35
- spec.add_development_dependency 'minitest', '~> 5.0'
34
+ spec.add_dependency 'monitor', '~> 0.1'
35
+ spec.add_dependency 'net-http', '~> 0.1'
36
+ spec.add_dependency 'openssl', '~> 4.0'
37
+ spec.add_dependency 'resolv', '~> 0.1'
38
+ spec.add_dependency 'timeout', '~> 0.1'
39
+ spec.add_development_dependency 'minitest', '~> 6.0'
36
40
  spec.add_development_dependency 'rake', '~> 13.0'
37
41
  spec.add_development_dependency 'rubocop', '~> 1.9'
42
+ spec.add_development_dependency 'rubocop-minitest', '~> 0.1'
43
+ spec.add_development_dependency 'rubocop-rake', '~> 0.1'
38
44
  spec.add_development_dependency 'vcr', '~> 6.0'
39
45
  spec.add_development_dependency 'webmock', '~> 3.4'
40
46
  end
@@ -0,0 +1,25 @@
1
+ module Net
2
+ module Hippie
3
+ class Client
4
+ DEFAULT_HEADERS: Hash[String, String]
5
+
6
+ attr_reader mapper: ContentTypeMapper
7
+ attr_reader logger: untyped
8
+ attr_reader follow_redirects: Integer
9
+
10
+ def initialize: (?Hash[Symbol, untyped] options) -> void
11
+
12
+ def get: (untyped uri, ?headers: Hash[String, String], ?body: untyped, ?stream: bool) ?{ (untyped) -> void } -> untyped
13
+ def post: (untyped uri, ?headers: Hash[String, String], ?body: untyped, ?stream: bool) ?{ (untyped) -> void } -> untyped
14
+ def put: (untyped uri, ?headers: Hash[String, String], ?body: untyped, ?stream: bool) ?{ (untyped) -> void } -> untyped
15
+ def patch: (untyped uri, ?headers: Hash[String, String], ?body: untyped, ?stream: bool) ?{ (untyped) -> void } -> untyped
16
+ def delete: (untyped uri, ?headers: Hash[String, String], ?body: untyped, ?stream: bool) ?{ (untyped) -> void } -> untyped
17
+ def head: (untyped uri, ?headers: Hash[String, String]) ?{ (untyped) -> void } -> untyped
18
+ def options: (untyped uri, ?headers: Hash[String, String]) ?{ (untyped) -> void } -> untyped
19
+
20
+ def execute: (untyped uri, untyped request, ?limit: Integer, ?stream: bool) ?{ (untyped) -> void } -> untyped
21
+ def with_retry: (?retries: Integer) { (Client) -> untyped } -> untyped
22
+ def close_all: () -> void
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ module Net
2
+ module Hippie
3
+ class ContentTypeMapper
4
+ def map_from: (Hash[String, String] headers, (Hash[untyped, untyped] | String) body) -> (String | Hash[untyped, untyped])
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,29 @@
1
+ module Net
2
+ module Hippie
3
+ VERSION: String
4
+
5
+ CONNECTION_ERRORS: Array[Class]
6
+
7
+ def self.logger: () -> untyped
8
+ def self.logger=: (untyped) -> untyped
9
+
10
+ def self.verify_mode: () -> Integer
11
+ def self.verify_mode=: (Integer) -> Integer
12
+
13
+ def self.basic_auth: (String username, String password) -> String
14
+ def self.bearer_auth: (String token) -> String
15
+
16
+ def self.default_client: () -> Client
17
+ def self.reset_default_client!: () -> nil
18
+
19
+ def self.resolve: (String hostname, ?timeout: Integer) -> String?
20
+
21
+ def self.get: (untyped uri, ?headers: Hash[String, String], ?body: untyped, ?stream: bool) ?{ (untyped) -> void } -> untyped
22
+ def self.post: (untyped uri, ?headers: Hash[String, String], ?body: untyped, ?stream: bool) ?{ (untyped) -> void } -> untyped
23
+ def self.put: (untyped uri, ?headers: Hash[String, String], ?body: untyped, ?stream: bool) ?{ (untyped) -> void } -> untyped
24
+ def self.patch: (untyped uri, ?headers: Hash[String, String], ?body: untyped, ?stream: bool) ?{ (untyped) -> void } -> untyped
25
+ def self.delete: (untyped uri, ?headers: Hash[String, String], ?body: untyped, ?stream: bool) ?{ (untyped) -> void } -> untyped
26
+ def self.head: (untyped uri, ?headers: Hash[String, String]) ?{ (untyped) -> void } -> untyped
27
+ def self.options: (untyped uri, ?headers: Hash[String, String]) ?{ (untyped) -> void } -> untyped
28
+ end
29
+ end
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: net-hippie
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
- - mo
7
+ - mo khan
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
@@ -51,48 +51,90 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '1.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: monitor
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.1'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.1'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: net-http
56
70
  requirement: !ruby/object:Gem::Requirement
57
71
  requirements:
58
72
  - - "~>"
59
73
  - !ruby/object:Gem::Version
60
- version: '0.6'
74
+ version: '0.1'
61
75
  type: :runtime
62
76
  prerelease: false
63
77
  version_requirements: !ruby/object:Gem::Requirement
64
78
  requirements:
65
79
  - - "~>"
66
80
  - !ruby/object:Gem::Version
67
- version: '0.6'
81
+ version: '0.1'
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: openssl
70
84
  requirement: !ruby/object:Gem::Requirement
71
85
  requirements:
72
86
  - - "~>"
73
87
  - !ruby/object:Gem::Version
74
- version: '3.0'
88
+ version: '4.0'
75
89
  type: :runtime
76
90
  prerelease: false
77
91
  version_requirements: !ruby/object:Gem::Requirement
78
92
  requirements:
79
93
  - - "~>"
80
94
  - !ruby/object:Gem::Version
81
- version: '3.0'
95
+ version: '4.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: resolv
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.1'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.1'
110
+ - !ruby/object:Gem::Dependency
111
+ name: timeout
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '0.1'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '0.1'
82
124
  - !ruby/object:Gem::Dependency
83
125
  name: minitest
84
126
  requirement: !ruby/object:Gem::Requirement
85
127
  requirements:
86
128
  - - "~>"
87
129
  - !ruby/object:Gem::Version
88
- version: '5.0'
130
+ version: '6.0'
89
131
  type: :development
90
132
  prerelease: false
91
133
  version_requirements: !ruby/object:Gem::Requirement
92
134
  requirements:
93
135
  - - "~>"
94
136
  - !ruby/object:Gem::Version
95
- version: '5.0'
137
+ version: '6.0'
96
138
  - !ruby/object:Gem::Dependency
97
139
  name: rake
98
140
  requirement: !ruby/object:Gem::Requirement
@@ -121,6 +163,34 @@ dependencies:
121
163
  - - "~>"
122
164
  - !ruby/object:Gem::Version
123
165
  version: '1.9'
166
+ - !ruby/object:Gem::Dependency
167
+ name: rubocop-minitest
168
+ requirement: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - "~>"
171
+ - !ruby/object:Gem::Version
172
+ version: '0.1'
173
+ type: :development
174
+ prerelease: false
175
+ version_requirements: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - "~>"
178
+ - !ruby/object:Gem::Version
179
+ version: '0.1'
180
+ - !ruby/object:Gem::Dependency
181
+ name: rubocop-rake
182
+ requirement: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - "~>"
185
+ - !ruby/object:Gem::Version
186
+ version: '0.1'
187
+ type: :development
188
+ prerelease: false
189
+ version_requirements: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - "~>"
192
+ - !ruby/object:Gem::Version
193
+ version: '0.1'
124
194
  - !ruby/object:Gem::Dependency
125
195
  name: vcr
126
196
  requirement: !ruby/object:Gem::Requirement
@@ -157,8 +227,6 @@ executables:
157
227
  extensions: []
158
228
  extra_rdoc_files: []
159
229
  files:
160
- - ".github/dependabot.yml"
161
- - ".github/workflows/ci.yml"
162
230
  - ".gitignore"
163
231
  - ".rubocop.yml"
164
232
  - CHANGELOG.md
@@ -175,14 +243,23 @@ files:
175
243
  - lib/net/hippie.rb
176
244
  - lib/net/hippie/client.rb
177
245
  - lib/net/hippie/connection.rb
246
+ - lib/net/hippie/connection_pool.rb
178
247
  - lib/net/hippie/content_type_mapper.rb
248
+ - lib/net/hippie/dns_cache.rb
249
+ - lib/net/hippie/tls_parser.rb
179
250
  - lib/net/hippie/version.rb
180
251
  - net-hippie.gemspec
181
- homepage: https://rubygems.org/gems/net-hippie
252
+ - sig/net/hippie.rbs
253
+ - sig/net/hippie/client.rbs
254
+ - sig/net/hippie/content_type_mapper.rbs
255
+ homepage: https://src.mokhan.ca/xlgmokha/net-hippie
182
256
  licenses:
183
257
  - MIT
184
258
  metadata:
185
- source_code_uri: https://github.com/xlgmokha/net-hippie
259
+ allowed_push_host: https://rubygems.org
260
+ homepage_uri: https://src.mokhan.ca/xlgmokha/net-hippie
261
+ source_code_uri: https://git.mokhan.ca/xlgmokha/net-hippie.git
262
+ changelog_uri: https://src.mokhan.ca/xlgmokha/net-hippie/blob/main/CHANGELOG.md.html
186
263
  rubygems_mfa_required: 'true'
187
264
  rdoc_options: []
188
265
  require_paths:
@@ -191,14 +268,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
191
268
  requirements:
192
269
  - - ">="
193
270
  - !ruby/object:Gem::Version
194
- version: 3.2.0
271
+ version: 4.0.0
195
272
  required_rubygems_version: !ruby/object:Gem::Requirement
196
273
  requirements:
197
274
  - - ">="
198
275
  - !ruby/object:Gem::Version
199
276
  version: '0'
200
277
  requirements: []
201
- rubygems_version: 3.7.2
278
+ rubygems_version: 4.0.5
202
279
  specification_version: 4
203
280
  summary: net/http for hippies. ☮️
204
281
  test_files: []
@@ -1,9 +0,0 @@
1
- ---
2
- version: 2
3
- updates:
4
- - package-ecosystem: "bundler"
5
- directory: "/"
6
- schedule:
7
- interval: "daily"
8
- assignees:
9
- - "xlgmokha"
@@ -1,31 +0,0 @@
1
- name: ci
2
- on:
3
- push:
4
- branches: [main]
5
- pull_request:
6
- branches: [main]
7
- jobs:
8
- test:
9
- runs-on: ubuntu-latest
10
- strategy:
11
- matrix:
12
- ruby-version: ['3.2', '3.3', '3.4']
13
- steps:
14
- - uses: actions/checkout@v2
15
- - name: Set up Ruby
16
- uses: ruby/setup-ruby@v1
17
- with:
18
- ruby-version: ${{ matrix.ruby-version }}
19
- bundler-cache: true
20
- - name: Running tests…
21
- run: sh bin/test
22
- style:
23
- runs-on: ubuntu-latest
24
- steps:
25
- - uses: actions/checkout@v2
26
- - uses: ruby/setup-ruby@v1
27
- with:
28
- ruby-version: '3.4'
29
- bundler-cache: true
30
- - name: Running style checks…
31
- run: sh bin/style