diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dcd9ce..5591197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## [Unreleased] +## [0.3.1] - 2026-08-25 + +- Preserve error response bodies: replace the http gem's raise_error with + flushed_raise_error, which reads the body before raising (fixes #8) + ## [0.3.0] - 2026-08-25 - Require http ~> 6.0 @@ -24,7 +29,8 @@ - OpenAPI-based code generator - RBS type signatures -[Unreleased]: https://github.com/lineofflight/amazon-ads-ruby/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/lineofflight/amazon-ads-ruby/compare/v0.3.1...HEAD +[0.3.1]: https://github.com/lineofflight/amazon-ads-ruby/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/lineofflight/amazon-ads-ruby/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/lineofflight/amazon-ads-ruby/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/lineofflight/amazon-ads-ruby/releases/tag/v0.1.0 diff --git a/lib/amazon_ads.rb b/lib/amazon_ads.rb index 3641c1f..2a60d7d 100644 --- a/lib/amazon_ads.rb +++ b/lib/amazon_ads.rb @@ -6,7 +6,9 @@ loader.inflector.inflect("api" => "API", "lwa" => "LWA") loader.collapse("#{__dir__}/amazon_ads/apis") loader.ignore("#{__dir__}/generator") +loader.ignore("#{__dir__}/amazon_ads/flushed_raise_error.rb") loader.setup +require_relative "amazon_ads/flushed_raise_error" # Amazon Ads API client for Ruby module AmazonAds diff --git a/lib/amazon_ads/api.rb b/lib/amazon_ads/api.rb index 3ed5cc5..9b9a049 100644 --- a/lib/amazon_ads/api.rb +++ b/lib/amazon_ads/api.rb @@ -50,10 +50,10 @@ def http .use(:auto_inflate) if retries.zero? - client.use(:raise_error) + client.use(:flushed_raise_error) else client - .use(raise_error: { ignore: [429] }) + .use(flushed_raise_error: { ignore: [429] }) .retriable(tries: retries + 1, retry_statuses: [429]) end end diff --git a/lib/amazon_ads/flushed_raise_error.rb b/lib/amazon_ads/flushed_raise_error.rb new file mode 100644 index 0000000..82faca1 --- /dev/null +++ b/lib/amazon_ads/flushed_raise_error.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +# rbs_inline: enabled + +require "http" + +module AmazonAds + # Drop-in replacement for the http gem's raise_error feature that reads + # the response body before raising. HTTP::Client#perform closes the + # connection when a feature raises, so with the stock feature the body of + # an error response is lost by the time callers rescue HTTP::StatusError. + class FlushedRaiseError < HTTP::Feature + # Status codes to pass through without raising + attr_reader :ignore #: Array[Integer] + + #: (?ignore: Array[Integer]) -> void + def initialize(ignore: []) + super() + @ignore = ignore + end + + #: (HTTP::Response) -> HTTP::Response + def wrap_response(response) + return response if response.code < 400 + return response if ignore.include?(response.code) + + response.flush # memoize the body before Client#perform closes the connection + raise HTTP::StatusError, response + end + + HTTP::Options.register_feature(:flushed_raise_error, self) + end +end diff --git a/lib/amazon_ads/lwa.rb b/lib/amazon_ads/lwa.rb index bd5c722..1326e0f 100644 --- a/lib/amazon_ads/lwa.rb +++ b/lib/amazon_ads/lwa.rb @@ -33,7 +33,7 @@ def request #: () -> HTTP::Client def http - @http.use(:raise_error) + @http.use(:flushed_raise_error) end #: () -> Hash[Symbol, String] diff --git a/lib/amazon_ads/version.rb b/lib/amazon_ads/version.rb index 790d31c..4d48605 100644 --- a/lib/amazon_ads/version.rb +++ b/lib/amazon_ads/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module AmazonAds - VERSION = "0.3.0" + VERSION = "0.3.1" end diff --git a/test/.env.example b/test/.env.example index 40c1a19..2356882 100644 --- a/test/.env.example +++ b/test/.env.example @@ -3,3 +3,4 @@ AMAZON_ADS_CLIENT_SECRET=CLIENT_SECRET AMAZON_ADS_TEST_REFRESH_TOKEN=REFRESH_TOKEN AMAZON_ADS_TEST_REGION=NA AMAZON_ADS_TEST_PROFILE_ID=PROFILE_ID +AMAZON_ADS_TEST_EU_PROFILE_ID=PROFILE_ID diff --git a/test/amazon_ads/apis/test_campaigns.rb b/test/amazon_ads/apis/test_campaigns.rb index 7a41f6d..32d5ca7 100644 --- a/test/amazon_ads/apis/test_campaigns.rb +++ b/test/amazon_ads/apis/test_campaigns.rb @@ -12,4 +12,18 @@ def test_query_campaign assert_predicate(res.status, :success?) end + + def test_query_campaign_rejects_bare_string_filter + api = AmazonAds::Campaigns.new( + region: "EU", + profile_id: ENV.fetch("AMAZON_ADS_TEST_EU_PROFILE_ID", "0"), + access_token:, + ) + error = assert_raises(HTTP::StatusError) do + api.query_campaign(ad_product_filter: "SPONSORED_PRODUCTS") + end + + assert_equal(400, error.response.status.code) + assert(error.response.body.to_s.length.positive?) + end end diff --git a/test/amazon_ads/test_flushed_raise_error.rb b/test/amazon_ads/test_flushed_raise_error.rb new file mode 100644 index 0000000..1da55df --- /dev/null +++ b/test/amazon_ads/test_flushed_raise_error.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "test_helper" +require "socket" + +class TestFlushedRaiseError < Minitest::Test + BODY = '{"message":"BAD_REQUEST: adProductFilter must be an object"}' + + def setup + # test_helper hooks VCR into WebMock for every test, which intercepts + # HTTP::Client#perform before it ever opens a socket. This test exists + # specifically to exercise that real socket path, so bypass both for + # its duration. + VCR.turn_off! + WebMock.allow_net_connect! + + @server = TCPServer.new("127.0.0.1", 0) + @port = @server.addr[1] + @thread = Thread.new do + socket = @server.accept + socket.readpartial(4096) + socket.write( + "HTTP/1.1 400 Bad Request\r\n" \ + "Content-Type: application/json\r\n" \ + "Content-Length: #{BODY.bytesize}\r\n" \ + "Connection: close\r\n" \ + "\r\n#{BODY}", + ) + socket.close + end + end + + def teardown + @thread.kill + @server.close + + WebMock.disable_net_connect! + VCR.turn_on! + end + + def test_status_error_retains_body + error = assert_raises(HTTP::StatusError) do + HTTP.use(:flushed_raise_error).get("http://127.0.0.1:#{@port}/") + end + + assert_equal(BODY, error.response.body.to_s) + end + + def test_ignored_codes_pass_through + response = HTTP.use(flushed_raise_error: { ignore: [400] }).get("http://127.0.0.1:#{@port}/") + + assert_equal(400, response.status.code) + end +end diff --git a/test/vcr_cassettes/TestCampaigns/test_query_campaign_rejects_bare_string_filter.yml b/test/vcr_cassettes/TestCampaigns/test_query_campaign_rejects_bare_string_filter.yml new file mode 100644 index 0000000..8b13b9e --- /dev/null +++ b/test/vcr_cassettes/TestCampaigns/test_query_campaign_rejects_bare_string_filter.yml @@ -0,0 +1,179 @@ +--- +http_interactions: + - request: + method: post + uri: https://api.amazon.com/auth/o2/token + body: + encoding: ASCII-8BIT + base64_string: | + Z3JhbnRfdHlwZT1yZWZyZXNoX3Rva2VuJmNsaWVudF9pZD1GSUxURVJFRCZj + bGllbnRfc2VjcmV0PUZJTFRFUkVEJnJlZnJlc2hfdG9rZW49RklMVEVSRUQ= + headers: + Connection: + - close + Content-Type: + - application/x-www-form-urlencoded + Host: + - api.amazon.com + User-Agent: + - http.rb/6.0.4 + response: + status: + code: 200 + message: OK + headers: + Server: + - Server + Date: + - Tue, 25 Aug 2026 18:00:57 GMT + Content-Type: + - application/json;charset=UTF-8 + Content-Length: + - "1162" + Connection: + - close + X-Amz-Rid: + - XSWTKJRGESBH3E9TKR7K + X-Amzn-Requestid: + - b60976a7-263c-4f0e-b289-7cf30a3cd5d5 + X-Amz-Date: + - Tue, 25 Aug 2026 18:00:57 GMT + Cache-Control: + - no-cache, no-store, must-revalidate + Pragma: + - no-cache + Vary: + - Accept-Encoding,User-Agent + Strict-Transport-Security: + - max-age=47474747; includeSubDomains; preload + body: + encoding: UTF-8 + string: '{"access_token":"FILTERED","refresh_token":"FILTERED","token_type":"bearer","expires_in":3600}' + recorded_at: Tue, 25 Aug 2026 18:00:57 GMT + - request: + method: post + uri: https://api.amazon.com/auth/o2/token + body: + encoding: ASCII-8BIT + base64_string: | + Z3JhbnRfdHlwZT1yZWZyZXNoX3Rva2VuJmNsaWVudF9pZD1GSUxURVJFRCZj + bGllbnRfc2VjcmV0PUZJTFRFUkVEJnJlZnJlc2hfdG9rZW49RklMVEVSRUQ= + headers: + Connection: + - close + Content-Type: + - application/x-www-form-urlencoded + Host: + - api.amazon.com + User-Agent: + - http.rb/6.0.4 + response: + status: + code: 200 + message: OK + headers: + Server: + - Server + Date: + - Tue, 25 Aug 2026 18:00:58 GMT + Content-Type: + - application/json;charset=UTF-8 + Content-Length: + - "1158" + Connection: + - close + X-Amz-Rid: + - RTFBSR5CA67G802HG7F1 + X-Amzn-Requestid: + - 9fe53e98-b9f7-48c7-86c6-57c964e35ac5 + X-Amz-Date: + - Tue, 25 Aug 2026 18:00:58 GMT + Cache-Control: + - no-cache, no-store, must-revalidate + Pragma: + - no-cache + Vary: + - Accept-Encoding,User-Agent + Strict-Transport-Security: + - max-age=47474747; includeSubDomains; preload + body: + encoding: UTF-8 + string: '{"access_token":"FILTERED","refresh_token":"FILTERED","token_type":"bearer","expires_in":3600}' + recorded_at: Tue, 25 Aug 2026 18:00:58 GMT + - request: + method: post + uri: https://advertising-api-eu.amazon.com/adsApi/v1/query/campaigns + body: + encoding: UTF-8 + string: '{"adProductFilter":"SPONSORED_PRODUCTS"}' + headers: + Authorization: + - FILTERED + Amazon-Advertising-Api-Clientid: + - FILTERED + Content-Type: + - application/json + Accept: + - application/json + Amazon-Advertising-Api-Scope: + - FILTERED + Connection: + - close + Host: + - advertising-api-eu.amazon.com + User-Agent: + - http.rb/6.0.4 + response: + status: + code: 400 + message: Bad Request + headers: + Content-Type: + - application/json + Content-Length: + - "27" + Connection: + - close + Server: + - Server + Date: + - Tue, 25 Aug 2026 18:11:35 GMT + Edge-Cache-Control: + - no-store,no-cache,stale-if-error=0,stale-while-revalidate=0 + X-Amz-Rid: + - TJ0709KC4QKZ3J71XVDX + X-Amzn-Requestid: + - 8ae0d531-21b1-4175-b627-1599bed96415 + X-Amzn-Remapped-X-Amzn-Requestid: + - 8ae0d531-21b1-4175-b627-1599bed96415 + X-Amzn-Remapped-Content-Length: + - "27" + X-Amzn-Errortype: + - SerializationException + X-Amzn-Remapped-Connection: + - close + X-Amz-Apigw-Id: + - Cq8hxEOXjoEFSUQ= + X-Amzn-Remapped-Server: + - Apache-Coyote/1.1 + X-Amzn-Trace-Id: + - Root=1-6a8ddad7-7f06614ceba03878afc53d36 + X-Amzn-Remapped-Date: + - Tue, 25 Aug 2026 18:11:35 GMT + Vary: + - Accept-Encoding,User-Agent + Strict-Transport-Security: + - max-age=47474747; includeSubDomains; preload + X-Cache: + - Error from cloudfront + Via: + - 1.1 acc5f68eb88a8e6d59815a0246ec23f0.cloudfront.net (CloudFront) + X-Amz-Cf-Pop: + - AMS1-P2 + X-Amz-Cf-Id: + - tibpiHu-vo6CczDEgTRnuOfZDBt88ovGu5ua2Lxgc8B_IEwVzcyqgg== + body: + encoding: UTF-8 + string: '{"Message":"Expected null"}' + recorded_at: Tue, 25 Aug 2026 18:11:35 GMT +recorded_with: VCR 6.4.0