Why your transcode job failed (and what to do in the poll loop)

A job you submitted came back failed. Vendor error tables map a code to a one-line sentence that never tells you what you need: retry, alert, or drop the file. Once your pipeline accepts video you didn't create, most failures were decided before the transcoder started.
Quick answer: A transcode failed almost always means the input was not transcodable, not that the service broke. The usual causes are a file with no video stream, a truncated upload whose moov atom was never written, a zero-byte object, an exotic codec, variable frame rate, or a file over your plan's input size cap. Probe the file withffprobebefore you submit so permanent failures are rejected in your own code, then poll the job to a terminalcompletedorfailedstate and retry only the transient cases. On FFmpeg Micro, failed jobs are never billed and status checks, job listing and downloads are free, so a defensive poll loop costs nothing, and polling is the contract: there is no completion callback.
What actually fails when the file came from a user
Conventional wisdom says a failed transcode is the transcoder's problem. That's partly right: a misconfigured job really does fail, and a bad preset really is your bug. But once the input is a phone upload, a UGC submission, or an object some other service wrote into a bucket, the cause moves upstream. The transcoder is reporting on the file, and the file is the variable.
This set repeats, and the "permanent" column is the only classification your code needs.
| Cause | What you see | Permanent? |
|---|---|---|
| No video stream at all (audio-only, or a video track that decodes to nothing) | MediaConvert returns error 1020, "Video sync did not read enough good frames to process successfully"; Google's Transcoder API fails when no video stream is detected | Yes |
| Exotic or unsupported codec | MediaConvert's 1030 names svq1, unknown video codecs, adpcm_ms audio and H.264 High 4:4:4 Predictive as unsupported inputs, all of which play fine in a desktop player | Yes |
| Truncated upload, no moov atom | A demux structure error; MediaConvert's 1075 example message is "Failed probe/open: [no moov box found in file]", and plain truncation surfaces separately as 1076, which asks "Is file truncated?" | Yes for that object, no if you re-upload |
| Zero-byte object | The upload "succeeded" and the stored object has no bytes | Yes |
| Variable frame rate | The job often completes and the output drifts out of sync; where frame-rate data is absent entirely it fails, as in MediaConvert's 1040: "Frame rate is set to follow, but there is no frame rate information in the input stream info" | Needs normalizing, not retrying |
| Rotation metadata | A sideways output, not an error | Needs handling |
| HDR source into an SDR target | Washed-out, grey output | Needs a tone map |
| Over the plan's input size cap | Rejected at submit, before any work is queued | Yes until the file shrinks |
| Too short | Google's Transcoder API requires inputs of at least 5 seconds | Yes |
Two of those don't error at all, which is why they bite hardest. Rotation lives as display-matrix side data in the container, not baked into the pixels (FFmpeg's documentation describes the matrix), so a job that ignores it hands you a valid sideways video. An HDR source into an SDR encode gives you the grey wash covered in Kling 4.0 washed out on TikTok. Codec profile is where platform acceptance diverges from decodability: a file your encoder produced happily can still be refused downstream, the argument in X (Twitter) video specs that survive the upload.
The nastiest variant is the job that fails and says nothing. Immich's v3 transcode queue did that: the counter jumped to the expected number, dropped straight to zero, the encoded video on disk was unchanged, and the log was empty. The maintainers traced it to the pipeline trusting stored metadata rows instead of probing the file. If your only success signal is "the job table moved", you'll ship that bug too. Assert on the output.
Probe before you submit, because rejecting a file is free
A pre-submit probe is the cheapest code in your pipeline: one ffprobe call answers most of the table above.
ffprobe -v error -show_entries \
"format=duration,size:stream=codec_type,codec_name,r_frame_rate,avg_frame_rate,color_transfer" \
-show_entries stream_side_data_list -of json input.mp4
Read it like a checklist. No object in streams with codec_type=video means reject, not retry. codec_name outside your allowlist means reject or pre-convert. r_frame_rate that disagrees with avg_frame_rate means variable frame rate, and stream-copying won't help, because fixing the drift means rebuilding timestamps. A color_transfer of smpte2084 or arib-std-b67 means an HDR source that needs a tone map on the way to SDR. A side_data_list entry with a non-zero rotation means set your output orientation deliberately. If ffprobe exits non-zero with moov atom not found, the upload was interrupted and that object will never transcode, however many times you re-queue it.
Catch zero-byte and truncated objects at ingest by comparing the stored object's byte length against what the client declared. FFmpeg Micro's upload flow does this: POST /v1/upload/confirm takes the same filename and fileSize from step one and checks the stored object matches, so a half-finished PUT fails the confirm instead of becoming a doomed job. Pass fileSize as a JSON number; a quoted "12345678" returns a 400. Base64 round-trips produce bytes that look right and aren't, the failure in n8n Convert to File base64 video is corrupt.
The poll loop, written properly
Cloud transcoders give you a job lifecycle to observe; MediaConvert documents PROGRESSING, COMPLETE, ERROR and CANCELED with polling or events. FFmpeg Micro jobs start pending, move to processing, and end at completed or failed. There's no completion callback: a webhookUrl on a request is ignored, so the loop is the integration.
The loop has to get one distinction right: an HTTP failure on the status call is not a job failure. Google's retry guidance is the rule to inherit: retry 429s, 5xx and network errors with truncated exponential backoff and jitter, never retry validation errors. Apply it to the transport, and treat the job's terminal state as final.
import random, time, requests
API, HEAD = "https://api.ffmpeg-micro.com", {"Authorization": f"Bearer {API_KEY}"}
class PermanentJobFailure(Exception): pass
def wait_for(job_id, deadline_s=1800):
give_up, delay, http_fails = time.monotonic() + deadline_s, 3, 0
while time.monotonic() < give_up:
try:
r = requests.get(f"{API}/v1/transcodes/{job_id}", headers=HEAD, timeout=10)
r.raise_for_status()
except requests.RequestException: # transport, not verdict
http_fails += 1
if http_fails > 5:
raise
time.sleep(delay + random.uniform(0, 1))
delay = min(delay * 2, 30)
continue
http_fails, delay = 0, 3
job = r.json()
if job["status"] == "completed":
return job # then GET /download
if job["status"] == "failed":
raise PermanentJobFailure(job) # dead-letter it, never resubmit
time.sleep(delay)
raise TimeoutError(job_id)
Three seconds is a sane interval for short clips, and the deadline separates "still running" from "stuck". The status response helps you set it honestly: inputs longer than two minutes run in the long lane on their own server, queue_position tells you how many jobs were ahead of yours at acceptance, and started_at minus createdAt gives you submission-to-start. Uploaded input links stay valid for 24 hours, so a job that waits still runs. The quickstart creates a temporary key and runs presigned upload, transcode, poll and download in the dashboard.
Route PermanentJobFailure to a dead-letter record keyed by the input's checksum, not to the back of the queue. A file with no moov atom fails identically on attempt one and attempt four hundred.
Mistakes that turn one bad file into a loop that never ends
Every expensive version of this bug is a retry policy that can't tell a file problem from a weather problem.
- Resubmitting a terminal
failedjob with the same input. Permanent causes stay permanent. Retry the upload if you suspect truncation; otherwise reject the asset and tell the user which check failed. - Treating a 500 on the status call as a failed job. The job is probably fine. You lost one HTTP request, so back off and ask again.
- Polling with no deadline. A loop that waits forever reports "stuck" as "working". A deadline reports it as a timeout you can alert on.
- Trusting the job table instead of the output. Immich's silent failure drained the queue and changed nothing on disk. Check that an output exists before you mark the asset done.
- Waiting for a callback. There isn't one here. Poll.
- Validating only at submit. Zero-byte and truncated objects are cheapest to catch by byte count the moment they land.
What a failed transcode costs
Not being billed for failures is table stakes: AWS states in its error code documentation that MediaConvert charges only for jobs reaching COMPLETED, never for ERROR jobs. FFmpeg Micro does the same, and what matters for loop design is what surrounds it: one token buys 10 seconds of input video, metered per second, while checking status, listing jobs and downloading cost nothing (pricing). The loop above, backoff and five transport retries included, is free to run as often as you like.
The size cap behaves differently from a failure, so name it that way in your own code. Maximum input file size is 100 MB on the free plan, 1,024 MB on Starter and 2,048 MB on Pro, and an oversized file is refused at submit rather than queued and failed. That's a branch you handle before the poll loop, usually by compressing first, as in Your 4K AI video isn't TikTok-ready.
FAQ about failed transcode jobs
Is a failed transcode my file or the service?
A failed transcode is the input's fault far more often than the service's, especially in pipelines that accept user files. Run ffprobe on the exact object the job read: if the probe can't find a video stream, can't find the moov atom, or reports a codec outside the supported set, the job would have failed on any transcoder.
Am I charged for a transcode that failed, and does polling cost anything?
Failed jobs are never billed on FFmpeg Micro, and checking job status, listing jobs and downloading outputs are all free. Billing applies to the duration of video actually processed, metered per second with no rounding up to the minute.
Does the API call me back when a job finishes, or do I have to poll?
Polling is the contract: submit the job, then GET /v1/transcodes/:id until the status is completed or failed. There are no completion webhooks for callers, and a webhookUrl included in a request is ignored.
How do I tell a job that is still running from a job that is stuck?
Give every poll loop a wall-clock deadline and compare it against the job's lane: inputs over two minutes long run in the long lane, so a four-minute source legitimately takes longer than a 20-second clip. Past the deadline, stop polling and raise a timeout rather than letting the loop imply progress.
Why did my upload produce a file with no moov atom, and can the job ever succeed on it?
A missing moov atom means the upload stopped before the trailing index was written, so the object on disk is an unfinished MP4. It can never transcode, on any service. Re-upload the complete file, and check stored byte length against the declared size at ingest so you catch the next one for free.
Rejecting a bad upload with one ffprobe call before it reaches a queue is the cheapest code you'll write, and everything after it runs at one token per ten seconds of input, zero when a job fails. Sign up free and point the submit-poll-download cycle at your worst real file: no servers to run, no FFmpeg to install, from n8n, Make and Zapier or from your AI agents.
About Javid Jamae
Founder & CEO at FFmpeg Micro
Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.
You might also like

Etsy Listing Video Requirements: 15 Seconds, No Audio, One Encode
Etsy video requirements in full: 5-15 seconds, 100 MB, no audio, 2:1 to 1:2 ratio. Cut two legal 15-second clips from one master with FFmpeg or one API call.

X (Twitter) Video Specs That Survive the Upload (and the API)
Twitter video specs tables miss the two rules that fail an API upload: H.264 High Profile with AAC LC, and the real chunked INIT/APPEND/FINALIZE byte math.

FFmpeg Builds for Windows: Essentials or Full, gyan or BtbN
FFmpeg builds for Windows, decided: gyan essentials vs full, static vs shared, gyan vs BtbN, with real OS and CPU requirements and the codec drift to avoid.
Ready to process videos at scale?
Start using FFmpeg Micro's simple API today. No infrastructure required.
Get Started Free