From 53f17cfbd4e6aaa1eeb3eb4f55234debcdeb814f Mon Sep 17 00:00:00 2001 From: Leo-Paul Goffic Date: Wed, 26 Aug 2026 18:00:06 +0200 Subject: [PATCH 1/2] feat(adapters): support explicit proxy configuration Add a `proxy` client option that is threaded through to the Faraday, httpx, and net/http adapters. Passing a URI string forces a specific proxy; passing `false` disables proxying (including env-based auto-detection) for a fixed, trusted `base_url`. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01T1xdiN28Drqsrh3rET88kW --- README.md | 1 + lib/altertable/adapters.rb | 42 +++++++++++++++---- lib/altertable/client.rb | 2 +- rbi/altertable.rbi | 17 +++++--- sig/altertable/adapters.rbs | 11 +++-- spec/adapter_selection_spec.rb | 8 ++++ .../adapters/faraday_adapter_spec.rb | 34 +++++++++++++++ .../altertable/adapters/httpx_adapter_spec.rb | 28 +++++++++++++ .../adapters/net_http_adapter_spec.rb | 25 +++++++++++ 9 files changed, 149 insertions(+), 19 deletions(-) create mode 100644 spec/altertable/adapters/faraday_adapter_spec.rb create mode 100644 spec/altertable/adapters/httpx_adapter_spec.rb create mode 100644 spec/altertable/adapters/net_http_adapter_spec.rb diff --git a/README.md b/README.md index 62b28a6..0341418 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ You can configure the client by passing options during initialization. | `debug` | Boolean | `false` | Enable debug logging. | | `on_error` | Proc | `nil` | Callback for handling errors. | | `adapter` | Symbol | auto-detect | HTTP adapter to use (`:faraday`, `:httpx`, `:net_http`). | +| `proxy` | String, Boolean | adapter default | Proxy to use for requests. Pass a URI string to force a specific proxy, or `false` to disable proxying (including any `HTTP_PROXY`/`HTTPS_PROXY` environment variables) for a fixed, trusted `base_url`. Unset by default: the `:faraday` and `:net_http` adapters auto-detect a proxy from the environment as they normally would; `:httpx` never does. | ## License diff --git a/lib/altertable/adapters.rb b/lib/altertable/adapters.rb index c5c88be..4ea6fff 100644 --- a/lib/altertable/adapters.rb +++ b/lib/altertable/adapters.rb @@ -15,13 +15,17 @@ def post(path, body: nil, params: {}, &block) end class FaradayAdapter < Base - def initialize(base_url:, timeout:, headers: {}) - super + # proxy left unset (default) follows Faraday's own env-based auto-detection + # (HTTP_PROXY/HTTPS_PROXY/NO_PROXY). Pass `false` to disable proxying entirely for + # fixed, trusted destinations, or a URI string to force a specific proxy. + def initialize(base_url:, timeout:, headers: {}, proxy: nil) + super(base_url: base_url, timeout: timeout, headers: headers) require "faraday" - + @conn = Faraday.new(url: @base_url) do |f| @headers.each { |k, v| f.headers[k] = v } f.options.timeout = @timeout + f.proxy = proxy unless proxy.nil? f.adapter Faraday.default_adapter end end @@ -46,10 +50,14 @@ def wrap_response(resp) end class HttpxAdapter < Base - def initialize(base_url:, timeout:, headers: {}) - super + # This adapter never auto-detects HTTP_PROXY/HTTPS_PROXY (httpx requires the :proxy + # plugin to be loaded explicitly). `proxy: false` and the default (unset) are + # therefore equivalent; pass a URI string to force a specific proxy. + def initialize(base_url:, timeout:, headers: {}, proxy: nil) + super(base_url: base_url, timeout: timeout, headers: headers) require "httpx" - @client = HTTPX.plugin(:retries).with( + client = proxy ? HTTPX.plugin(:proxy).plugin(:retries).with(proxy: { uri: proxy }) : HTTPX.plugin(:retries) + @client = client.with( timeout: { operation_timeout: @timeout }, headers: @headers, base_url: @base_url @@ -74,11 +82,15 @@ def wrap_response(resp) end class NetHttpAdapter < Base - def initialize(base_url:, timeout:, headers: {}) - super + # proxy left unset (default) follows Net::HTTP's own env-based auto-detection + # (HTTP_PROXY/HTTPS_PROXY/NO_PROXY). Pass `false` to disable proxying entirely for + # fixed, trusted destinations, or a URI string to force a specific proxy. + def initialize(base_url:, timeout:, headers: {}, proxy: nil) + super(base_url: base_url, timeout: timeout, headers: headers) require "net/http" require "uri" @uri = URI.parse(@base_url) + @proxy = proxy end def post(path, body: nil, params: {}) @@ -89,7 +101,7 @@ def post(path, body: nil, params: {}) @headers.each { |k, v| req[k] = v } req.body = body if body - Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout, read_timeout: @timeout) do |http| + Net::HTTP.start(uri.host, uri.port, *proxy_start_args, use_ssl: uri.scheme == "https", open_timeout: @timeout, read_timeout: @timeout) do |http| resp = http.request(req) Response.new(resp.code.to_i, resp.body, resp.to_hash) end @@ -98,6 +110,18 @@ def post(path, body: nil, params: {}) rescue StandardError => e raise Altertable::NetworkError.new(e.message, e) end + + private + + # Net::HTTP.start's p_addr defaults to :ENV; returning [] here preserves that. + # Passing an explicit nil p_addr (i.e. [nil]) is how Net::HTTP disables proxying. + def proxy_start_args + return [] if @proxy.nil? + return [nil] if @proxy == false + + proxy_uri = URI.parse(@proxy) + [proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password] + end end end end diff --git a/lib/altertable/client.rb b/lib/altertable/client.rb index e0dd91c..7ff285d 100644 --- a/lib/altertable/client.rb +++ b/lib/altertable/client.rb @@ -28,7 +28,7 @@ def initialize(api_key, options = {}) "X-API-Key" => @api_key, "Content-Type" => "application/json" } - @adapter = select_adapter(adapter_name, { base_url: @base_url, timeout: @timeout, headers: headers }) + @adapter = select_adapter(adapter_name, { base_url: @base_url, timeout: @timeout, headers: headers, proxy: options[:proxy] }) end def track(event, distinct_id, **options) diff --git a/rbi/altertable.rbi b/rbi/altertable.rbi index 3496657..98a139f 100644 --- a/rbi/altertable.rbi +++ b/rbi/altertable.rbi @@ -82,8 +82,8 @@ module Altertable end class FaradayAdapter < Base - sig { params(base_url: String, timeout: T.any(Integer, Float), headers: T.nilable(T::Hash[String, String])).void } - def initialize(base_url:, timeout:, headers: nil); end + sig { params(base_url: String, timeout: T.any(Integer, Float), headers: T.nilable(T::Hash[String, String]), proxy: T.nilable(T.any(String, T::Boolean))).void } + def initialize(base_url:, timeout:, headers: nil, proxy: nil); end sig { params(path: String, body: T.nilable(String), params: T.nilable(T::Hash[T.any(Symbol, String), T.untyped]), block: T.nilable(T.proc.params(arg0: T.untyped).void)).returns(Altertable::Adapters::Response) } def post(path, body: nil, params: nil, &block); end @@ -95,8 +95,8 @@ module Altertable end class HttpxAdapter < Base - sig { params(base_url: String, timeout: T.any(Integer, Float), headers: T.nilable(T::Hash[String, String])).void } - def initialize(base_url:, timeout:, headers: nil); end + sig { params(base_url: String, timeout: T.any(Integer, Float), headers: T.nilable(T::Hash[String, String]), proxy: T.nilable(T.any(String, T::Boolean))).void } + def initialize(base_url:, timeout:, headers: nil, proxy: nil); end sig { params(path: String, body: T.nilable(String), params: T.nilable(T::Hash[T.any(Symbol, String), T.untyped]), block: T.nilable(T.proc.params(arg0: T.untyped).void)).returns(Altertable::Adapters::Response) } def post(path, body: nil, params: nil, &block); end @@ -108,11 +108,16 @@ module Altertable end class NetHttpAdapter < Base - sig { params(base_url: String, timeout: T.any(Integer, Float), headers: T.nilable(T::Hash[String, String])).void } - def initialize(base_url:, timeout:, headers: nil); end + sig { params(base_url: String, timeout: T.any(Integer, Float), headers: T.nilable(T::Hash[String, String]), proxy: T.nilable(T.any(String, T::Boolean))).void } + def initialize(base_url:, timeout:, headers: nil, proxy: nil); end sig { params(path: String, body: T.nilable(String), params: T.nilable(T::Hash[T.any(Symbol, String), T.untyped]), block: T.nilable(T.proc.params(arg0: T.untyped).void)).returns(Altertable::Adapters::Response) } def post(path, body: nil, params: nil, &block); end + + private + + sig { returns(T::Array[T.untyped]) } + def proxy_start_args; end end end diff --git a/sig/altertable/adapters.rbs b/sig/altertable/adapters.rbs index e37188c..bb6fde6 100644 --- a/sig/altertable/adapters.rbs +++ b/sig/altertable/adapters.rbs @@ -19,7 +19,7 @@ module Altertable class FaradayAdapter < Base @conn: untyped - def initialize: (base_url: String, timeout: Integer | Float, ?headers: ::Hash[String, String]) -> void + def initialize: (base_url: String, timeout: Integer | Float, ?headers: ::Hash[String, String], ?proxy: (String | bool)?) -> void def post: (String path, ?body: String?, ?params: ::Hash[Symbol | String, untyped]) -> Response private @@ -30,7 +30,7 @@ module Altertable class HttpxAdapter < Base @client: untyped - def initialize: (base_url: String, timeout: Integer | Float, ?headers: ::Hash[String, String]) -> void + def initialize: (base_url: String, timeout: Integer | Float, ?headers: ::Hash[String, String], ?proxy: (String | bool)?) -> void def post: (String path, ?body: String?, ?params: ::Hash[Symbol | String, untyped]) -> Response private @@ -40,9 +40,14 @@ module Altertable class NetHttpAdapter < Base @uri: URI::HTTP | URI::HTTPS + @proxy: (String | bool)? - def initialize: (base_url: String, timeout: Integer | Float, ?headers: ::Hash[String, String]) -> void + def initialize: (base_url: String, timeout: Integer | Float, ?headers: ::Hash[String, String], ?proxy: (String | bool)?) -> void def post: (String path, ?body: String?, ?params: ::Hash[Symbol | String, untyped]) -> Response + + private + + def proxy_start_args: () -> ::Array[untyped] end end end diff --git a/spec/adapter_selection_spec.rb b/spec/adapter_selection_spec.rb index 81f22a9..4380973 100644 --- a/spec/adapter_selection_spec.rb +++ b/spec/adapter_selection_spec.rb @@ -26,5 +26,13 @@ expect(adapter).to be_a(Altertable::Adapters::NetHttpAdapter) end end + + context "when proxy is provided" do + it "threads proxy: false through to the adapter" do + client = described_class.new(api_key, base_url: base_url, adapter: :net_http, proxy: false) + adapter = client.instance_variable_get(:@adapter) + expect(adapter.send(:proxy_start_args)).to eq([nil]) + end + end end end diff --git a/spec/altertable/adapters/faraday_adapter_spec.rb b/spec/altertable/adapters/faraday_adapter_spec.rb new file mode 100644 index 0000000..5a4f28a --- /dev/null +++ b/spec/altertable/adapters/faraday_adapter_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Altertable::Adapters::FaradayAdapter do + let(:base_url) { "https://api.test.local" } + let(:timeout) { 5 } + + describe "proxy configuration" do + it "leaves Faraday's own env-based auto-detection in place by default" do + adapter = described_class.new(base_url: base_url, timeout: timeout) + conn = adapter.instance_variable_get(:@conn) + expect(conn.instance_variable_get(:@manual_proxy)).to be false + end + + it "disables Faraday's env-based auto-detection when proxy: false is given" do + adapter = described_class.new(base_url: base_url, timeout: timeout, proxy: false) + conn = adapter.instance_variable_get(:@conn) + expect(conn.instance_variable_get(:@manual_proxy)).to be true + end + + it "has no proxy configured when proxy: false is given" do + adapter = described_class.new(base_url: base_url, timeout: timeout, proxy: false) + conn = adapter.instance_variable_get(:@conn) + expect(conn.proxy).to be_nil + end + + it "uses an explicit proxy when a URI string is given" do + adapter = described_class.new(base_url: base_url, timeout: timeout, proxy: "http://proxy.local:8080") + conn = adapter.instance_variable_get(:@conn) + expect(conn.proxy.uri.host).to eq("proxy.local") + end + end +end diff --git a/spec/altertable/adapters/httpx_adapter_spec.rb b/spec/altertable/adapters/httpx_adapter_spec.rb new file mode 100644 index 0000000..d3a4adf --- /dev/null +++ b/spec/altertable/adapters/httpx_adapter_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Altertable::Adapters::HttpxAdapter do + let(:base_url) { "https://api.test.local" } + let(:timeout) { 5 } + + describe "proxy configuration" do + it "does not proxy by default (httpx never auto-detects env proxies)" do + adapter = described_class.new(base_url: base_url, timeout: timeout) + client = adapter.instance_variable_get(:@client) + expect(client.class.to_s).not_to include("Plugins::Proxy") + end + + it "does not proxy when proxy: false is given" do + adapter = described_class.new(base_url: base_url, timeout: timeout, proxy: false) + client = adapter.instance_variable_get(:@client) + expect(client.class.to_s).not_to include("Plugins::Proxy") + end + + it "uses an explicit proxy when a URI string is given" do + adapter = described_class.new(base_url: base_url, timeout: timeout, proxy: "http://proxy.local:8080") + client = adapter.instance_variable_get(:@client) + expect(client.class.to_s).to include("Plugins::Proxy") + end + end +end diff --git a/spec/altertable/adapters/net_http_adapter_spec.rb b/spec/altertable/adapters/net_http_adapter_spec.rb new file mode 100644 index 0000000..81ccc88 --- /dev/null +++ b/spec/altertable/adapters/net_http_adapter_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Altertable::Adapters::NetHttpAdapter do + let(:base_url) { "https://api.test.local" } + let(:timeout) { 5 } + + describe "proxy configuration" do + it "leaves Net::HTTP's own env-based auto-detection in place by default" do + adapter = described_class.new(base_url: base_url, timeout: timeout) + expect(adapter.send(:proxy_start_args)).to eq([]) + end + + it "disables proxying when proxy: false is given" do + adapter = described_class.new(base_url: base_url, timeout: timeout, proxy: false) + expect(adapter.send(:proxy_start_args)).to eq([nil]) + end + + it "uses an explicit proxy when a URI string is given" do + adapter = described_class.new(base_url: base_url, timeout: timeout, proxy: "http://user:pass@proxy.local:8080") + expect(adapter.send(:proxy_start_args)).to eq(["proxy.local", 8080, "user", "pass"]) + end + end +end From 583e8e5e9246870ab87f040fa670143814552d83 Mon Sep 17 00:00:00 2001 From: Leo-Paul Goffic Date: Thu, 27 Aug 2026 09:13:15 +0200 Subject: [PATCH 2/2] fix(spec): assert httpx proxy config via options, not class name HTTPX's dynamically generated plugin classes only get a readable to_s via Class#set_temporary_name, which is Ruby >= 3.4 only. On Ruby 3.2/3.3 the class stays anonymous, so the proxy spec's `client.class.to_s.include?("Plugins::Proxy")` assertion always failed there, breaking CI. Check the client's actual proxy option instead, which is version-independent. --- spec/altertable/adapters/httpx_adapter_spec.rb | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/spec/altertable/adapters/httpx_adapter_spec.rb b/spec/altertable/adapters/httpx_adapter_spec.rb index d3a4adf..38ce718 100644 --- a/spec/altertable/adapters/httpx_adapter_spec.rb +++ b/spec/altertable/adapters/httpx_adapter_spec.rb @@ -7,22 +7,28 @@ let(:timeout) { 5 } describe "proxy configuration" do + # Introspects the httpx client's actual proxy option rather than the class name: + # httpx's plugin system only gives its dynamically generated classes readable + # names on Ruby >= 3.4 (via Class#set_temporary_name), so `client.class.to_s` + # is anonymous (e.g. "#") on older Rubies and can't be asserted on. + def proxy_option(adapter) + options = adapter.instance_variable_get(:@client).instance_variable_get(:@options) + options.proxy if options.respond_to?(:proxy) + end + it "does not proxy by default (httpx never auto-detects env proxies)" do adapter = described_class.new(base_url: base_url, timeout: timeout) - client = adapter.instance_variable_get(:@client) - expect(client.class.to_s).not_to include("Plugins::Proxy") + expect(proxy_option(adapter)).to be_nil end it "does not proxy when proxy: false is given" do adapter = described_class.new(base_url: base_url, timeout: timeout, proxy: false) - client = adapter.instance_variable_get(:@client) - expect(client.class.to_s).not_to include("Plugins::Proxy") + expect(proxy_option(adapter)).to be_nil end it "uses an explicit proxy when a URI string is given" do adapter = described_class.new(base_url: base_url, timeout: timeout, proxy: "http://proxy.local:8080") - client = adapter.instance_variable_get(:@client) - expect(client.class.to_s).to include("Plugins::Proxy") + expect(proxy_option(adapter).uri.to_s).to eq("http://proxy.local:8080") end end end