diff --git a/README.md b/README.md index 85d8118..e940857 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,11 @@ response.reload! response.reload_until_finished! tries: 300 # default is 600 ``` +Assembly status rate limits are temporary polling errors. `reload_until_finished!` +keeps polling the original status URL and waits at least the server’s `info.retryIn` +seconds before retrying. The `tries` limit still applies. A status rate limit does +not make `finished?` return `true`; `status_rate_limit?` identifies this response. + In general, you use hash accessor syntax to query any direct attribute from the [response](https://transloadit.com/docs/api/#assembly-status-response). Methods suffixed by a question mark provide a more readable way of querying diff --git a/lib/transloadit/response/assembly.rb b/lib/transloadit/response/assembly.rb index 6e6d998..193a770 100644 --- a/lib/transloadit/response/assembly.rb +++ b/lib/transloadit/response/assembly.rb @@ -2,7 +2,10 @@ module Transloadit::Response::Assembly def reload! - replace Transloadit::Request.new(self["assembly_ssl_url"]).get + url = assembly_ssl_url + raise ArgumentError, "Assembly response does not include a status URL" if url.nil? || url.empty? + + replace Transloadit::Request.new(url).get end def cancel! @@ -34,7 +37,7 @@ def replaying? end def finished? - aborted? || canceled? || completed? || error? + aborted? || canceled? || completed? || (error? && !status_rate_limit?) end def uploading? @@ -45,8 +48,22 @@ def rate_limit? self["error"] == "RATE_LIMIT_REACHED" end + def status_rate_limit? + self["error"] == "ASSEMBLY_STATUS_FETCHING_RATE_LIMIT_REACHED" + end + def wait_time - self["info"]["retryIn"] || 0 + self["info"]&.fetch("retryIn", 0) || 0 + end + + def replace(other) + assembly_ssl_url + super + end + + # Status rate-limit responses omit this URL, so retain it across reloads. + def assembly_ssl_url + @assembly_ssl_url ||= self["assembly_ssl_url"] end DEFAULT_RELOAD_TRIES = 600 @@ -55,7 +72,7 @@ def reload_until_finished!(options = {}) tries = options[:tries] || DEFAULT_RELOAD_TRIES tries.times do - sleep 1 + sleep(status_rate_limit? ? [1, wait_time].max : 1) reload! return self if finished? end diff --git a/test/unit/transloadit/test_response.rb b/test/unit/transloadit/test_response.rb index 227cafd..7e46765 100644 --- a/test/unit/transloadit/test_response.rb +++ b/test/unit/transloadit/test_response.rb @@ -116,6 +116,74 @@ end end end + + it "must retry status rate limits at the original URL after retryIn" do + WebMock.reset! + response = Transloadit::Response.new( + body: MultiJson.dump(ok: "ASSEMBLY_EXECUTING", assembly_ssl_url: request_uri), + headers: {}, status: 200 + ).extend!(Transloadit::Response::Assembly) + WebMock.stub_request(:get, request_uri).to_return( + {status: 429, body: '{"error":"ASSEMBLY_STATUS_FETCHING_RATE_LIMIT_REACHED","info":{"retryIn":5}}'}, + {status: 200, body: '{"ok":"ASSEMBLY_COMPLETED"}'} + ) + delays = [] + + with_singleton_method(response, :sleep, proc { |delay| delays << delay }) do + _(response.reload_until_finished!(tries: 2)).must_be_same_as response + end + + _(delays).must_equal [1, 5] + _(response.completed?).must_equal true + _(response.assembly_ssl_url).must_equal request_uri + WebMock.assert_requested(:get, request_uri, times: 2) + ensure + WebMock.reset! + end + + it "must retain the status URL when manually reloading after a rate limit" do + WebMock.reset! + WebMock.stub_request(:get, request_uri).to_return( + {status: 429, body: '{"error":"ASSEMBLY_STATUS_FETCHING_RATE_LIMIT_REACHED"}'}, + {status: 200, body: '{"ok":"ASSEMBLY_COMPLETED"}'} + ) + + @response.reload! + _(@response.status_rate_limit?).must_equal true + _(@response.finished?).must_equal false + _(@response.wait_time).must_equal 0 + @response.reload! + + _(@response.completed?).must_equal true + WebMock.assert_requested(:get, request_uri, times: 2) + ensure + WebMock.reset! + end + + it "must still stop polling at the tries limit when status requests are rate limited" do + WebMock.reset! + WebMock.stub_request(:get, request_uri).to_return( + status: 429, body: '{"error":"ASSEMBLY_STATUS_FETCHING_RATE_LIMIT_REACHED"}' + ) + + with_singleton_method(@response, :sleep, proc { |_delay| }) do + assert_raises Transloadit::Exception::ReloadLimitReached do + @response.reload_until_finished!(tries: 2) + end + end + + _(@response.finished?).must_equal false + WebMock.assert_requested(:get, request_uri, times: 2) + ensure + WebMock.reset! + end + + it "must reject a reload without a status URL before making a request" do + response = Transloadit::Response.new(body: "{}", headers: {}, status: 200) + .extend!(Transloadit::Response::Assembly) + + assert_raises(ArgumentError) { response.reload! } + end end describe "statuses" do 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"