When a media API returns HTTP 429, stop the immediate retry loop. Inspect the response, preserve the operation's identity, and schedule the next eligible attempt using a valid Retry-After hint when one is present. If the required wait exceeds the worker's remaining time budget, defer the work instead of shortening the server's wait.
This guide focuses on timing a retry. It does not make every operation safe to repeat. A status lookup and a request that creates a new media job have different consequences, especially when the original response was lost.
Read the response before choosing a delay
HTTP 429 indicates too many requests and may include Retry-After. The status alone does not specify how a service counts requests or which clients share a limit. Do not infer a universal per-second allowance from one rejection.
Record the operation, attempt number, HTTP status and next eligible time. Keep API keys and signed delivery links out of logs. If the response describes a condition requiring account or configuration changes, waiting alone may not resolve it.
A transport timeout is a different observation: you do not know whether the server accepted the request. For a create operation, reconcile the outcome before submitting again. The worker recovery guide covers that uncertainty separately.
Recognize both Retry-After forms
RFC 9110 defines a delay in whole seconds or an HTTP date. A date requires a reliable wall clock. A value such as 30 means a waiting interval; a date identifies a time. Treat missing or malformed values as a separate case, not an instruction to retry immediately.
The helper below interprets ASCII integer seconds and timezone-aware dates that Python's email date parser accepts. It rounds a positive fractional wait upward and returns None for unsupported input. The input-length guard is an application choice. This is a small scheduling helper, not a complete HTTP-date conformance validator.
import math
import re
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
def retry_after_seconds(value, now):
if not isinstance(now, datetime) or now.utcoffset() is None:
raise ValueError("Use a timezone-aware current time")
if not isinstance(value, str) or len(value) > 128:
return None
value = value.strip()
if re.fullmatch(r"[0-9]+", value):
return int(value)
try:
target = parsedate_to_datetime(value)
if target.utcoffset() is None:
return None
return max(0, math.ceil((target - now).total_seconds()))
except (TypeError, ValueError, OverflowError):
return None
now = datetime(2026, 10, 23, 9, 0, tzinfo=timezone.utc)
assert retry_after_seconds("30", now) == 30
assert retry_after_seconds("Fri, 23 Oct 2026 09:00:45 GMT", now) == 45
assert retry_after_seconds("Fri, 23 Oct 2026 08:59:00 GMT", now) == 0
assert retry_after_seconds("not-a-date", now) is None
These assertions were executed with a fixed clock and synthetic headers. Additional checks cover whitespace, malformed values, fractional timing and naive clocks. No requests were sent to provoke throttling, and no provider quota was measured.
For a valid zero or past date, apply your own minimum spacing and retry budget rather than creating a busy loop. For None, use the service's documented fallback policy with bounded backoff, or defer for inspection. Oversized or ambiguous headers should not cause immediate retries.
Respect the wait and your worker deadline
A maximum worker lifetime is not permission to retry earlier than requested. Consider a synthetic case: the response asks for 120 seconds, but your worker has 20 seconds left. Persist the pending operation and its next eligible time, then release the worker. Do not replace the delay with 20 seconds and send again.
For an in-process wait, use a monotonic deadline so a wall-clock adjustment does not accidentally extend the worker's runtime. For durable scheduling across restarts, store an absolute timestamp and reconcile it when the next worker claims the task. Allow time for the next request itself; a wait that consumes the whole budget leaves no useful execution window.
Use attempt limits and an overall elapsed-time limit. If repeated rejections persist, stop increasing pressure and surface the blocked stage. Keep delays observable so a queue that is waiting by design is distinguishable from a worker that has stopped making progress.
Coordinate retries across workers
Independent workers can all wake at the same instant. Where the service's limit is shared, coordinate admission at the corresponding application scope. A process-local sleep does not limit the rest of your fleet.
If adding random spacing to a valid server wait, add nonnegative jitter after that wait rather than selecting a shorter interval. Apply the deadline decision to the full delay. When no valid hint exists, a bounded backoff policy can spread attempts, but it still needs a retry budget and a shared concurrency limit.
The backpressure guide explains why concurrency, request rate and queue size need separate controls. A retry timer is one part of that system, not a substitute for admission control.
Keep media job state separate from HTTP retries
Tornado's create-job reference describes job creation and its returned identifiers. Once you have a job ID, preserve it and follow that job rather than turning every later HTTP failure into another create request.
Apply timing logic to the actual response received. Do not hard-code one delay as a universal Tornado guarantee, assume every rejection includes a header, or infer that a locally inspected implementation proves every deployed route behaves identically.
A retryable status check does not establish that the remote media job failed. Likewise, a failed downstream upload should be retried at the delivery stage when appropriate, without automatically repeating source ingestion. The partial-failure guide shows how to retain successful work while recovering individual stages.
Start with one media workflow, then test delayed responses using offline fixtures. Cover a valid seconds hint, a future date, a missing header and a wait longer than the worker's budget before increasing traffic.