Idempotency
Idempotency ensures that retrying a request produces the same result as the original request, without creating duplicate resources. This is critical for handling network failures, timeouts, and other transient errors in production systems.
How It Works
When creating a job, supply an idempotency key. There are two ways to do this, and they behave differently on replay:
Idempotency-KeyHTTP header — the recommended mechanism, and what the official SDKs auto-inject. On a replay with the same header key, Transcodely does not return the original response; it returns a409 already_existserror. Fetch the job by ID (GetJob) to retrieve the result.idempotency_keyrequest-body field — shown in the curl example below for raw HTTP callers that don’t set the header. On a replay, this returns the original job directly. It is superseded whenever anIdempotency-Keyheader is also present: the header is gated before the job is created, so the body field never takes effect.
curl -X POST https://api.transcodely.com/transcodely.v1.JobService/Create
-H "Authorization: Bearer {{API_KEY}}"
-H "X-Organization-ID: org_a1b2c3d4e5"
-H "Content-Type: application/json"
-d '{
"input_url": "gs://my-bucket/video.mp4",
"output_origin_id": "ori_x9y8z7w6v5",
"outputs": [
{
"type": "mp4",
"video": [
{ "codec": "h264", "resolution": "1080p", "quality": "standard" }
]
}
],
"idempotency_key": "upload_usr12345_2026-01-15T10:30:00Z"
}'const job = await client.jobs.create({
inputUrl: "gs://my-bucket/video.mp4",
outputOriginId: "ori_x9y8z7w6v5",
outputs: [{
type: OutputFormat.MP4,
video: [
{ codec: VideoCodec.H264, resolution: Resolution.RESOLUTION_1080P, quality: QualityTier.STANDARD },
],
}],
idempotencyKey: "upload_usr12345_2026-01-15T10:30:00Z",
});job = client.jobs.create(
input_url="gs://my-bucket/video.mp4",
output_origin_id="ori_x9y8z7w6v5",
outputs=[{
"type": "mp4",
"video": [
{"codec": "h264", "resolution": "1080p", "quality": "standard"}
],
}],
idempotency_key="upload_usr12345_2026-01-15T10:30:00Z",
)job, err := client.Jobs.Create(ctx, &transcodely.JobCreateParams{
InputUrl: "gs://my-bucket/video.mp4",
OutputOriginId: proto.String("ori_x9y8z7w6v5"),
Outputs: []*transcodely.OutputSpec{{
Type: transcodely.OutputFormatMP4,
Video: []*transcodely.VideoVariant{
{Codec: transcodely.VideoCodecH264, Resolution: transcodely.Resolution1080P, Quality: transcodely.QualityTierStandard},
},
}},
IdempotencyKey: proto.String("upload_usr12345_2026-01-15T10:30:00Z"),
})First Request
The job is created normally and the idempotency key is associated with the job.
Subsequent Requests (Same Key)
The outcome depends on how the key was supplied:
- With the
Idempotency-Keyheader (the SDK path): the request is gated before the job handler runs, and the API returns a409 already_existserror. The original response is not replayed — fetch the job by ID (GetJob) to retrieve the result. - With the
idempotency_keybody field only (no header): the API returns the existing job directly, without creating a new one.
SDKs auto-inject idempotency keys
The official SDKs generate a UUID v4 Idempotency-Key automatically, so retrying within the same process is safe with no extra code. The scope differs by SDK: the Go SDK auto-injects on Create RPCs only, while the TypeScript and Python SDKs auto-inject on every mutating RPC (Create, Update, Delete, Cancel, Confirm, etc.), so those calls are idempotency-protected too. For cross-process safety (queue workers, cron jobs, retried RPCs), pass your own key — that survives a process restart whereas the auto-generated one does not.
curl -X POST https://api.transcodely.com/transcodely.v1.JobService/Create
-H "Authorization: Bearer {{API_KEY}}"
-H "X-Organization-ID: {{ORG_ID}}"
-H "Content-Type: application/json"
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000"
-d '{ "input_url": "...", "outputs": [] }'const job = await client.jobs.create({
inputUrl: "...",
outputs: [/* ... */],
idempotencyKey: `transcode_asset_${assetId}_v1`,
});client.jobs.create(
input_url="...",
outputs=[...],
idempotency_key=f"transcode_asset_{asset_id}_v1",
)import "google.golang.org/protobuf/proto"
job, err := client.Jobs.Create(ctx, &transcodely.JobCreateParams{
InputUrl: "...",
Outputs: []*transcodely.OutputSpec{/* ... */},
IdempotencyKey: proto.String("transcode_asset_42_v1"),
})To disable auto-injection across the entire Go client (e.g. when targeting a server that doesn’t yet support idempotency keys), use transcodely.WithAutoIdempotency(false) at construction. The TypeScript and Python SDKs auto-inject on every mutating RPC — pass an explicit key to override per call.
Key Format
Idempotency keys are free-form strings with a maximum length of 128 characters. We recommend using a format that ties the key to the specific operation:
| Strategy | Example | Best For |
|---|---|---|
| UUID v4 | 550e8400-e29b-41d4-a716-446655440000 | Simple, guaranteed uniqueness |
| Operation-based | upload_usr12345_2026-01-15T10:30:00Z | Readable, debuggable |
| Content hash | sha256:a1b2c3d4e5f6... | Deduplication based on input |
Recommended: Operation-Based Keys
{action}_{entity_id}_{timestamp}Examples:
transcode_vid_abc123_2026-01-15T10:30:00Z— ties to a specific uploadbatch_campaign_summer2026_chunk_42— ties to a specific batch itemretry_job_a1b2c3_attempt_3— explicit retry tracking
Scope
Idempotency keys are scoped to the app associated with the API key. The same key can be used independently across different apps without conflict.
Behavior on Replay
These outcomes apply to the recommended Idempotency-Key header path (what the official SDKs use). On any replay the server returns an error rather than the original response — fetch the job by ID to retrieve the result.
| Scenario | Behavior |
|---|---|
| Same key, same request body | 409 already_exists — the request is gated; re-fetch the job by ID |
| Same key, different request body | 409 already_exists with a distinct “different request body” message — the request body is compared, via a deterministic SHA-256 hash |
| Same key, different API key (same app) | 409 already_exists — keys are app-scoped, so the same key collides across API keys |
| Same key, different app | Creates a new job (keys are app-scoped) |
Important: The request body is compared on replay. Reusing a key with a different body does not silently return the original job — it returns a
409 already_existserror identifying the mismatch. Always use a unique key for each distinct operation.
Without the Idempotency-Key header (supplying only the idempotency_key body field), a same-body replay returns the original job directly, and a different body returns a 409 conflict.
Expiration
Expiration depends on which mechanism you use:
Idempotency-Keyheader — replay records are stored for 24 hours. After that, a previously used header key no longer gates the request.idempotency_keybody field — never expires on its own. The key is checked against existing jobs for the app, so it stays reserved for as long as the original job exists.
When to Use Idempotency Keys
- Network retries — your HTTP client automatically retries on timeout or connection reset
- Queue-based processing — a message queue may deliver the same message more than once
- User-triggered actions — a user clicks “Submit” multiple times before the UI disables the button
- Batch processing — processing a list of items where some may need to be retried
Example: Safe Retry Logic
The loops below are written for direct callers (raw Connect-RPC stubs). If you use one of the official SDKs, you get retry-with-jittered-backoff and auto-idempotency for free — see the SDK auto-injection section above.
What each example does:
- One key for every attempt. A single idempotency key is generated before the first try and reused on every retry, so a retry after a timeout can’t create a duplicate job.
- Client errors are not retried. The TypeScript, Python, and Go examples re-raise invalid-argument and not-found errors immediately — retrying them would never succeed.
- Transient errors back off exponentially. Every other failure is retried with a doubling delay (1s, 2s, 4s, …), capped at a fixed number of attempts (three retries by default in the SDK examples).
key="job_$(uuidgen)"
for attempt in 1 2 3 4; do
curl -sf -X POST https://api.transcodely.com/transcodely.v1.JobService/Create
-H "Authorization: Bearer {{API_KEY}}"
-H "X-Organization-ID: {{ORG_ID}}"
-H "Content-Type: application/json"
-H "Idempotency-Key: $key"
-d '{
"input_url": "gs://my-bucket/video.mp4",
"output_origin_id": "ori_x9y8z7w6v5",
"outputs": [{ "type": "mp4", "video": [{ "codec": "h264", "resolution": "1080p" }] }]
}' && break
sleep $((2 ** (attempt - 1)))
doneasync function createJobWithRetry(
client: JobServiceClient,
request: CreateJobRequest,
maxRetries = 3
): Promise<Job> {
// Generate a unique idempotency key for this operation
const idempotencyKey = `job_${crypto.randomUUID()}`;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await client.create({
...request,
idempotencyKey,
});
return response.job;
} catch (err) {
if (err instanceof ConnectError) {
// Don't retry on client errors
if (err.code === Code.InvalidArgument || err.code === Code.NotFound) {
throw err;
}
// Retry on transient errors
if (attempt < maxRetries) {
await sleep(Math.pow(2, attempt) * 1000); // Exponential backoff
continue;
}
}
throw err;
}
}
throw new Error('Max retries exceeded');
}import time
import uuid
from connectrpc.exceptions import ConnectError
def create_job_with_retry(client, request, max_retries=3):
idempotency_key = f"job_{uuid.uuid4()}"
for attempt in range(max_retries + 1):
try:
request.idempotency_key = idempotency_key
response = client.create(request)
return response.job
except ConnectError as e:
if e.code in ("invalid_argument", "not_found"):
raise # Don't retry client errors
if attempt < max_retries:
time.sleep(2 ** attempt) # Exponential backoff
continue
raisefunc createJobWithRetry(ctx context.Context, client *transcodely.Client, params *transcodely.JobCreateParams, maxRetries int) (*transcodely.Job, error) {
// Generate a unique idempotency key for this operation
params.IdempotencyKey = proto.String(fmt.Sprintf("job_%s", uuid.NewString()))
var err error
for attempt := 0; attempt <= maxRetries; attempt++ {
var job *transcodely.Job
job, err = client.Jobs.Create(ctx, params)
if err == nil {
return job, nil
}
// Don't retry on client errors
var invalid *transcodely.InvalidRequestError
var notFound *transcodely.NotFoundError
if errors.As(err, &invalid) || errors.As(err, ¬Found) {
return nil, err
}
// Retry on transient errors with exponential backoff
if attempt < maxRetries {
time.Sleep(time.Duration(1<<attempt) * time.Second)
}
}
return nil, err
}Best Practices
- Generate the key before the first attempt and reuse it across retries.
- Use descriptive, deterministic keys when possible — they make debugging easier.
- Never reuse a key for a different operation — always generate a new key for each distinct request.
- Store the key alongside your internal records so you can trace which Transcodely job maps to which internal entity.