From c3ed37c548c9288b475dcdd241d1051313b8c4db Mon Sep 17 00:00:00 2001 From: tim-kos Date: Tue, 13 Oct 2026 09:23:41 +0200 Subject: [PATCH 1/2] Handle invalid response JSON with SDK diagnostics --- README.md | 8 +++ lib/transloadit/exception.rb | 15 ++++++ lib/transloadit/response.rb | 12 ++++- test/unit/transloadit/test_response.rb | 74 ++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 85d8118..afe6475 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,14 @@ state (e.g., `assembly.completed?` vs. checking the result of `assembly[:ok]`). Methods suffixed by a bang make a live query against the Transloadit HTTP API. +Responses parse their JSON body once and discard that cached value when reloaded +or replaced. If the API or an upstream proxy returns invalid JSON, accessing +`body` or a response attribute raises `Transloadit::Exception::InvalidResponse`. +The exception exposes `status`, `raw_body`, and the original parser error through +`cause` when available. Its message includes the HTTP status without including the +response body. Treat the raw diagnostics as potentially sensitive when handling +the exception. + ### 2. Uploading multiple files Multiple files can be given to the `create!` method in order to upload more diff --git a/lib/transloadit/exception.rb b/lib/transloadit/exception.rb index 4071199..83e3c45 100644 --- a/lib/transloadit/exception.rb +++ b/lib/transloadit/exception.rb @@ -30,4 +30,19 @@ def message "reload_until_finished! reached limit specified in :tries option. This is not a rate limit and you may continue to poll for updates." end end + + # Exception raised when an API response does not contain valid JSON. + class InvalidResponse < StandardError + # @return [Integer] the HTTP status of the invalid response + attr_reader :status + + # @return [String] the original body, which may contain sensitive diagnostics + attr_reader :raw_body + + def initialize(status:, raw_body:) + @status = status + @raw_body = raw_body + super("Transloadit returned invalid JSON (HTTP #{status})") + end + end end diff --git a/lib/transloadit/response.rb b/lib/transloadit/response.rb index 18c2a06..cce4086 100644 --- a/lib/transloadit/response.rb +++ b/lib/transloadit/response.rb @@ -41,7 +41,16 @@ def [](attribute) # @return [Hash] the parsed JSON body hash # def body - MultiJson.load @raw_body + return @body if instance_variable_defined?(:@body) + + # Some MultiJson adapters accept empty input as nil instead of reporting a parse error. + if @raw_body.to_s.strip.empty? + raise Transloadit::Exception::InvalidResponse.new(status: status, raw_body: @raw_body) + end + + @body = MultiJson.load(@raw_body) + rescue MultiJson::ParseError => error + raise Transloadit::Exception::InvalidResponse.new(status: status, raw_body: @raw_body), cause: error end # @@ -76,6 +85,7 @@ def replace(other) @raw_body = other.raw_body @headers = other.headers @status = other.status + remove_instance_variable(:@body) if instance_variable_defined?(:@body) self end diff --git a/test/unit/transloadit/test_response.rb b/test/unit/transloadit/test_response.rb index 227cafd..443f211 100644 --- a/test/unit/transloadit/test_response.rb +++ b/test/unit/transloadit/test_response.rb @@ -20,6 +20,7 @@ status: 200 ) + _(response.body.fetch("ok")).must_equal "ASSEMBLY_EXECUTING" returned = response.replace(replacement) _(returned).must_be_same_as response @@ -29,6 +30,79 @@ _(response.status).must_equal 200 end + it "must parse a successful body only once" do + response = Transloadit::Response.new(body: '{"ok":"ASSEMBLY_COMPLETED"}', headers: {}, status: 200) + calls = 0 + parser = MultiJson.method(:load) + with_singleton_method(MultiJson, :load, proc { |raw| + calls += 1 + parser.call(raw) + }) do + parsed = response.body + _(response.body).must_be_same_as parsed + _(response["ok"]).must_equal "ASSEMBLY_COMPLETED" + _(response.inspect).must_equal parsed.inspect + end + + _(calls).must_equal 1 + end + + it "must preserve array bodies" do + response = Transloadit::Response.new(body: '[{"assembly_id":"assembly-id"}]', headers: {}, status: 200) + + _(response.body).must_equal [{"assembly_id" => "assembly-id"}] + end + + it "must memoize JSON null and false without changing their values" do + null_response = Transloadit::Response.new(body: "null", headers: {}, status: 200) + false_response = Transloadit::Response.new(body: "false", headers: {}, status: 200) + assert_nil null_response.body + _(false_response.body).must_equal false + + with_singleton_method(MultiJson, :load, proc { |_raw| raise "Body was parsed again" }) do + assert_nil null_response.body + _(false_response.body).must_equal false + end + end + + it "must expose invalid-response diagnostics without including the body in the message" do + raw_body = "Proxy error: sensitive upstream diagnostics" + response = Transloadit::Response.new(body: raw_body, headers: {}, status: 502) + + error = assert_raises(Transloadit::Exception::InvalidResponse) { response.body } + + _(error.status).must_equal 502 + _(error.raw_body).must_equal raw_body + _(error.cause).must_be_kind_of MultiJson::ParseError + _(error.message).must_equal "Transloadit returned invalid JSON (HTTP 502)" + end + + it "must wrap parse failures raised through attribute access" do + response = Transloadit::Response.new(body: "", headers: {}, status: 503) + + error = assert_raises(Transloadit::Exception::InvalidResponse) { response["ok"] } + _(error.status).must_equal 503 + end + + it "must reject whitespace-only bodies" do + response = Transloadit::Response.new(body: " \n\t", headers: {}, status: 502) + + assert_raises(Transloadit::Exception::InvalidResponse) { response.body } + end + + it "must discard a parsed body when a replacement contains invalid JSON" do + response = Transloadit::Response.new(body: '{"ok":"ASSEMBLY_EXECUTING"}', headers: {}, status: 200) + _(response.body.fetch("ok")).must_equal "ASSEMBLY_EXECUTING" + replacement = Transloadit::Response.new(body: "upstream failure", headers: {}, status: 502) + + response.replace(replacement) + error = assert_raises(Transloadit::Exception::InvalidResponse) { response.body } + _(error.status).must_equal 502 + + response.replace(Transloadit::Response.new(body: '{"ok":"ASSEMBLY_COMPLETED"}', headers: {}, status: 200)) + _(response["ok"]).must_equal "ASSEMBLY_COMPLETED" + end + describe "when initialized" do before do VCR.use_cassette "fetch_assembly_ok" do From 543e67678bf5aeda655bd552f2e0bff5fe577b9f Mon Sep 17 00:00:00 2001 From: tim-kos Date: Tue, 13 Oct 2026 09:30:53 +0200 Subject: [PATCH 2/2] Keep JSON compatible with MultiJson on Ruby 3.1 --- transloadit.gemspec | 2 ++ 1 file changed, 2 insertions(+) diff --git a/transloadit.gemspec b/transloadit.gemspec index afcbef7..f66916f 100644 --- a/transloadit.gemspec +++ b/transloadit.gemspec @@ -23,6 +23,8 @@ Gem::Specification.new do |gem| gem.add_dependency "faraday", ">= 2.0", "< 3.0" gem.add_dependency "faraday-multipart", "~> 1.0" + # Ruby 3.1 uses MultiJson 1.19, whose positional parse options are incompatible with JSON 3. + gem.add_dependency "json", "< 3.0" gem.add_dependency "multi_json" gem.add_dependency "mime-types"