Ruby Net::HTTP never follows redirects: saving a Sume video

Net::HTTP returns the 302 from /v1/videos/{id}/content as-is. A 26-line Ruby script submits Seedance 2.5, polls, follows the redirect and saves the MP4.

4 min readSume
All posts

A Ruby script that polls a Sume video job to completed and then saves an empty or tiny file has almost always hit the same thing: Ruby's Net::HTTP does not follow redirects. The download URL in unsigned_urls[0] is /v1/videos/{id}/content?index=0, and Sume answers it with a 302 to the artifact. The standard library hands you that 302 response and stops.

This script submits a Seedance 2.5 job, polls it, follows the redirect by hand and writes out.mp4. It is 26 lines of plain Ruby with no gems. It was run against a local stand-in server that returns the documented 202, poll and 302 shapes.

The script

Set SUME_API_KEY and run ruby video.rb. The request is an 8-second, 720p, 9:16 Seedance 2.5 clip, which Sume prices per video token. By the pricing table that is about $4.62 for 8 seconds in 720p vertical; the exact figure arrives in usage.cost on the completed job.

require "net/http"
require "json"
BASE = ENV.fetch("SUME_API_BASE", "https://api.sume.com")
KEY = ENV.fetch("SUME_API_KEY")
def send_req(req, uri)
  req["Authorization"] = "Bearer #{KEY}"
  Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |h| h.request(req) }
end
uri = URI("#{BASE}/v1/videos")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
req["Idempotency-Key"] = "seedance-25-demo-0001"
req.body = { model: "seedance-2.5", prompt: "A paper boat drifts down a rainy street",
             duration: 8, resolution: "720p", aspect_ratio: "9:16" }.to_json
poll = URI(JSON.parse(send_req(req, uri).body).fetch("polling_url"))
loop do
  sleep ENV.fetch("POLL_SECONDS", "30").to_i
  job = JSON.parse(send_req(Net::HTTP::Get.new(poll), poll).body)
  abort "#{job['status']}: #{job['error']}" if %w[failed cancelled].include?(job["status"])
  next unless job["status"] == "completed"
  url = URI(job["unsigned_urls"][0])
  res = send_req(Net::HTTP::Get.new(url), url)
  res = Net::HTTP.get_response(URI(res["location"])) if res.is_a?(Net::HTTPRedirection)
  File.binwrite("out.mp4", res.body)
  puts "saved, usage.cost = #{job.dig('usage', 'cost')}"
  break
end

The three details that matter

Most of the script is ordinary. These parts are where Ruby readers get stuck.

  • res.is_a?(Net::HTTPRedirection) is the check for the 302. The location header holds the next URL, and the second request goes out without the Bearer header because the artifact link is not the API. Sending credentials to a storage host you do not control is a habit worth avoiding, and it keeps the script correct if the redirect target changes.
  • Idempotency-Key on the submit means a retry after a network error returns the original job. Without it a second POST can reserve and spend a second time.
  • failed and cancelled are terminal. The script aborts with the error field, and a 409 job_not_completed from the content URL only means you asked too early.

Seedance 2.5 limits to know

Seedance 2.5 runs 4 to 30 seconds at 480p, 720p or 1080p, with eight aspect ratio values including auto. It accepts image, video and audio references, which Kling 3.0 does not. The price is a token price, so 1080p costs far more than 720p for the same length.

Seedance 2.5 price of a 5-second 9:16 clip on Sume (pricing tables, read 2026-10-05)
ResolutionOutput sizePrice
480p496 x 864$1.34
720p720 x 1280$2.89
1080p1080 x 1920$7.11

Poll rhythm

Poll about every 30 seconds and read status each time. If you need the finished clip in a queue worker, store the job id first and fetch the content URL only after completed.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume