From 9e095c7d807806672b0e345539133d2f5a49c362 Mon Sep 17 00:00:00 2001 From: Dominik Madarasz Date: Fri, 30 Oct 2026 07:52:19 +0100 Subject: [PATCH 1/4] fix: keep batchId and accept replayed batches The API lets a client retry POST /batches safely by sending a batchId: a retry creates only the missing jobs and answers 200 with status 'replayed' or 'partial'. The vendored spec did not describe batchId or the 200 response, so the CLI dropped batchId from the request and reported a replay as an error. The batch schemas and responses are synced from nexrender-api and the client regenerated. `batch submit` keeps batchId (and each job's batchItemId), treats 200 as success with a summary that says what the retry did, and on 503 with a batchId tells the user to run the same command again. The README explains how to make a batch retry-safe. Found by Vanguard. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ internal/api/api.gen.go | 46 ++++++++++++++++++++++---- internal/command/batch.go | 21 ++++++++++-- internal/command/command_test.go | 31 +++++++++++++++++ openapi/openapi.json | 57 +++++++++++++++++++++++++++++--- 5 files changed, 144 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index caeee51..d4abb02 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,8 @@ nexrender batch submit --file campaign.json nexrender batch wait BATCH_ID ``` +To make a batch safe to resubmit, give it a `batchId` (a ULID you choose) and, optionally, each job a `batchItemId`. Submitting the same file again creates only the jobs that are missing. + Fonts and encrypted secrets are along for the ride too: ```bash diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index b674671..821ca36 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -38,9 +38,10 @@ const ( // Defines values for BatchCreationResponseStatus. const ( - BatchCreationResponseStatusCreated BatchCreationResponseStatus = "created" - BatchCreationResponseStatusError BatchCreationResponseStatus = "error" - BatchCreationResponseStatusPartial BatchCreationResponseStatus = "partial" + BatchCreationResponseStatusCreated BatchCreationResponseStatus = "created" + BatchCreationResponseStatusError BatchCreationResponseStatus = "error" + BatchCreationResponseStatusPartial BatchCreationResponseStatus = "partial" + BatchCreationResponseStatusReplayed BatchCreationResponseStatus = "replayed" ) // Defines values for BatchStatusResponseStatus. @@ -234,7 +235,10 @@ type BatchCancellationResponseStatus string // BatchCreation Configuration for creating a batch of render jobs in a single request (1 to 1000 jobs) type BatchCreation struct { - // Jobs Array of job configurations to create as a batch. Each item follows the same schema as a single job creation request. + // BatchId Optional client-chosen ULID that makes the request safe to retry. Sending a batch again with the same batchId creates only the jobs that are not in it yet: the response is 200 with status 'replayed' when nothing was missing, or 'partial' when the missing jobs were created. Without it the API generates one, and a retry creates a new batch. + BatchId *string `json:"batchId,omitempty"` + + // Jobs Array of job configurations to create as a batch. Each item follows the same schema as a single job creation request, plus an optional `batchItemId` string (at most 255 characters) that identifies the job within the batch when it is retried. Items without one are matched by their position. Jobs []JobCreation `json:"jobs"` } @@ -243,6 +247,12 @@ type BatchCreationResponse struct { // BatchId Unique batch identifier in ULID format for tracking the entire batch BatchId string `json:"batchId"` + // Error Present on 409 and 503 responses: why the batch could not be completed + Error *string `json:"error,omitempty"` + + // ErrorCode Present on 409 and 503 responses, for example BATCH_ALREADY_PARTIAL or BATCH_RETRYABLE + ErrorCode *string `json:"errorCode,omitempty"` + // Errors Array of errors for failed jobs (only present when status is 'partial' or 'error') Errors *[]struct { // Error Error message describing why the job failed @@ -281,7 +291,7 @@ type BatchCreationResponse struct { Status *string `json:"status,omitempty"` } `json:"jobs"` - // Status 'created' if all jobs succeeded, 'partial' if some failed, 'error' if all failed + // Status 'created' if all jobs succeeded, 'partial' if some failed or a retry created only the missing jobs, 'error' if all failed, 'replayed' if a retry with the same batchId found every job already created Status BatchCreationResponseStatus `json:"status"` // SuccessCount Number of successfully created jobs @@ -291,7 +301,7 @@ type BatchCreationResponse struct { TotalJobs int `json:"totalJobs"` } -// BatchCreationResponseStatus 'created' if all jobs succeeded, 'partial' if some failed, 'error' if all failed +// BatchCreationResponseStatus 'created' if all jobs succeeded, 'partial' if some failed or a retry created only the missing jobs, 'error' if all failed, 'replayed' if a retry with the same batchId found every job already created type BatchCreationResponseStatus string // BatchStatusResponse Current status and progress of a batch of jobs @@ -3922,10 +3932,13 @@ type ClientWithResponsesInterface interface { type CreateBatchResponse struct { Body []byte HTTPResponse *http.Response + JSON200 *BatchCreationResponse JSON201 *BatchCreationResponse JSON400 *BatchCreationResponse JSON401 *ErrorResponse JSON403 *ErrorResponse + JSON409 *BatchCreationResponse + JSON503 *BatchCreationResponse } // Status returns HTTPResponse.Status @@ -4930,6 +4943,13 @@ func ParseCreateBatchResponse(rsp *http.Response) (*CreateBatchResponse, error) } switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest BatchCreationResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: var dest BatchCreationResponse if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -4958,6 +4978,20 @@ func ParseCreateBatchResponse(rsp *http.Response) (*CreateBatchResponse, error) } response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest BatchCreationResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest BatchCreationResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON503 = &dest + } return response, nil diff --git a/internal/command/batch.go b/internal/command/batch.go index 1d7f63a..80c9100 100644 --- a/internal/command/batch.go +++ b/internal/command/batch.go @@ -58,10 +58,25 @@ func newBatchSubmitCommand(state *State) *cobra.Command { if err != nil { return client.NetworkError(err) } - if response.StatusCode() != http.StatusCreated || response.JSON201 == nil { - return client.HTTPError(response.StatusCode(), response.Body, responseHeaders(response.HTTPResponse)) + switch { + case response.StatusCode() == http.StatusCreated && response.JSON201 != nil: + return state.Printer(state.Out).Success(*response.JSON201, "Submitted batch "+response.JSON201.BatchId, nil) + case response.StatusCode() == http.StatusOK && response.JSON200 != nil: + // A retry with a batchId the API already knows: it created only what was missing. + result := *response.JSON200 + summary := fmt.Sprintf("Batch %s already existed; created the missing jobs (%d of %d now exist)", result.BatchId, result.SuccessCount, result.TotalJobs) + if result.Status == api.BatchCreationResponseStatusReplayed { + summary = "Batch " + result.BatchId + " already existed; no new jobs were created" + } + return state.Printer(state.Out).Success(result, summary, nil) + } + failure := client.HTTPError(response.StatusCode(), response.Body, responseHeaders(response.HTTPResponse)) + if response.StatusCode() == http.StatusServiceUnavailable && payload.BatchId != nil { + if structured, ok := failure.(*clierr.Error); ok { + structured.Hint = "Some jobs may already exist. Run the same command again with the same batchId; only the missing jobs are created." + } } - return state.Printer(state.Out).Success(*response.JSON201, "Submitted batch "+response.JSON201.BatchId, nil) + return failure }, } cmd.Flags().StringVarP(&file, "file", "f", "-", "batch JSON file, or - for stdin") diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 352d733..afb8368 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -80,6 +80,37 @@ func TestJobListUsesV2AndBearerAuth(t *testing.T) { } } +func TestBatchSubmitKeepsBatchIDAndAcceptsReplay(t *testing.T) { + var sent map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v2/batches" { + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&sent); err != nil { + t.Fatal(err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + io.WriteString(w, `{"batchId":"01ARZ3NDEKTSV4RRFFQ69G5FAV","status":"replayed","totalJobs":1,"successCount":1,"jobs":[{"index":0,"id":"01ARZ3NDEKTSV4RRFFQ69G5FA0","status":"queued","outputUrl":"https://out.example.test/a.mp4"}]}`) + })) + defer server.Close() + batch := `{"batchId":"01ARZ3NDEKTSV4RRFFQ69G5FAV","jobs":[{"template":{"id":"01ARZ3NDEKTSV4RRFFQ69G5FAW","composition":"Main"},"batchItemId":"hero"}]}` + state, out, _ := testState(t, server.URL+"/api/v2", strings.NewReader(batch)) + if code := Execute(context.Background(), state, []string{"--json", "batch", "submit"}); code != 0 { + t.Fatalf("exit = %d, output = %s", code, out.String()) + } + if sent["batchId"] != "01ARZ3NDEKTSV4RRFFQ69G5FAV" { + t.Fatalf("batchId was not sent: %v", sent) + } + jobs, _ := sent["jobs"].([]any) + if len(jobs) != 1 || jobs[0].(map[string]any)["batchItemId"] != "hero" { + t.Fatalf("batchItemId was not sent: %v", sent["jobs"]) + } + if !strings.Contains(out.String(), `"replayed"`) || !strings.Contains(out.String(), "no new jobs were created") { + t.Fatalf("unexpected output: %s", out.String()) + } +} + func TestTemplateUploadDoesNotForwardBearer(t *testing.T) { var uploaded []byte uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/openapi/openapi.json b/openapi/openapi.json index 04c4af8..de88d05 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -921,9 +921,14 @@ "type": "object", "description": "Configuration for creating a batch of render jobs in a single request (1 to 1000 jobs)", "properties": { + "batchId": { + "type": "string", + "pattern": "^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$", + "description": "Optional client-chosen ULID that makes the request safe to retry. Sending a batch again with the same batchId creates only the jobs that are not in it yet: the response is 200 with status 'replayed' when nothing was missing, or 'partial' when the missing jobs were created. Without it the API generates one, and a retry creates a new batch." + }, "jobs": { "type": "array", - "description": "Array of job configurations to create as a batch. Each item follows the same schema as a single job creation request.", + "description": "Array of job configurations to create as a batch. Each item follows the same schema as a single job creation request, plus an optional `batchItemId` string (at most 255 characters) that identifies the job within the batch when it is retried. Items without one are matched by their position.", "items": { "$ref": "#/components/schemas/JobCreation" }, @@ -943,8 +948,8 @@ }, "status": { "type": "string", - "enum": ["created", "partial", "error"], - "description": "'created' if all jobs succeeded, 'partial' if some failed, 'error' if all failed" + "enum": ["created", "partial", "error", "replayed"], + "description": "'created' if all jobs succeeded, 'partial' if some failed or a retry created only the missing jobs, 'error' if all failed, 'replayed' if a retry with the same batchId found every job already created" }, "totalJobs": { "type": "integer", @@ -954,6 +959,14 @@ "type": "integer", "description": "Number of successfully created jobs" }, + "error": { + "type": "string", + "description": "Present on 409 and 503 responses: why the batch could not be completed" + }, + "errorCode": { + "type": "string", + "description": "Present on 409 and 503 responses, for example BATCH_ALREADY_PARTIAL or BATCH_RETRYABLE" + }, "jobs": { "type": "array", "description": "Array of successfully created job details", @@ -3161,8 +3174,44 @@ } } }, + "200": { + "description": "A retry with an existing batchId. Status 'replayed' when every job already existed, 'partial' when the missing jobs were created now.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchCreationResponse" + } + } + } + }, "400": { - "description": "Invalid request or all jobs in the batch failed", + "description": "Invalid request (including INVALID_BATCH_ID) or all jobs in the batch failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchCreationResponse" + } + } + } + }, + "409": { + "description": "BATCH_ALREADY_PARTIAL: a retry with an existing batchId could not reconcile the jobs already in the batch with the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchCreationResponse" + } + } + } + }, + "503": { + "description": "BATCH_RETRYABLE: the database was briefly unavailable part-way through. The jobs created so far are listed. Retry with the same batchId after Retry-After seconds to create only the rest.", + "headers": { + "Retry-After": { + "description": "Seconds to wait before retrying", + "schema": { "type": "integer" } + } + }, "content": { "application/json": { "schema": { From 0944fb07a3a2267e6c23a6fe65fb7392d407c5d9 Mon Sep 17 00:00:00 2001 From: Dominik Madarasz Date: Fri, 30 Oct 2026 07:57:01 +0100 Subject: [PATCH 2/4] docs: say how long batch retries are recognised Synced from nexrender-api: retries are recognised for 30 days, while the batch's jobs are kept. Co-Authored-By: Claude Opus 5.5 --- internal/api/api.gen.go | 2 +- openapi/openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index 821ca36..aa6913f 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -235,7 +235,7 @@ type BatchCancellationResponseStatus string // BatchCreation Configuration for creating a batch of render jobs in a single request (1 to 1000 jobs) type BatchCreation struct { - // BatchId Optional client-chosen ULID that makes the request safe to retry. Sending a batch again with the same batchId creates only the jobs that are not in it yet: the response is 200 with status 'replayed' when nothing was missing, or 'partial' when the missing jobs were created. Without it the API generates one, and a retry creates a new batch. + // BatchId Optional client-chosen ULID that makes the request safe to retry. Sending a batch again with the same batchId creates only the jobs that are not in it yet: the response is 200 with status 'replayed' when nothing was missing, or 'partial' when the missing jobs were created. Retries are recognised for 30 days, while the batch's jobs are kept. Without it the API generates one, and a retry creates a new batch. BatchId *string `json:"batchId,omitempty"` // Jobs Array of job configurations to create as a batch. Each item follows the same schema as a single job creation request, plus an optional `batchItemId` string (at most 255 characters) that identifies the job within the batch when it is retried. Items without one are matched by their position. diff --git a/openapi/openapi.json b/openapi/openapi.json index de88d05..990cdc3 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -924,7 +924,7 @@ "batchId": { "type": "string", "pattern": "^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$", - "description": "Optional client-chosen ULID that makes the request safe to retry. Sending a batch again with the same batchId creates only the jobs that are not in it yet: the response is 200 with status 'replayed' when nothing was missing, or 'partial' when the missing jobs were created. Without it the API generates one, and a retry creates a new batch." + "description": "Optional client-chosen ULID that makes the request safe to retry. Sending a batch again with the same batchId creates only the jobs that are not in it yet: the response is 200 with status 'replayed' when nothing was missing, or 'partial' when the missing jobs were created. Retries are recognised for 30 days, while the batch's jobs are kept. Without it the API generates one, and a retry creates a new batch." }, "jobs": { "type": "array", From d3a8274ce1ee172e3b1a8dabd1c24e46f4d3ae21 Mon Sep 17 00:00:00 2001 From: Dominik Madarasz Date: Fri, 30 Oct 2026 08:04:09 +0100 Subject: [PATCH 3/4] fix: report replayed batches accurately A retry can answer 200 partial while still listing jobs that could not be created; the summary now says so instead of claiming the missing jobs were created. The README and the vendored spec say to send a retry only after the previous attempt has finished, since overlapping requests with the same batchId can both create jobs, and that batchItemIds must be unique. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- internal/api/api.gen.go | 2 +- internal/command/batch.go | 22 +++++++++++++++++----- internal/command/command_test.go | 16 ++++++++++++++++ openapi/openapi.json | 2 +- 5 files changed, 36 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d4abb02..ab14566 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ nexrender batch submit --file campaign.json nexrender batch wait BATCH_ID ``` -To make a batch safe to resubmit, give it a `batchId` (a ULID you choose) and, optionally, each job a `batchItemId`. Submitting the same file again creates only the jobs that are missing. +To make a batch safe to resubmit, give it a `batchId` (a ULID you choose) and, optionally, each job a unique `batchItemId`. Submitting the same file again, after the first attempt has finished or failed, creates only the jobs that are missing. Fonts and encrypted secrets are along for the ride too: diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index aa6913f..f383c5d 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -235,7 +235,7 @@ type BatchCancellationResponseStatus string // BatchCreation Configuration for creating a batch of render jobs in a single request (1 to 1000 jobs) type BatchCreation struct { - // BatchId Optional client-chosen ULID that makes the request safe to retry. Sending a batch again with the same batchId creates only the jobs that are not in it yet: the response is 200 with status 'replayed' when nothing was missing, or 'partial' when the missing jobs were created. Retries are recognised for 30 days, while the batch's jobs are kept. Without it the API generates one, and a retry creates a new batch. + // BatchId Optional client-chosen ULID that makes the request safe to retry. Sending a batch again with the same batchId creates only the jobs that are not in it yet: the response is 200 with status 'replayed' when nothing was missing, or 'partial' when the missing jobs were created. Retries are recognised for 30 days, while the batch's jobs are kept. Send a retry only after the previous request has returned or timed out: two requests with the same batchId in flight at once can both create jobs. Without it the API generates one, and a retry creates a new batch. BatchId *string `json:"batchId,omitempty"` // Jobs Array of job configurations to create as a batch. Each item follows the same schema as a single job creation request, plus an optional `batchItemId` string (at most 255 characters) that identifies the job within the batch when it is retried. Items without one are matched by their position. diff --git a/internal/command/batch.go b/internal/command/batch.go index 80c9100..9869b92 100644 --- a/internal/command/batch.go +++ b/internal/command/batch.go @@ -64,11 +64,7 @@ func newBatchSubmitCommand(state *State) *cobra.Command { case response.StatusCode() == http.StatusOK && response.JSON200 != nil: // A retry with a batchId the API already knows: it created only what was missing. result := *response.JSON200 - summary := fmt.Sprintf("Batch %s already existed; created the missing jobs (%d of %d now exist)", result.BatchId, result.SuccessCount, result.TotalJobs) - if result.Status == api.BatchCreationResponseStatusReplayed { - summary = "Batch " + result.BatchId + " already existed; no new jobs were created" - } - return state.Printer(state.Out).Success(result, summary, nil) + return state.Printer(state.Out).Success(result, replaySummary(result), nil) } failure := client.HTTPError(response.StatusCode(), response.Body, responseHeaders(response.HTTPResponse)) if response.StatusCode() == http.StatusServiceUnavailable && payload.BatchId != nil { @@ -189,3 +185,19 @@ func newBatchCancelCommand(state *State) *cobra.Command { cmd.Flags().BoolVarP(&yes, "yes", "y", false, "confirm cancellation") return cmd } + +// replaySummary describes what a retry with a known batchId did. A partial replay can still +// report jobs that could not be created, as a 201 partial batch does. +func replaySummary(result api.BatchCreationResponse) string { + if result.Status == api.BatchCreationResponseStatusReplayed { + return "Batch " + result.BatchId + " already existed; no new jobs were created" + } + failed := 0 + if result.Errors != nil { + failed = len(*result.Errors) + } + if failed > 0 { + return fmt.Sprintf("Batch %s already existed; created some missing jobs, but %d could not be created (see errors). %d of %d jobs exist", result.BatchId, failed, result.SuccessCount, result.TotalJobs) + } + return fmt.Sprintf("Batch %s already existed; created the missing jobs (%d of %d now exist)", result.BatchId, result.SuccessCount, result.TotalJobs) +} diff --git a/internal/command/command_test.go b/internal/command/command_test.go index afb8368..747f404 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -111,6 +111,22 @@ func TestBatchSubmitKeepsBatchIDAndAcceptsReplay(t *testing.T) { } } +func TestBatchReplaySummaryReportsJobsThatStillFailed(t *testing.T) { + var result api.BatchCreationResponse + body := `{"batchId":"01ARZ3NDEKTSV4RRFFQ69G5FAV","status":"partial","totalJobs":3,"successCount":2,"jobs":[],"errors":[{"index":2,"error":"Template not found","errorCode":"TEMPLATE_NOT_FOUND"}]}` + if err := json.Unmarshal([]byte(body), &result); err != nil { + t.Fatal(err) + } + summary := replaySummary(result) + if !strings.Contains(summary, "1 could not be created") || !strings.Contains(summary, "2 of 3 jobs exist") { + t.Fatalf("summary does not report the failure: %q", summary) + } + result.Errors = nil + if summary := replaySummary(result); !strings.Contains(summary, "created the missing jobs (2 of 3 now exist)") { + t.Fatalf("unexpected summary without errors: %q", summary) + } +} + func TestTemplateUploadDoesNotForwardBearer(t *testing.T) { var uploaded []byte uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/openapi/openapi.json b/openapi/openapi.json index 990cdc3..9afc7f3 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -924,7 +924,7 @@ "batchId": { "type": "string", "pattern": "^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$", - "description": "Optional client-chosen ULID that makes the request safe to retry. Sending a batch again with the same batchId creates only the jobs that are not in it yet: the response is 200 with status 'replayed' when nothing was missing, or 'partial' when the missing jobs were created. Retries are recognised for 30 days, while the batch's jobs are kept. Without it the API generates one, and a retry creates a new batch." + "description": "Optional client-chosen ULID that makes the request safe to retry. Sending a batch again with the same batchId creates only the jobs that are not in it yet: the response is 200 with status 'replayed' when nothing was missing, or 'partial' when the missing jobs were created. Retries are recognised for 30 days, while the batch's jobs are kept. Send a retry only after the previous request has returned or timed out: two requests with the same batchId in flight at once can both create jobs. Without it the API generates one, and a retry creates a new batch." }, "jobs": { "type": "array", From 719f60f8d759e6615550fe1ea80ed6797b599615 Mon Sep 17 00:00:00 2001 From: Dominik Madarasz Date: Fri, 30 Oct 2026 08:11:10 +0100 Subject: [PATCH 4/4] docs: say that a client timeout does not make a batch retry safe Synced from nexrender-api. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- internal/api/api.gen.go | 2 +- openapi/openapi.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ab14566..10a931c 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ nexrender batch submit --file campaign.json nexrender batch wait BATCH_ID ``` -To make a batch safe to resubmit, give it a `batchId` (a ULID you choose) and, optionally, each job a unique `batchItemId`. Submitting the same file again, after the first attempt has finished or failed, creates only the jobs that are missing. +To make a batch safe to resubmit, give it a `batchId` (a ULID you choose) and, optionally, each job a unique `batchItemId`. Submitting the same file again creates only the jobs that are missing. Wait until the first attempt has finished on the server first: a local timeout is not enough, so check `nexrender batch show BATCH_ID` until it stops changing. Fonts and encrypted secrets are along for the ride too: diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index f383c5d..bbbc26a 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -235,7 +235,7 @@ type BatchCancellationResponseStatus string // BatchCreation Configuration for creating a batch of render jobs in a single request (1 to 1000 jobs) type BatchCreation struct { - // BatchId Optional client-chosen ULID that makes the request safe to retry. Sending a batch again with the same batchId creates only the jobs that are not in it yet: the response is 200 with status 'replayed' when nothing was missing, or 'partial' when the missing jobs were created. Retries are recognised for 30 days, while the batch's jobs are kept. Send a retry only after the previous request has returned or timed out: two requests with the same batchId in flight at once can both create jobs. Without it the API generates one, and a retry creates a new batch. + // BatchId Optional client-chosen ULID that makes the request safe to retry. Sending a batch again with the same batchId creates only the jobs that are not in it yet: the response is 200 with status 'replayed' when nothing was missing, or 'partial' when the missing jobs were created. Retries are recognised for 30 days, while the batch's jobs are kept. Requests with the same batchId must not run at the same time: two that overlap on the server can both create jobs. A timeout on your side does not mean the server stopped processing, so after a timeout wait until GET /batches/{id} stops changing before retrying. Without it the API generates one, and a retry creates a new batch. BatchId *string `json:"batchId,omitempty"` // Jobs Array of job configurations to create as a batch. Each item follows the same schema as a single job creation request, plus an optional `batchItemId` string (at most 255 characters) that identifies the job within the batch when it is retried. Items without one are matched by their position. diff --git a/openapi/openapi.json b/openapi/openapi.json index 9afc7f3..9f4a16e 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -924,7 +924,7 @@ "batchId": { "type": "string", "pattern": "^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$", - "description": "Optional client-chosen ULID that makes the request safe to retry. Sending a batch again with the same batchId creates only the jobs that are not in it yet: the response is 200 with status 'replayed' when nothing was missing, or 'partial' when the missing jobs were created. Retries are recognised for 30 days, while the batch's jobs are kept. Send a retry only after the previous request has returned or timed out: two requests with the same batchId in flight at once can both create jobs. Without it the API generates one, and a retry creates a new batch." + "description": "Optional client-chosen ULID that makes the request safe to retry. Sending a batch again with the same batchId creates only the jobs that are not in it yet: the response is 200 with status 'replayed' when nothing was missing, or 'partial' when the missing jobs were created. Retries are recognised for 30 days, while the batch's jobs are kept. Requests with the same batchId must not run at the same time: two that overlap on the server can both create jobs. A timeout on your side does not mean the server stopped processing, so after a timeout wait until GET /batches/{id} stops changing before retrying. Without it the API generates one, and a retry creates a new batch." }, "jobs": { "type": "array",