Oban worker that polls a Sume job: snooze instead of sleeping
Submit a Sume job once, then let an Oban worker check status, return {:snooze, seconds} while it runs and finish on terminal. Elixir with Req, no sleep loop.

In Elixir, poll a Sume job with an Oban worker that reads /v1/jobs/{id}/status once per run and returns {:snooze, seconds} while terminal is false. Oban's worker docs describe {:snooze, period} as rescheduling the job to run again after the period without counting as a failed attempt (read 2026-10-04). That keeps a worker process free between polls, instead of a Process.sleep loop holding a queue slot for minutes.
The Sume side comes from Jobs and results: the status read carries terminal, sume_status and next_poll_after_seconds, which is null once the job is terminal.
What does the worker look like?
Insert it with the Sume job id in args and a unique window so a double insert does not start two pollers.
defmodule MyApp.PollSumeJob do
use Oban.Worker, queue: :media, max_attempts: 5,
unique: [period: 3600, keys: [:job_id]]
@impl true
def perform(%Oban.Job{args: %{"job_id" => id}}) do
resp =
Req.get!("https://api.sume.com/v1/jobs/#{id}/status",
headers: [{"x-api-key", System.fetch_env!("SUME_API_KEY")}],
retry: false, receive_timeout: 30_000)
case {resp.status, resp.body} do
{200, %{"data" => %{"terminal" => true, "sume_status" => "completed"}}} ->
MyApp.Media.store_result(id)
{200, %{"data" => %{"terminal" => true, "sume_status" => s}}} ->
{:cancel, "job #{id} ended #{s}"}
{200, %{"data" => d}} ->
{:snooze, max(d["next_poll_after_seconds"] || 5, 5)}
{429, _} ->
{:snooze, String.to_integer(Req.Response.get_header(resp, "retry-after") |> List.first("30"))}
{status, _} when status >= 500 -> {:error, "sume #{status}"}
{status, _} -> {:cancel, "sume #{status}"}
end
end
endWhy does each branch return what it does?
| Sume answer | Oban return | Reason |
|---|---|---|
| 200, terminal, completed | store result, :ok | Fetch /result or the job record and persist artifact URLs |
| 200, terminal, failed or canceled | {:cancel, reason} | Retrying a finished job cannot change it |
| 200, still running | {:snooze, n} | Honors next_poll_after_seconds and reschedules without failing the job |
| 429 | {:snooze, retry-after} | A read-budget limit is transient; the job keeps running |
| 5xx | {:error, reason} | Oban backoff retries; counts as an attempt |
| Other 4xx | {:cancel, reason} | Fix the request or the key; do not loop |
How many polls is that?
Polling spends your read budget, and a long video job can take minutes, so respect next_poll_after_seconds and floor it. A webhook avoids the loop entirely for long jobs; see Webhooks. Check how your Oban version counts snoozes against max_attempts before you rely on a long poll chain.
What about the submit step?
Submit in a separate worker or in the code that enqueues this one, with an Idempotency-Key derived from your own record id, so a retried submit returns the same job id and does not bill twice.
Sources
Related posts
More in Developers
- One Gemini billing cap pauses every linked project: guard the batch
Google pauses all projects on a billing account at the tier cap. Add a balance check and idempotent submits so one Omni batch cannot stall the rest.
- One-hour Sume OAuth token in an OpenAI MCP authorization field
OpenAI does not store the MCP authorization token and wants it on every request. Sume's token lasts one hour with no refresh, so track expiry yourself.
- One TypeScript job shape for Sume /v1/videos and /v1/jobs responses
Sume's /v1/videos returns a bare object and /v1/jobs wraps in data. A short normalizer maps both to one state, including the cancelled versus canceled spelling.
- One TTS job with sentence segments vs one job per sentence
Sume TTS can return gapless sentence timings and per-sentence wav slices from one job. The price per character is the same; the jobs, keys and polls are not.
Written by Sume