Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions api-reference/comfy-router/limitations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: "What Comfy Router does not do today, what to use instead where an

<Note>
**Comfy Router is not generally available yet.** The routes referenced below
(`POST /v1/models/{provider}/{model}` and its catalog and schema siblings) are
(`POST /v2/models/{provider}/{model}` and its catalog and schema siblings) are
not serving requests yet: an authenticated call answers `404` today. This page
describes the contract they will serve, published ahead of that rollout so an
integration can be written against a known shape. Everything below is a
Expand All @@ -31,7 +31,7 @@ Each row links to the section that explains it. **Deliberate** means the limit i

## No queued submission

There is one way to run a model: `POST /v1/models/{provider}/{model}`, which holds the connection until the generation finishes and returns the result in the response. There is no endpoint that accepts a job, hands you an identifier and lets you collect the result later, and no callback or webhook on completion.
There is one way to run a model: `POST /v2/models/{provider}/{model}`, which holds the connection until the generation finishes and returns the result in the response. There is no endpoint that accepts a job, hands you an identifier and lets you collect the result later, and no callback or webhook on completion.

**What to do instead.** For most models this is a non-issue: keep the connection open and read the result. A fast image model returns in a few seconds; a long video generation can run for minutes, and Router will hold the connection for it. Set a generous client read timeout, above [Router's own deadline](#calls-are-cut-off-at-a-server-deadline), and treat the call as long-running rather than as a fast request. If your architecture genuinely cannot hold a connection open (a serverless function with a short execution ceiling, a browser tab you expect the user to close), then run the call from a worker you control that can, or use a partner-proxy route for a provider that exposes its own submit-and-poll pair. See [the last section](#router-does-not-cover-every-partner-operation).

Expand All @@ -49,16 +49,24 @@ A Router response tells you what the model produced, and its contract says nothi

Router does not keep a resumable record of an in-flight call. There is no status route, no job identifier, and nothing to reconnect to: if the connection drops mid-call (a client crash, a network partition, a deploy that restarts your process), the response is gone, and the call is not something you can ask about afterwards. Whether the *generation* completed and was charged is a separate question from whether you received it, and losing the connection does not reliably answer either.

**What to do instead.** Send an `Idempotency-Key` header on every call. It does not make a lost call resumable, but it makes retrying one safe. Router reserves the key for the duration of the call, and when the call actually reached you with an answer it records that response against the key for 24 hours; retrying with the **same** key then replays the recorded response instead of dispatching, and re-charging, the provider a second time, marked `Idempotent-Replayed: true` so you can tell a replay from a fresh run. Generate a fresh key per logical call, not per attempt; the same key presented with a *different* request body is a `409` rather than a silent overwrite.
**What to do instead.** Send an `Idempotency-Key` header on every call: the [quickstart](/api-reference/comfy-router/quickstart#retrying-safely-with-your-own-key) has the mechanics, including the step that is easiest to skip: persist the key before you send the request. It does not make a lost call resumable, but it makes retrying one safe. Router reserves the key for the duration of the call, and when the call actually reached you with an answer it records that response against the key for 24 hours; retrying with the **same** key then replays the recorded response instead of dispatching, and re-charging, the provider a second time, marked `Idempotent-Replayed: true` so you can tell a replay from a fresh run. Generate a fresh key per logical call, not per attempt; the same key presented with a *different* request (a different body, model path, query string or method) is a `409` rather than a silent overwrite.

Be precise about what that buys you, because it is a **billing** property and not a delivery one: **a key is charged at most once.** It is not a promise that a key is dispatched to the provider at most once. Router holds a key against an answer you actually received; the outcomes that charged you nothing release it so the call can be made again. A `5xx`, a `408`/`425`/`429`, and (this is the one that matters here) a call where nothing reached you at all: each of those releases the key, and a retry with it genuinely re-runs and re-dispatches the provider.
Be precise about what that buys you, because it is a **billing** property and not a delivery one: **a key is charged at most once.** It is not a promise that a key is dispatched to the provider at most once. Router holds a key against an answer you actually received; the outcomes that charged you nothing release it so the call can be made again. A `5xx`, a `408`/`425`/`429`, and (this is the one that matters here) a call where nothing reached you at all: each of those releases the key, and a retry with it genuinely re-runs and re-dispatches the provider. The one `5xx` that does *not* release is the cut-off with something to collect: a `deadline_exceeded` `504` that carries `Retry-After` means the provider had already accepted the generation when Router stopped waiting, and Router parks the key against that running job instead: re-send the **same** key after `Retry-After` to collect it, because a fresh key there is a second billed generation. A `504` without `Retry-After` had nothing to park and releases like the rest.

**So a dropped connection is the case idempotency does *not* rescue.** A connection lost mid-call usually means no response was ever committed to you, which is exactly the release path above: retrying with the same key starts a fresh run rather than handing you the result you missed, and if the original generation had already been dispatched the provider may run it a second time. That is the right default: an unbilled call you never received should be re-runnable, but plan for "retry produces a new run", not "retry collects the lost one".

When Router *does* hold something for the key, the retry is answered rather than re-run: either the original response replayed, or a `409` explaining why it cannot be. A retry sent while the original is still in flight is a `409` carrying `Retry-After`, so wait and re-send the same key. A retry against a call that completed but whose response Router could not keep a faithful copy of is also a `409`, and that is not only the oversized-response case: a response past the replay cap, a handler that failed or panicked after answering, and a write to you that failed or came up short all record the key as consumed-but-not-replayable and return the same `409`. Do not go hunting for a size problem when you see it. The guidance in every one of those cases is the same: use a **new** key. The original completed and was charged, and Router will neither invent its response nor re-run it under the old key.

<Note>
**Not yet in the generated contract.** The `Idempotency-Key` request header, the `409` response and the `Idempotent-Replayed` and `Retry-After` response headers described here are not declared on `POST /v1/models/{provider}/{model}` in the OpenAPI contract the reference is generated from, so they do not appear in the generated API reference and the SDKs do not model them. Send and read them yourself until they do.
**In the contract, with one gap.** The `Idempotency-Key` request header, the
`409` response and the `Idempotent-Replayed` and `Retry-After` response headers
described here are declared on `POST /v2/models/{provider}/{model}`, so they
appear in the generated [API reference](/api-reference/comfy-router/reference) and in the
specification the SDKs vendor, so an SDK picks them up when it regenerates. The
gap: `Retry-After` is
declared on the `409` and the `504` but **not** on the `rate_limited` `429`
described [below](#requests-are-rate-limited-per-caller), which sends it too:
read it there without waiting for the contract to say so.
</Note>

**Status: not yet.** Durable, resumable execution is expected to arrive with the queued path, which is where a request record has somewhere to live. Idempotent retry is the answer today and is not a stopgap; it is worth wiring in regardless.
Expand All @@ -77,17 +85,17 @@ Do not confuse it with the other `504`. `provider_timeout` is the partner failin

## Requests are rate limited per caller

Router bounds two different things about your traffic, and they answer with two different buckets on the same `429`. The concurrency limit caps how many calls you have **in flight** at once and answers `concurrency_limit_exceeded`; it clears the moment one of your own calls finishes, so retrying in seconds is right. The rate limit caps how **often** you may hit the Router surface at all (`POST /v1/models/{provider}/{model}` and the three catalog reads under `/v1/models` alike, whether the call ran a model or was refused before it could) and answers `rate_limited`. That one is an allowance that refills continuously over a one-minute window, so nothing you do drains it early: the response carries a `Retry-After` header with the seconds to wait, and `detail` names the window. Branch on `X-Comfy-Error-Type`, never on the status alone.
Router bounds two different things about your traffic, and they answer with two different buckets on the same `429`. The concurrency limit caps how many calls you have **in flight** at once and answers `concurrency_limit_exceeded`; it clears the moment one of your own calls finishes, so retrying in seconds is right. The rate limit caps how **often** you may hit the Router surface at all (`POST /v2/models/{provider}/{model}` and the three catalog reads under `/v2/models` alike, whether the call ran a model or was refused before it could) and answers `rate_limited`. That one is an allowance that refills continuously over a one-minute window, so nothing you do drains it early: the response carries a `Retry-After` header with the seconds to wait, and `detail` names the window. Branch on `X-Comfy-Error-Type`, never on the status alone.

The limit is keyed on the authenticated caller, not on the source address, so it follows your credential across hosts. A call that runs on your own provider key (bring-your-own-key) is exempt: you own that throughput. The allowance is a server-side configuration value rather than a published constant, and this page deliberately does not quote it; design for backoff, not for a number.

**What to do instead.** Honour `Retry-After`: a retry inside it lands on the same refusal. Fetch `GET /v1/models` and a model's `openapi.json` once and cache them for the life of your process rather than re-reading them ahead of every call; they change only on a deploy. A client that keeps a request identifier from a `429` has the artifact support can trace.
**What to do instead.** Honour `Retry-After`: a retry inside it lands on the same refusal. Fetch `GET /v2/models` and a model's `openapi.json` once and cache them for the life of your process rather than re-reading them ahead of every call; they change only on a deploy. A client that keeps a request identifier from a `429` has the artifact support can trace.

**Status: deliberate.** A per-caller bound on request rate has to exist for the same reason the deadline does. The number is tunable; the existence of the limit will not go away.

## No progress while a call runs

`POST /v1/models/{provider}/{model}` returns exactly once, at the end. There is no streaming response, no server-sent events, no percentage, no partial or preview frame. This holds even for partners whose own API is submit-and-poll: Router does that polling internally, inside your one call, and the intermediate states it sees are not forwarded to you. From the outside, a three-second image and a six-minute video are the same shape: one request, one response, nothing in between.
`POST /v2/models/{provider}/{model}` returns exactly once, at the end. There is no streaming response, no server-sent events, no percentage, no partial or preview frame. This holds even for partners whose own API is submit-and-poll: Router does that polling internally, inside your one call, and the intermediate states it sees are not forwarded to you. From the outside, a three-second image and a six-minute video are the same shape: one request, one response, nothing in between.

**What to do instead.** On Router today, nothing: show an indeterminate progress state rather than a percentage you cannot source. If progress is a hard requirement for a specific provider, check whether that provider's partner-proxy routes expose their own polling or streaming and use those directly: a few do, and they are unchanged and fully supported.

Expand Down
Loading
Loading