Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Security

- `InferenceClient`, `HTTPClient` and the authentication token requests no longer follow HTTP redirects. A redirect response now raises `InferenceClientError` or `APIException`. A redirect is a 301, 302, 303, 307 or 308 response with a `Location` header. The error message includes the `Location` value. Other 3xx responses, such as 304, go back to the caller as before.
- `InferenceClient` now checks the request `path` with the same rules as the main API client. A path built from untrusted input could inject query parameters or a fragment into a request that carries the inference key, and dot-segment forms such as `..;/` and `..%252f` passed the old check. `path` may now contain only letters, digits, `-._~`, `/` and `:`, and must not contain a `.` or `..` segment. Anything else raises `InferenceClientError` before the request is sent.

### Added

- `InferenceClient` has a new `follow_redirects` argument. It is off by default. When it is on, the client follows a redirect only if the target is inside the deployment. The target must have the same scheme, host and port, and a path under `endpoint_base_url` that passes the same rules as `path`. The client follows at most 5 redirects. Any other redirect raises `InferenceClientError`. `AsyncInferenceExecution.status_json()` and `result()` never follow a redirect.
- When a redirect only adds or removes a trailing slash, the `InferenceClientError` message tells you to change `path`.
- `InferenceClient.run_sync`, `InferenceClient.run`, `Deployment.run_sync` and `Deployment.run` accept a `params` argument for query string parameters, like the other `InferenceClient` methods.

### Changed

- `AsyncInferenceExecution.status_json()` and `result()` now use the client `timeout_seconds` and raise `InferenceClientError` on an HTTP error status.
- The `InferenceClient` methods `get`, `post`, `put`, `delete`, `patch`, `head`, `options` and `health` now use the client `timeout_seconds` when you do not give `timeout_seconds`. Before, these calls had no timeout. To wait longer, set `timeout_seconds` on the client or on the call.
- `HTTPClient` methods ignore the `allow_redirects` keyword argument.
- An `InferenceClientError` for an HTTP error status now ends with the response body, cut to 500 characters. For a failed async job, `result()` returns the deployment's error status, so the error now shows the deployment's message.
- **Breaking:** `InferenceClient` no longer accepts a query string or fragment in `path`. Pass query data as `params` instead. For example, replace `client.get('v1/models?limit=10')` with `client.get('v1/models', params={'limit': 10})`, and `client.run_sync(data, path='v1/completions?stream=true')` with `client.run_sync(data, path='v1/completions', params={'stream': 'true'})`.
- **Breaking:** `InferenceClient` now accepts only letters, digits, `-._~`, `/` and `:` in `path`. A path with a percent-encoded value (such as `%20` or `%2F`), a space, a non-ASCII character or another character such as `@`, `+`, `,`, `=` or `;` raises `InferenceClientError`. Send such values in the request body or in `params`.

### Fixed

- `AsyncInferenceExecution.status_json()` no longer raises `ValueError` for a failed job. `AsyncStatus` has a new value, `Error`. The status JSON has an `Error` field with the reason.
- `AsyncInferenceExecution.result()` now parses a JSON result when the `Content-Type` has parameters, for example `application/json; charset=utf-8`. A result without a `Content-Type` header no longer raises `KeyError`.

## [1.25.0] - 2026-10-23

### Deprecated
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,15 @@ Reject, do not encode: `requests` decodes `%2E` back to `.` before sending, and

All five verbs delegate to a single private `_request`, which is where the url is built and validated. Add new verbs by delegating to it, never by calling `requests` directly.

**Never follow a redirect.** Every request that carries credentials is sent with `allow_redirects=False`, and a redirect raises: `handle_error` for `HTTPClient` and the auth token calls, and `InferenceClient._send` for inference. `requests` keeps `Authorization` on a same-host redirect, and a 307/308 also keeps the method and body. The `Location` target never went through path validation. Any new send site must pass `allow_redirects=False` and go through the existing error check. Inference requests, including `AsyncInferenceExecution` status and result, go through `InferenceClient._send`, never `_session` directly. A redirect is `requests.Response.is_redirect`: 301, 302, 303, 307 or 308 with a `Location` header, the same rule `requests` uses to follow. Any other 3xx, such as 304, goes back to the caller.

The one exception is `InferenceClient(follow_redirects=True)`, an opt-in for deployment calls only. `_send` then passes `allow_redirects=True`, and `_RedirectPolicySession.get_redirect_target` reports a target only if `_is_inside_deployment` accepts it: same scheme, host and port as `endpoint_base_url`, a path equal to the deployment path or under `<deployment path>/`, a raw `Location` path that passes `has_relative_path_segment`, and a target path under the deployment path that passes `check_request_path` with `_ALLOWED_IN_INFERENCE_PATH`, the same check as `path`. A rejected target stops `resolve_redirects`, and `_send` raises on the remaining redirect. The session caps hops at 5. `AsyncInferenceExecution` status and result never follow, because those urls belong to the gateway. Do not add the flag to `HTTPClient` or the auth token calls.

The check is `_encode_path_segment` in `verda/http_client/_http_client.py`; `path_params` is the only supported way to put a caller-supplied value into a path. `_add_base_url` re-asserts the same allowlist on the finished path, as a backstop for a call site that skips `path_params`.

The finished-path check is `verda.helpers.check_request_path`. It rejects `?`, `#`, any character outside an allowlist, and `.`/`..` segments. `HTTPClient._add_base_url` calls it with the default allowlist (unreserved set plus `/`). `InferenceClient._build_url` calls it on the caller's multi-segment `path` with `_ALLOWED_IN_INFERENCE_PATH`, which also allows `:` (TensorFlow Serving's `my_model:predict`). Query data for both clients goes in `params`, never in the path. Do not re-implement the check.

`verda.helpers.has_relative_path_segment` strips the query string and decodes escapes and encoded separators. It is for `InferenceClient`'s `endpoint_base_url` only. Do not use it to check a request path.
`verda.helpers.has_relative_path_segment` strips the query string and decodes escapes and encoded separators. It is for two values only: `InferenceClient`'s `endpoint_base_url`, and the raw `Location` path in the `follow_redirects` check (`_is_inside_deployment`), where it runs together with `check_request_path`. Do not use it to check a request path that the SDK builds.

## Code style

Expand Down
3 changes: 2 additions & 1 deletion docs/source/examples/containers/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ This section contains examples demonstrating how to work with containers in Verd
scaling
inference_async
inference_sync
inference_minimal
inference_minimal
inference_redirects
19 changes: 19 additions & 0 deletions docs/source/examples/containers/inference_redirects.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Following redirects inside a deployment
=======================================

By default, ``InferenceClient`` does not follow HTTP redirects. A 3xx response raises ``InferenceClientError``, and the error message shows the ``Location`` value.

Set ``follow_redirects=True`` if your container sends redirects inside its own deployment. The client then 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 the deployment path and ``/``.
- The target path passes the same check as ``path``.
- The client followed fewer than 5 redirects.

The client raises ``InferenceClientError`` for any other redirect. ``AsyncInferenceExecution.status_json()`` and ``result()`` never follow a redirect.

``Deployment.run_sync`` and ``Deployment.run`` always use the default. To follow redirects, create an ``InferenceClient`` with the deployment's ``endpoint_base_url``, as this example shows.

.. literalinclude:: ../../../../examples/containers/calling_the_endpoint_with_redirects.py
:language: python
:caption: Following redirects inside a deployment
10 changes: 8 additions & 2 deletions examples/containers/calling_the_endpoint_asynchronously.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@
headers=header,
)

while response.status() != AsyncStatus.Completed:
# Poll for status until the job completes or fails
while response.status() not in (AsyncStatus.Completed, AsyncStatus.Error):
print(response.status_json())
sleep(1)
print(response.output())

if response.status() == AsyncStatus.Error:
# The status json has an Error field with the reason
print(f'Inference failed: {response.status_json()}')
else:
print(response.output())
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,14 @@
# Run the request asynchronously using the inference client
async_inference_execution = inference_client.run(data=data, path='v1/completions')

# Poll for status until completion
while async_inference_execution.status() != AsyncStatus.Completed:
# Poll for status until the job completes or fails
while async_inference_execution.status() not in (AsyncStatus.Completed, AsyncStatus.Error):
print(async_inference_execution.status_json())
sleep(5)

# Print the response
print(async_inference_execution.output())
if async_inference_execution.status() == AsyncStatus.Error:
# The status json has an Error field with the reason
print(f'Inference failed: {async_inference_execution.status_json()}')
else:
# Print the response
print(async_inference_execution.output())
60 changes: 60 additions & 0 deletions examples/containers/calling_the_endpoint_with_redirects.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Copyright 2026 Verda Cloud Oy
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import os

from verda import VerdaClient
from verda.inference_client import InferenceClient, InferenceClientError

# Configuration - replace with your deployment name
DEPLOYMENT_NAME = os.environ.get('VERDA_DEPLOYMENT_NAME')

# Client id and secret authenticate calls to the Verda API, such as the deployment lookup
CLIENT_ID = os.environ.get('VERDA_CLIENT_ID')
CLIENT_SECRET = os.environ.get('VERDA_CLIENT_SECRET')

# The inference key authenticates calls to the deployment endpoint
INFERENCE_KEY = os.environ.get('VERDA_INFERENCE_KEY')

# Verda client instance, used only to look up the deployment
verda = VerdaClient(CLIENT_ID, CLIENT_SECRET)

# Get the deployment, to read its endpoint base url
deployment = verda.containers.get_deployment_by_name(DEPLOYMENT_NAME)

# By default the inference client does not follow redirects, and a 3xx response
# raises InferenceClientError. With follow_redirects=True, the client follows a
# redirect only if the target is inside this deployment: the same scheme, host and
# port, and a path under endpoint_base_url. It follows at most 5 redirects.
with InferenceClient(
inference_key=INFERENCE_KEY,
endpoint_base_url=deployment.endpoint_base_url,
follow_redirects=True,
) as inference_client:
# Make a synchronous request to the endpoint.
# This example demonstrates calling a SGLang deployment which serves LLMs using an OpenAI-compatible API format
data = {
'model': 'deepseek-ai/deepseek-llm-7b-chat',
'prompt': 'Write a haiku about the sea.',
'max_tokens': 64,
'temperature': 0.7,
}

try:
response = inference_client.run_sync(data=data, path='v1/completions')
print(response.output())
except InferenceClientError as e:
# A redirect that fails the follow_redirects check is not followed. The error
# message shows the Location value, and a hint if only a trailing slash differs.
print(f'Request failed: {e}')
35 changes: 35 additions & 0 deletions tests/unit_tests/authentication/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,38 @@ def test_is_expired(self, authentication_service):
# assert
assert is_expired_current
assert not is_expired_future

@pytest.mark.parametrize('status', [302, 307, 308])
def test_authenticate_redirect_failed(self, authentication_service, endpoint, status):
# arrange
target = BASE_URL + '/moved'
responses.add(responses.POST, endpoint, status=status, headers={'Location': target})
responses.add(responses.POST, target, json={})
responses.add(responses.GET, target, json={})

# act
with pytest.raises(APIException) as excinfo:
authentication_service.authenticate()

# assert
assert excinfo.value.code is None
assert f'unexpected redirect ({status})' in excinfo.value.message
assert len(responses.calls) == 1

@pytest.mark.parametrize('status', [302, 307, 308])
def test_refresh_redirect_failed(self, authentication_service, endpoint, status):
# arrange
authentication_service._refresh_token = REFRESH_TOKEN
target = BASE_URL + '/moved'
responses.add(responses.POST, endpoint, status=status, headers={'Location': target})
responses.add(responses.POST, target, json={})
responses.add(responses.GET, target, json={})

# act
with pytest.raises(APIException) as excinfo:
authentication_service.refresh()

# assert
assert excinfo.value.code is None
assert f'unexpected redirect ({status})' in excinfo.value.message
assert len(responses.calls) == 1
86 changes: 85 additions & 1 deletion tests/unit_tests/http_client/test_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
from unittest.mock import Mock

import pytest
import requests
import responses # https://github.com/getsentry/responses

from verda.exceptions import APIException
from verda.http_client._http_client import _encode_path_segment
from verda.http_client._http_client import _encode_path_segment, handle_error

INVALID_REQUEST = 'invalid_request'
INVALID_REQUEST_MESSAGE = 'Your existence is invalid'
Expand Down Expand Up @@ -497,3 +498,86 @@ def test_delete_failed(self, http_client):
# assert
assert excinfo.value.code == INVALID_REQUEST
assert excinfo.value.message == INVALID_REQUEST_MESSAGE

@pytest.mark.parametrize('status', [301, 302, 303, 307, 308])
@pytest.mark.parametrize('method', ['get', 'post', 'put', 'patch', 'delete'])
def test_request_methods_redirect_failed(self, http_client, method, status):
# arrange
url = http_client._base_url + '/test'
target = http_client._base_url + '/moved'
responses.add(method.upper(), url, status=status, headers={'Location': target})
responses.add(method.upper(), target, json={})
responses.add(responses.GET, target, json={})

# act
with pytest.raises(APIException) as excinfo:
getattr(http_client, method)('/test')

# assert
assert excinfo.value.code is None
assert f'unexpected redirect ({status})' in excinfo.value.message
assert len(responses.calls) == 1

@pytest.mark.parametrize('method', ['get', 'post', 'put', 'patch', 'delete'])
def test_request_methods_allow_redirects_false_successful(self, http_client, method):
# arrange
url = http_client._base_url + '/test'
responses.add(method.upper(), url, json={})

# act
response = getattr(http_client, method)('/test', allow_redirects=False)

# assert
assert response.status_code == 200
assert len(responses.calls) == 1

@pytest.mark.parametrize('method', ['get', 'post', 'put', 'patch', 'delete'])
def test_request_methods_allow_redirects_true_redirect_failed(self, http_client, method):
# arrange
url = http_client._base_url + '/test'
target = http_client._base_url + '/moved'
responses.add(method.upper(), url, status=307, headers={'Location': target})
responses.add(method.upper(), target, json={})

# act
with pytest.raises(APIException) as excinfo:
getattr(http_client, method)('/test', allow_redirects=True)

# assert
assert excinfo.value.code is None
assert 'unexpected redirect (307)' in excinfo.value.message
assert len(responses.calls) == 1


def test_handle_error_redirect_failed():
# arrange
response = requests.Response()
response.status_code = 302
response.headers['Location'] = 'https://api.example.com/v1/moved'

# act
with pytest.raises(APIException) as excinfo:
handle_error(response)

# assert
assert excinfo.value.code is None
assert excinfo.value.message == (
"unexpected redirect (302) to 'https://api.example.com/v1/moved'; redirects are not followed"
)


@pytest.mark.parametrize(
('status', 'headers'),
[(304, {}), (304, {'Location': 'https://api.example.com/v1/moved'}), (300, {}), (307, {})],
)
def test_handle_error_non_redirect_3xx_successful(status, headers):
# arrange
response = requests.Response()
response.status_code = status
response.headers.update(headers)

# act
handle_error(response)

# assert
assert response.status_code == status
Loading
Loading