Skip to content

Remove OpenAPI timeout parameter, make client optional, surface timeout errors#3067

Merged
jlowin merged 2 commits intomainfrom
fix/openapi-timeout-handling
Feb 4, 2026
Merged

Remove OpenAPI timeout parameter, make client optional, surface timeout errors#3067
jlowin merged 2 commits intomainfrom
fix/openapi-timeout-handling

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Feb 3, 2026

OpenAPI tools generated from specs were hitting httpx's default 5-second timeout with no useful error message — just "Request error: " with nothing after the colon. This came from httpx.ReadTimeout stringifying to an empty string.

The timeout parameter on OpenAPIProvider / FastMCP.from_openapi() / from_fastapi() was accepted and stored but never actually applied to the HTTP call for tools (it worked for resources, inconsistently). Rather than fix a broken parameter, timeout is now controlled entirely through the httpx client, which is how httpx is designed to work.

The client parameter is now optional — when omitted, a default client is created from the spec's servers[0].url with a 30-second timeout:

# Just provide the spec — client auto-created with 30s timeout
provider = OpenAPIProvider(openapi_spec=spec)

# Or bring your own client for full control
client = httpx.AsyncClient(base_url="...", timeout=60)
provider = OpenAPIProvider(openapi_spec=spec, client=client)

Timeout errors now surface clearly as "HTTP request timed out (ReadTimeout)" instead of the empty message.

Breaking: timeout parameter removed from OpenAPIProvider, FastMCP.from_openapi(), FastMCP.from_fastapi(), and FastMCPOpenAPI.

@jlowin jlowin added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. breaking change Breaks backward compatibility. Requires minor version bump. Critical for maintainer attention. openapi Related to OpenAPI integration, parsing, or code generation features. labels Feb 3, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 38390ce788

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +97 to 99
if client is None:
client = self._create_default_client(openapi_spec)
self._client = client
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Close default AsyncClient on provider shutdown

When client is omitted, the provider constructs its own httpx.AsyncClient, but OpenAPIProvider does not override lifespan() to close it, so servers created with the default client will leave connections open on shutdown (often emitting ResourceWarning and leaking sockets in long-lived processes). Consider tracking ownership of the created client and calling await client.aclose() in a provider lifespan teardown.

Useful? React with 👍 / 👎.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 3, 2026

Walkthrough

This PR removes the timeout parameter from the OpenAPI configuration across FastMCP: constructors and factory methods no longer accept timeout. The provider and component constructors now accept an optional httpx.AsyncClient (provider may create a default client using the OpenAPI spec’s first server URL with a 30-second timeout). Per-call timeout arguments were removed, timeout-related attributes deleted, and httpx timeout/request errors are now caught and re-raised with enriched messages. Documentation was updated with a Breaking Changes note and migration examples.

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed Title accurately summarizes the three main changes: removing the timeout parameter, making the client optional, and improving timeout error visibility.
Description check ✅ Passed Description addresses the context (httpx timeout issues), rationale (timeout control moved to client), implementation (optional client with default 30s timeout), and breaking changes clearly.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/openapi-timeout-handling

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Comment on lines +162 to +173
@classmethod
def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient:
"""Create a default httpx client from the OpenAPI spec's server URL."""
servers = openapi_spec.get("servers", [])
if not servers or not servers[0].get("url"):
raise ValueError(
"No server URL found in OpenAPI spec. Either add a 'servers' "
"entry to the spec or provide an httpx.AsyncClient explicitly."
)
base_url = servers[0]["url"]
return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

httpx.AsyncClient base_url absolute relative URL requirements

💡 Result:

  • base_url on httpx.AsyncClient is used only to resolve request URLs that are relative (no scheme/host). It’s “a URL to use as the base when building request URLs”, and the request url is “merged with any base_url set on the client.” [1]
  • If you pass an absolute request URL (e.g. https://api.example.com/v1/users), it does not need base_url and will effectively ignore it (no “merging” needed). [1]
  • If you pass a relative request URL (e.g. "/v1/users" or "v1/users"), you must set base_url (common example: in ASGI tests, base_url="http://testserver"), otherwise relative URLs like "/" won’t work. [2]
  • Practically, base_url should itself be an absolute URL (include scheme + host, like https://api.example.com), because it’s the base used to construct the final absolute request URL. [1], [2]

Sources: HTTPX API reference for AsyncClient/build_request and base_url merging behavior [1]; Starlette docs noting you must set base_url for relative URLs with httpx.AsyncClient [2].


🌐 Web query:

httpx base_url template variables behavior

💡 Result:

httpx does not support “URL template variables” (like {version} / {id}) in base_url (or request URLs). It treats { and } as literal characters in the URL, and—because they’re not generally safe URL characters—clients commonly percent-encode them to %7B and %7D rather than interpreting them as placeholders. [2], [3]

What base_url does do is standard base + relative URL resolution: it prepends a base to outgoing requests when you pass a relative URL (e.g. client.get("/headers")). [1], [2]

So, instead of:

httpx.Client(base_url="https://api.example.com/{version}")
client.get("/users/{id}")

you should fill variables yourself first, e.g.:

version = "v1"
user_id = 123
with httpx.Client(base_url=f"https://api.example.com/{version}") as client:
    r = client.get(f"/users/{user_id}")

Sources: HTTPX base_url client behavior [1], HTTPX URL parsing/encoding behavior [2], curly braces are typically percent-encoded in URLs [3].

[1] https://www.python-httpx.org/advanced/clients/
[2] https://www.encode.io/httpnext/urls
[3] https://stackoverflow.com/questions/53766725/curl-does-not-work-with-urls-with-curly-braces-in-parameters


Handle templated and relative server URLs before constructing the default client.

OpenAPI server URLs can be templated (e.g., "{protocol}://api.example.com/{version}") or relative (e.g., "/api"). The httpx.AsyncClient requires base_url to be absolute and does not support URL template variables—passing templated URLs will cause them to be percent-encoded as literal characters, resulting in invalid requests. Relative URLs will also cause failures.

Detect and resolve server URL variables against the OpenAPI variables object, validate that the result is absolute, and raise a clear error if not possible so users know to pass a client explicitly.

🔧 Suggested hardening for default client creation
+from string import Formatter
@@
     `@classmethod`
     def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient:
         """Create a default httpx client from the OpenAPI spec's server URL."""
         servers = openapi_spec.get("servers", [])
-        if not servers or not servers[0].get("url"):
-            raise ValueError(
-                "No server URL found in OpenAPI spec. Either add a 'servers' "
-                "entry to the spec or provide an httpx.AsyncClient explicitly."
-            )
-        base_url = servers[0]["url"]
+        if not servers or not servers[0].get("url"):
+            raise ValueError("Missing OpenAPI server URL; provide an AsyncClient.")
+        server = servers[0]
+        base_url = server["url"]
+        if "{" in base_url:
+            variables = server.get("variables") or {}
+            formatter = Formatter()
+            parts: list[str] = []
+            for literal, field_name, *_ in formatter.parse(base_url):
+                parts.append(literal)
+                if field_name:
+                    default = variables.get(field_name, {}).get("default")
+                    if default is None:
+                        raise ValueError(
+                            f"Server URL variable '{field_name}' has no default; "
+                            "provide an AsyncClient explicitly."
+                        )
+                    parts.append(str(default))
+            base_url = "".join(parts)
+        if not base_url.startswith(("http://", "https://")):
+            raise ValueError(
+                "Server URL must be absolute (http/https); provide an AsyncClient."
+            )
         return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@classmethod
def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient:
"""Create a default httpx client from the OpenAPI spec's server URL."""
servers = openapi_spec.get("servers", [])
if not servers or not servers[0].get("url"):
raise ValueError(
"No server URL found in OpenAPI spec. Either add a 'servers' "
"entry to the spec or provide an httpx.AsyncClient explicitly."
)
base_url = servers[0]["url"]
return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT)
`@classmethod`
def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient:
"""Create a default httpx client from the OpenAPI spec's server URL."""
servers = openapi_spec.get("servers", [])
if not servers or not servers[0].get("url"):
raise ValueError("Missing OpenAPI server URL; provide an AsyncClient.")
server = servers[0]
base_url = server["url"]
if "{" in base_url:
variables = server.get("variables") or {}
formatter = Formatter()
parts: list[str] = []
for literal, field_name, *_ in formatter.parse(base_url):
parts.append(literal)
if field_name:
default = variables.get(field_name, {}).get("default")
if default is None:
raise ValueError(
f"Server URL variable '{field_name}' has no default; "
"provide an AsyncClient explicitly."
)
parts.append(str(default))
base_url = "".join(parts)
if not base_url.startswith(("http://", "https://")):
raise ValueError(
"Server URL must be absolute (http/https); provide an AsyncClient."
)
return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT)
🧰 Tools
🪛 Ruff (0.14.14)

[warning] 167-170: Avoid specifying long messages outside the exception class

(TRY003)

@jlowin jlowin merged commit 5fd41b2 into main Feb 4, 2026
14 checks passed
@jlowin jlowin deleted the fix/openapi-timeout-handling branch February 4, 2026 02:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change Breaks backward compatibility. Requires minor version bump. Critical for maintainer attention. bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. openapi Related to OpenAPI integration, parsing, or code generation features.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant