Skip to content

Retry, response size, and timeout policy differ by provider branch #23

Closed

Description

@oppih

The five provider branches do not share a transport policy, so which backend you configure decides whether a transient upstream failure is retried, how large a response is accepted, and whether a stalled connection is ever given up on.

Everything below is read off the current main (69ffb4b) and the installed client libraries; I have not seen any of this fail in production.

Retries are inherited from two libraries and absent from three branches

  • typesafe goes through @typesafe-ai/sdk (src/provider.ts:108), and the TypeSafe SDKs ship a retry policy — the API's own docs say 429/529 should be retried with exponential backoff and that "our client SDKs handle this automatically" (docs.typesafe.ai/api, Handling rate limits).
  • vercel goes through experimental_evaluate from ai (src/provider.ts:216), which calls prepareRetries({ maxRetries, abortSignal }) and wraps the model call in retry(...). In the installed version (ai@7.0.105) the retryable set is 408, 409, 429, and anything >= 500, with a default of 2 retries.
  • openrouter (src/provider.ts:132), compatible (:159), and cloudflare (:233) each issue a single fetch with no status handling: a 429 or a 503 becomes isError: true on the first answer. There is no retry helper anywhere in src/.

So the same question asked through JEV_PROVIDER=compatible gets one attempt while JEV_PROVIDER=vercel gets three, and the failure a caller sees for a rate limit depends on which backend they happen to have credentials for. The status classes involved are not exotic: OpenRouter and the Cloudflare AI gateway both return 429 and 5xx, and the TypeSafe-compatible path is frequently a proxy in front of the same endpoint the docs describe.

Nothing bounds the response body

All three fetch branches read the body whole — await response.json() at src/provider.ts:148, :175, and :244. There is no Content-Length check and no byte ceiling. For the direct and Cloudflare paths the upstream is TypeSafe's own token budget, but compatible reads JEV_API_BASE_URL, which is operator-supplied: a proxy that answers a 2xx with a large HTML page (or a misconfigured base URL pointing somewhere else) is buffered into memory in full, and the JSON parse then fails in a way the caller sees as an opaque error. For comparison, itsmostafa/typesafe-mcp added a 16 MiB ceiling in 0.4.2 and chose to reject oversized responses rather than truncate them, which is the same fail-closed instinct this repo applies to malformed answers.

Nothing sets a deadline

No branch creates its own timeout: signal is threaded in from the caller (src/provider.ts:104, :115) and the fetch calls pass it through, so an upstream that accepts the connection and stalls holds the tool call open until the MCP client's own request timeout fires. typesafe-mcp uses a 60 s HTTP timeout for the same call.

What I am asking

Is this asymmetry deliberate — each client library owns its own policy and the hand-rolled branches stay minimal — or would a small shared transport helper be welcome? If it is welcome, the smallest useful version would be:

  1. bounded attempts with jittered backoff on 429/5xx (and 408/409 to match the AI SDK), leaving every other status to the existing error path;
  2. one byte ceiling on the read, erroring rather than truncating, so a bad JEV_API_BASE_URL cannot turn into a memory problem;
  3. one deadline per request, so a hung upstream fails as a timeout rather than as silence.

If it is deliberate, a sentence in the README next to the provider table saying which branches inherit retries and which do not would settle it just as well — that is genuinely the only thing I need to know before deciding whether to send a PR for any of this.

Found while comparing the retry and response-bound behavior here against itsmostafa/typesafe-mcp, which implements the 429/529 backoff and the size ceiling and leaves it at that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions