Skip to content

Do not follow HTTP redirects in the API and inference clients - #102

Merged
yutkin merged 11 commits into
masterfrom
fix/no-follow-redirects
Oct 30, 2026
Merged

yutkin merged 11 commits into
masterfrom
fix/no-follow-redirects

Conversation

@yutkin

@yutkin yutkin commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The SDK clients do not follow HTTP redirects by default. A redirect response raises an error. A redirect is a 301, 302, 303, 307 or 308 with a Location header, the same rule requests uses. Other 3xx responses, such as 304, go back to the caller as before.

 InferenceClient._make_request
 AsyncInferenceExecution.status_json / result
-  session.request(...)                      # follows 3xx
+  InferenceClient._send(...)
+    session.request(..., allow_redirects=False)
+    redirect -> raise InferenceClientError
+    4xx/5xx  -> raise InferenceClientError
 HTTPClient._request
-  requests.request(...)                     # follows 3xx
+  requests.request(..., allow_redirects=False)
+  handle_error: redirect -> raise APIException
 AuthenticationService.authenticate / refresh
-  requests.post(...)
+  requests.post(..., allow_redirects=False)

InferenceClient has a new opt-in argument, follow_redirects (default False). When it is on, the client follows a redirect only if all of these are true:

  • The target has the same scheme, host and port as endpoint_base_url.
  • The target path is the deployment path, or it starts with <deployment path>/.
  • The Location path has no relative segment, plain or encoded.
  • The part of the target path under the deployment passes the same check as path. This check rejects forms such as ..;/, %-escapes and \. It does not check the query string.
  • The client followed fewer than 5 redirects.

The client raises InferenceClientError for any other redirect. requests method rules apply: 303 changes to GET, 301 and 302 change POST to GET, and 307 and 308 keep the method and body. status_json() and result() never follow a redirect. HTTPClient and the authentication calls do not get the option.

When a redirect only adds or removes a trailing slash, the error message tells you to change path.

A new example, examples/containers/calling_the_endpoint_with_redirects.py, and a docs page, inference_redirects.rst, show the opt-in. Deployment.run_sync and run always use the default, so the example creates an InferenceClient with the deployment's endpoint_base_url.

status_json() and result() also use the client timeout_seconds now. They raise InferenceClientError on a 4xx or 5xx. The gateway returns 200 from /status/ for every job state, so polling status_json() does not change. So do get, post, put, delete, patch, head, options and health when the caller gives no timeout_seconds. Before, those calls had no timeout.

Related fixes for async jobs:

  • AsyncStatus has a new value, Error. Before, status_json() raised ValueError for a failed job. The async examples now stop polling on Error.
  • An InferenceClientError for an HTTP error status ends with the response body, cut to 500 characters. For a failed job, /result/ returns the deployment's error status and body, so the error shows why the job failed.
  • result() parses JSON when the Content-Type has parameters, such as charset=utf-8. A result without a Content-Type header returns the text.

Evidence

Unit test, for each client method and each status 301, 302, 303, 307 and 308:

server: POST /predict -> 307, Location: /moved
call:   client.run_sync(data, path='predict')
  • Before: the client sends a second request to /moved. The test fails.
    After: the client sends one request and raises InferenceClientError. The test passes.

Unit tests for follow_redirects=True:

  • The client follows a target inside the deployment. A 307 or 308 keeps the method and body. A 301, 302 or 303 changes POST to GET.
  • The client sends one request, then rejects these targets:
    • another deployment, or a deployment name with the same prefix
    • .., %2e%2e, ..%2F, ..;/ and ..%252f segments
    • characters that path does not allow
    • another host, http instead of https, another port, or user info
    • a Location that the client cannot parse
  • The sixth redirect raises an error.
  • status_json() and result() do not follow a redirect.

Other unit tests:

  • A 304, a 300, or a redirect status without a Location header goes back to the caller, in both modes.
  • The verb helpers and health() send the client timeout_seconds when the caller gives no timeout.
  • status_json() returns a failed job with AsyncStatus.Error. result() for a failed job raises an error that ends with the deployment's body.
  • result() parses application/json; charset=utf-8, and returns text when there is no Content-Type.

Tested locally:

  • uv run pytest tests/unit_tests: 660 passed, with security: check the inference request path with the API path rules #103 merged.
  • uv run ruff check and uv run ruff format --check: pass.
  • A FastAPI app and a Flask app behind a local reverse proxy. A request with a trailing-slash mismatch gets a 307 or 308. Before, the client followed the redirect and got 404. After, the client raises an error that shows the Location. A request without a mismatch gets 200 before and after.

Merge Danger

Door: two-way

A revert restores the old behavior.

Blast Radius: consumers

These cases can break:

  • Code that relies on a redirect. For example, an http:// base URL that the server redirects to https://. The call now raises an error. follow_redirects=True does not help here, because the scheme changes.
  • A container that redirects to add or remove a trailing slash. The call now raises an error. The caller must change the path. If the container sends a Location inside the deployment, InferenceClient(..., follow_redirects=True) restores the old behavior.
  • status_json() and result() now raise on a 4xx or 5xx response. They also stop after timeout_seconds (default 300 seconds). Before the job completes, /result/ returns 404, so result() now raises. Before, it returned the 404 body as the result.
  • The InferenceClient verb helpers and health() now stop after timeout_seconds (default 300 seconds) when the caller gives no timeout. A call that waits longer than that for a response now raises InferenceClientError.
  • HTTPClient methods ignore allow_redirects=True.

🤖 Generated with Claude Code

yutkin and others added 4 commits October 29, 2026 16:18
InferenceClient, HTTPClient and the authentication token requests now
send requests with allow_redirects=False. A 3xx response raises
InferenceClientError or APIException.

AsyncInferenceExecution.status_json() and result() now use the client
timeout and raise on an HTTP error status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
InferenceClient(follow_redirects=True) follows a redirect only if the
target is inside the deployment: same scheme, host and port as
endpoint_base_url, a path under the deployment path, and no relative
path segment in the Location path. It follows at most 5 redirects.
Any other redirect raises InferenceClientError. The default stays off.

AsyncInferenceExecution status and result never follow a redirect.
HTTPClient and the authentication calls do not change.

When a redirect only adds or removes a trailing slash, the error
message tells the caller to change path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ects-34af24

# Conflicts:
#	CHANGELOG.md
#	tests/unit_tests/inference_client/test_inference_client.py
With follow_redirects on, the part of the target path under the
deployment must now pass check_request_path with the same allowlist as
path. Forms such as `..;/`, `%`-escapes and `\` are rejected. The query
string is not checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yutkin
yutkin marked this pull request as ready for review October 30, 2026 07:52
yutkin and others added 7 commits October 30, 2026 10:09
The example looks up a deployment and creates an InferenceClient with its
endpoint_base_url and follow_redirects=True. Deployment.run_sync and run
always use the default, so this is the way to turn on the option for a
deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Point the service API pages at the public import paths, such as
verda.containers.ContainersService. The old paths named modules that
no longer exist, so autodoc could not import them.

Include CHANGELOG.md and CONTRIBUTING.md with the recommonmark parser.
The pages referenced .rst files that no longer exist, so they had no
content and no title.

Add inference_minimal_async to the container examples toctree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This reverts commit 576cd40. The docs
build fix goes in a separate pull request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Location that urllib cannot parse, such as an unclosed IPv6 bracket,
raised ValueError from the trailing-slash hint or the redirect check.
The redirect check now rejects such a Location, and the hint is empty.
The client then raises InferenceClientError, as for any other redirect.

Tests: assert APIException.code in the redirect tests, rename tests to
end in _successful or _failed, and move module constants to the top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A response is now a redirect only if requests would follow it: 301,
302, 303, 307 or 308 with a Location header (Response.is_redirect).
Before, every 3xx raised, so a 304 Not Modified became an error. Other
3xx responses go back to the caller, as on master. This applies to
InferenceClient._send and to handle_error in the HTTP client.

The InferenceClient verb helpers and health() passed
timeout_seconds=None, so the request had no timeout. They now use the
client timeout_seconds when the caller gives none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With follow_redirects on, the error said "the target is outside the
deployment" also for a target inside the deployment that failed the path
check. It now says that the target failed the follow_redirects check.

CLAUDE.md said that has_relative_path_segment is for endpoint_base_url
only, but the follow_redirects check also uses it on the raw Location
path. The note now names both uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gateway returns Status "Error" for a failed job. AsyncStatus had no
such value, so status_json() raised ValueError. AsyncStatus now has
Error, and the async examples stop polling on it.

For a failed job, /result/ returns the deployment's own error status and
body. InferenceClientError dropped that body, so the caller could not
see why the job failed. An HTTP error now ends with the response body,
cut to 500 characters.

result() compared Content-Type with 'application/json' exactly, so
'application/json; charset=utf-8' came back as text, and a missing
header raised KeyError. It now compares the media type only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yutkin
yutkin merged commit e30b88a into master Oct 30, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants