Classify client retries at the front and recover ambiguous submits by operation key¶
Context and Problem Statement¶
The official OpenAI SDK retries 408, 409, 429, every 5xx, timeouts and connection errors up to twice, and does not
send an idempotency key. A front that answers a server-side timeout with 408, or a lost submit with 502, turns one
client call into up to three charged jobs. The SDK honours an x-should-retry response header. How does the front
avoid duplicate work, and what happens when the front does not learn whether a submit reached the Horde?
Decision Drivers¶
- Internal recovery of a submission the Horde may have accepted must not create another job.
- A client retry without a stable caller key cannot be shown to be the same logical request.
- A retry that is safe, because the request was not submitted, must stay allowed.
- The design cannot require a client change.
- A header reduces retries only when the client receives it.
Considered Options¶
- Retry classification with
x-should-retry: falseplus a front-generated operation key claimed at activation - Retry classification alone
- Caller-provided idempotency keys only
- Deduplication of client retries by a hash of the request content
Decision Outcome¶
Chosen option: "Retry classification with x-should-retry: false plus a front-generated operation key claimed at
activation".
The front distinguishes three states: not submitted, submitted with an id, and outcome unknown. Once the Horde holds a
request, or may hold one, every response includes x-should-retry: false, including a successful stream, a timeout
after submission and a transport failure after the request was sent. A response sent before submission, such as a
refused connection or the 408 for a slow request body, stays retriable.
The front generates a UUID for each client HTTP request and sends it as horde.operation_key. When a submit's response
is lost, the front sends the same body and key again, up to submit_recovery_attempts times (default 2) within the
request's budget.
The native chat route claims the key for the submitting user, or for the owner of the shared key when one is used:
- Before validation and admission,
find_existing_claimlooks the key up. A repeat with the same fingerprint returns 200 with the first request'sidandkudosand does not queue or charge anything. A repeat with a different fingerprint returns 400OperationKeyConflict. - An unclaimed key is written in
TextWaitingPrompt._activate(horde/classes/kobold/waiting_prompt.py) before the first commit, which also includes the activation and the horde tax. The partial unique index on(user_id, operation_key)lets one of two concurrent submissions commit. The other rolls back, its inactive row and upfront kudos hold are deleted, and it returns the winning request or the conflict. - The claim is held by the request row and lasts as long as the row. The same key from another user is a separate claim, and a dry run quotes without claiming.
The fingerprint is a SHA-256 digest of the stored messages, the mapped parameters, the sorted model list, the stored
chat_extras and the horde options. It leaves out the options that do not change the work
(OPERATION_FINGERPRINT_EXCLUDED_HORDE_KEYS in horde/classes/kobold/chat_request.py: availability_mode,
dry_run, include_reasoning, max_kudos and operation_key).
horde.operation_key is accepted from any native caller. The front refuses it in a public request body and always
supplies its own. The chat routes do not read an Idempotency-Key header.
Consequences¶
- Good: Recovering an ambiguous submit does not activate or tax a second request, and clients do not have to change.
- Good: A native caller, such as a proxy or bot, can recover its own ambiguous submits with a stable key.
- Bad: Each client HTTP request gets a new key, so a client retry after a lost response, a front crash or a retry routed to another front process can still create charged work. The front does not promise exactly-once delivery.
- Bad: The header reduces retries only for clients that receive and honour it.
- Bad: Two columns,
operation_keyandoperation_fingerprint, and a unique index onwaiting_prompts. - Bad: The claim ends with the request row, so recovery after the row is deleted creates a new request. The front's recovery runs within one client call.
Pros and Cons of the Options¶
Classification plus operation key¶
- Good: Gives header-reading clients useful guidance and makes internal ambiguous submits recoverable.
- Bad: Cannot recognise a fresh client retry without a stable logical-request key.
- Bad: The claim has to be atomic with activation and tax in an activation path that commits in more than one place.
Classification alone¶
- Good: Small.
- Bad: Does not help with a lost submit response or a client that ignores the header.
Caller keys only¶
- Good: A standard shape with caller-controlled scope.
- Bad: The official SDK does not send one, so most clients would not be protected.
Deduplication by request content¶
- Good: Catches a client retry without a key.
- Bad: Two identical requests from one user are legitimate, for example a regeneration, and would be merged.
Confirmation¶
tests/integration/test_chat.py pins the operation key: a repeat returns the first request, also while admission is
off and after its model went offline. A changed request is refused. Reordered models are the same operation. Two users
make two requests. A dry run does not claim. Two concurrent submits with one key make one activation, one tax and
one job. tests/unit/oai/test_submit_recovery.py pins the front's recovery, its bounds, a fresh key per client request
and the retry-hint metric. tests/unit/oai/test_errors.py and tests/unit/oai/test_chat_front.py pin the
x-should-retry header. docs/reference/chat_requests.md records the operation key and its fingerprint.