fix(sdk-py): percent-encode caller-supplied identifiers in URL paths (#7893)

## Summary

Wraps every caller-supplied identifier (thread_id, assistant_id, run_id,
cron_id, checkpoint_id, namespace) interpolated into request URL paths
with `urllib.parse.quote(safe="")` via a new `_quote_path_param` helper.
All-dot strings are encoded as `%2E` triplets so they aren't collapsed
by httpx's client-side path normalization. Helper raises `TypeError` for
None/bytes inputs.

## Test plan

- [x] `make format` / `make lint` / `make test` from `libs/sdk-py`
This commit is contained in:
Nick Hollon
2026-05-22 12:51:37 -04:00
committed by GitHub
parent 346aa97425
commit 604534e1b7
10 changed files with 602 additions and 67 deletions
+16 -9
View File
@@ -8,6 +8,7 @@ from typing import Any, Literal, cast, overload
import httpx
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.schema import (
Assistant,
AssistantSelectField,
@@ -84,7 +85,9 @@ class AssistantsClient:
```
"""
return await self.http.get(
f"/assistants/{assistant_id}", headers=headers, params=params
f"/assistants/{_quote_path_param(assistant_id)}",
headers=headers,
params=params,
)
async def get_graph(
@@ -142,7 +145,9 @@ class AssistantsClient:
query_params.update(params)
return await self.http.get(
f"/assistants/{assistant_id}/graph", params=query_params, headers=headers
f"/assistants/{_quote_path_param(assistant_id)}/graph",
params=query_params,
headers=headers,
)
async def get_schemas(
@@ -263,7 +268,9 @@ class AssistantsClient:
"""
return await self.http.get(
f"/assistants/{assistant_id}/schemas", headers=headers, params=params
f"/assistants/{_quote_path_param(assistant_id)}/schemas",
headers=headers,
params=params,
)
async def get_subgraphs(
@@ -293,13 +300,13 @@ class AssistantsClient:
get_params = {**get_params, **dict(params)}
if namespace is not None:
return await self.http.get(
f"/assistants/{assistant_id}/subgraphs/{namespace}",
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs/{_quote_path_param(namespace)}",
params=get_params,
headers=headers,
)
else:
return await self.http.get(
f"/assistants/{assistant_id}/subgraphs",
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs",
params=get_params,
headers=headers,
)
@@ -436,7 +443,7 @@ class AssistantsClient:
if description:
payload["description"] = description
return await self.http.patch(
f"/assistants/{assistant_id}",
f"/assistants/{_quote_path_param(assistant_id)}",
json=payload,
headers=headers,
params=params,
@@ -479,7 +486,7 @@ class AssistantsClient:
if params:
query_params.update(params)
await self.http.delete(
f"/assistants/{assistant_id}",
f"/assistants/{_quote_path_param(assistant_id)}",
headers=headers,
params=query_params or None,
)
@@ -686,7 +693,7 @@ class AssistantsClient:
if metadata:
payload["metadata"] = metadata
return await self.http.post(
f"/assistants/{assistant_id}/versions",
f"/assistants/{_quote_path_param(assistant_id)}/versions",
json=payload,
headers=headers,
params=params,
@@ -726,7 +733,7 @@ class AssistantsClient:
payload: dict[str, Any] = {"version": version}
return await self.http.post(
f"/assistants/{assistant_id}/latest",
f"/assistants/{_quote_path_param(assistant_id)}/latest",
json=payload,
headers=headers,
params=params,
+6 -4
View File
@@ -8,7 +8,7 @@ from datetime import datetime, tzinfo
from typing import Any
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import _resolve_timezone
from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone
from langgraph_sdk.schema import (
All,
Config,
@@ -166,7 +166,7 @@ class CronClient:
payload["multitask_strategy"] = multitask_strategy
payload = {k: v for k, v in payload.items() if v is not None}
return await self.http.post(
f"/threads/{thread_id}/runs/crons",
f"/threads/{_quote_path_param(thread_id)}/runs/crons",
json=payload,
headers=headers,
params=params,
@@ -315,7 +315,9 @@ class CronClient:
```
"""
await self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params)
await self.http.delete(
f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params
)
async def update(
self,
@@ -402,7 +404,7 @@ class CronClient:
}
payload = {k: v for k, v in payload.items() if v is not None}
return await self.http.patch(
f"/runs/crons/{cron_id}",
f"/runs/crons/{_quote_path_param(cron_id)}",
json=payload,
headers=headers,
params=params,
+19 -10
View File
@@ -12,6 +12,7 @@ import httpx
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import (
_get_run_metadata_from_response,
_quote_path_param,
_sse_to_v2_dict,
)
from langgraph_sdk.schema import (
@@ -337,7 +338,7 @@ class RunsClient:
"langsmith_tracer": langsmith_tracing,
}
endpoint = (
f"/threads/{thread_id}/runs/stream"
f"/threads/{_quote_path_param(thread_id)}/runs/stream"
if thread_id is not None
else "/runs/stream"
)
@@ -596,7 +597,7 @@ class RunsClient:
on_run_created(metadata)
return await self.http.post(
f"/threads/{thread_id}/runs" if thread_id else "/runs",
f"/threads/{_quote_path_param(thread_id)}/runs" if thread_id else "/runs",
json=payload,
params=params,
headers=headers,
@@ -821,7 +822,9 @@ class RunsClient:
"langsmith_tracer": langsmith_tracing,
}
endpoint = (
f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait"
f"/threads/{_quote_path_param(thread_id)}/runs/wait"
if thread_id is not None
else "/runs/wait"
)
def on_response(res: httpx.Response):
@@ -895,7 +898,9 @@ class RunsClient:
if params:
query_params.update(params)
return await self.http.get(
f"/threads/{thread_id}/runs", params=query_params, headers=headers
f"/threads/{_quote_path_param(thread_id)}/runs",
params=query_params,
headers=headers,
)
async def get(
@@ -930,7 +935,9 @@ class RunsClient:
"""
return await self.http.get(
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
headers=headers,
params=params,
)
async def cancel(
@@ -978,14 +985,14 @@ class RunsClient:
query_params.update(params)
if wait:
return await self.http.request_reconnect(
f"/threads/{thread_id}/runs/{run_id}/cancel",
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
"POST",
params=query_params,
headers=headers,
)
else:
return await self.http.post(
f"/threads/{thread_id}/runs/{run_id}/cancel",
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
json=None,
params=query_params,
headers=headers,
@@ -1081,7 +1088,7 @@ class RunsClient:
"""
return await self.http.request_reconnect(
f"/threads/{thread_id}/runs/{run_id}/join",
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/join",
"GET",
headers=headers,
params=params,
@@ -1136,7 +1143,7 @@ class RunsClient:
if params:
query_params.update(params)
return self.http.stream(
f"/threads/{thread_id}/runs/{run_id}/stream",
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/stream",
"GET",
params=query_params,
headers={
@@ -1177,5 +1184,7 @@ class RunsClient:
"""
await self.http.delete(
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
headers=headers,
params=params,
)
+19 -10
View File
@@ -6,6 +6,7 @@ from collections.abc import AsyncIterator, Mapping, Sequence
from typing import Any, Literal, overload
from langgraph_sdk._async.http import HttpClient
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.schema import (
Checkpoint,
Json,
@@ -90,7 +91,7 @@ class ThreadsClient:
if params:
query_params.update(params)
return await self.http.get(
f"/threads/{thread_id}",
f"/threads/{_quote_path_param(thread_id)}",
headers=headers,
params=query_params or None,
)
@@ -254,7 +255,7 @@ class ThreadsClient:
if return_minimal:
request_headers["Prefer"] = "return=minimal"
return await self.http.patch(
f"/threads/{thread_id}",
f"/threads/{_quote_path_param(thread_id)}",
json=payload,
headers=request_headers or None,
params=params,
@@ -287,7 +288,9 @@ class ThreadsClient:
```
"""
await self.http.delete(f"/threads/{thread_id}", headers=headers, params=params)
await self.http.delete(
f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params
)
async def search(
self,
@@ -430,7 +433,10 @@ class ThreadsClient:
"""
return await self.http.post(
f"/threads/{thread_id}/copy", json=None, headers=headers, params=params
f"/threads/{_quote_path_param(thread_id)}/copy",
json=None,
headers=headers,
params=params,
)
async def prune(
@@ -586,7 +592,7 @@ class ThreadsClient:
"""
if checkpoint:
return await self.http.post(
f"/threads/{thread_id}/state/checkpoint",
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
headers=headers,
params=params,
@@ -596,7 +602,7 @@ class ThreadsClient:
if params:
get_params = {**get_params, **dict(params)}
return await self.http.get(
f"/threads/{thread_id}/state/{checkpoint_id}",
f"/threads/{_quote_path_param(thread_id)}/state/{_quote_path_param(checkpoint_id)}",
params=get_params,
headers=headers,
)
@@ -605,7 +611,7 @@ class ThreadsClient:
if params:
get_params = {**get_params, **dict(params)}
return await self.http.get(
f"/threads/{thread_id}/state",
f"/threads/{_quote_path_param(thread_id)}/state",
params=get_params,
headers=headers,
)
@@ -670,7 +676,10 @@ class ThreadsClient:
if as_node:
payload["as_node"] = as_node
return await self.http.post(
f"/threads/{thread_id}/state", json=payload, headers=headers, params=params
f"/threads/{_quote_path_param(thread_id)}/state",
json=payload,
headers=headers,
params=params,
)
async def get_history(
@@ -719,7 +728,7 @@ class ThreadsClient:
if checkpoint:
payload["checkpoint"] = checkpoint
return await self.http.post(
f"/threads/{thread_id}/history",
f"/threads/{_quote_path_param(thread_id)}/history",
json=payload,
headers=headers,
params=params,
@@ -763,7 +772,7 @@ class ThreadsClient:
if params:
query_params.update(params)
return self.http.stream(
f"/threads/{thread_id}/stream",
f"/threads/{_quote_path_param(thread_id)}/stream",
"GET",
headers={
**({"Last-Event-ID": last_event_id} if last_event_id else {}),
+34 -1
View File
@@ -8,7 +8,7 @@ import re
from collections.abc import Mapping
from datetime import tzinfo
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import urlparse
from urllib.parse import quote, urlparse
import httpx
@@ -198,6 +198,39 @@ def _provided_vals(d: Mapping[str, Any]) -> dict[str, Any]:
return {k: v for k, v in d.items() if v is not None}
def _quote_path_param(value: Any) -> str:
"""Encode a value for safe interpolation into a request path segment.
Path segments are encoded with ``safe=""`` so that ``/`` and other reserved
characters are escaped. Standalone dot-segments (``.`` and ``..``) are also
encoded because some URL-handling stacks (including ``httpx``) collapse
them client-side as relative-path traversal before transmission. The value
is coerced to ``str`` so callers can pass ``uuid.UUID`` and similar types
directly without changing call sites.
A properly formed identifier (for example, a standard UUID, which contains
no dots or reserved characters) round-trips through this function
unchanged.
Raises:
TypeError: If `value` is `None` or a `bytes`/`bytearray` instance.
Coercing those would produce misleading paths (e.g. `/threads/None`),
so surface the caller bug instead.
"""
if value is None:
raise TypeError("path parameter must not be None")
if isinstance(value, (bytes, bytearray)):
raise TypeError("path parameter must not be bytes; pass a str or uuid.UUID")
quoted = quote(str(value), safe="")
# Bare "." or ".." (or any all-dot string) acts as a relative-path segment
# that some HTTP stacks (including ``httpx``) collapse client-side before
# transmission. Encode the dots so the segment becomes opaque to that
# logic. Mixed values like "agent.v1" are unaffected.
if quoted and all(c == "." for c in quoted):
quoted = "%2E" * len(quoted)
return quoted
_registered_transports: list[httpx.ASGITransport] = []
+16 -9
View File
@@ -7,6 +7,7 @@ from typing import Any, Literal, cast, overload
import httpx
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import (
Assistant,
@@ -83,7 +84,9 @@ class SyncAssistantsClient:
"""
return self.http.get(
f"/assistants/{assistant_id}", headers=headers, params=params
f"/assistants/{_quote_path_param(assistant_id)}",
headers=headers,
params=params,
)
def get_graph(
@@ -136,7 +139,9 @@ class SyncAssistantsClient:
if params:
query_params.update(params)
return self.http.get(
f"/assistants/{assistant_id}/graph", params=query_params, headers=headers
f"/assistants/{_quote_path_param(assistant_id)}/graph",
params=query_params,
headers=headers,
)
def get_schemas(
@@ -269,7 +274,9 @@ class SyncAssistantsClient:
"""
return self.http.get(
f"/assistants/{assistant_id}/schemas", headers=headers, params=params
f"/assistants/{_quote_path_param(assistant_id)}/schemas",
headers=headers,
params=params,
)
def get_subgraphs(
@@ -297,13 +304,13 @@ class SyncAssistantsClient:
get_params = {**get_params, **dict(params)}
if namespace is not None:
return self.http.get(
f"/assistants/{assistant_id}/subgraphs/{namespace}",
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs/{_quote_path_param(namespace)}",
params=get_params,
headers=headers,
)
else:
return self.http.get(
f"/assistants/{assistant_id}/subgraphs",
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs",
params=get_params,
headers=headers,
)
@@ -438,7 +445,7 @@ class SyncAssistantsClient:
if description:
payload["description"] = description
return self.http.patch(
f"/assistants/{assistant_id}",
f"/assistants/{_quote_path_param(assistant_id)}",
json=payload,
headers=headers,
params=params,
@@ -481,7 +488,7 @@ class SyncAssistantsClient:
if params:
query_params.update(params)
self.http.delete(
f"/assistants/{assistant_id}",
f"/assistants/{_quote_path_param(assistant_id)}",
headers=headers,
params=query_params or None,
)
@@ -685,7 +692,7 @@ class SyncAssistantsClient:
if metadata:
payload["metadata"] = metadata
return self.http.post(
f"/assistants/{assistant_id}/versions",
f"/assistants/{_quote_path_param(assistant_id)}/versions",
json=payload,
headers=headers,
params=params,
@@ -724,7 +731,7 @@ class SyncAssistantsClient:
payload: dict[str, Any] = {"version": version}
return self.http.post(
f"/assistants/{assistant_id}/latest",
f"/assistants/{_quote_path_param(assistant_id)}/latest",
json=payload,
headers=headers,
params=params,
+6 -4
View File
@@ -7,7 +7,7 @@ from collections.abc import Mapping, Sequence
from datetime import datetime, tzinfo
from typing import Any
from langgraph_sdk._shared.utilities import _resolve_timezone
from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import (
All,
@@ -156,7 +156,7 @@ class SyncCronClient:
}
payload = {k: v for k, v in payload.items() if v is not None}
return self.http.post(
f"/threads/{thread_id}/runs/crons",
f"/threads/{_quote_path_param(thread_id)}/runs/crons",
json=payload,
headers=headers,
params=params,
@@ -304,7 +304,9 @@ class SyncCronClient:
```
"""
self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params)
self.http.delete(
f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params
)
def update(
self,
@@ -391,7 +393,7 @@ class SyncCronClient:
}
payload = {k: v for k, v in payload.items() if v is not None}
return self.http.patch(
f"/runs/crons/{cron_id}",
f"/runs/crons/{_quote_path_param(cron_id)}",
json=payload,
headers=headers,
params=params,
+19 -10
View File
@@ -11,6 +11,7 @@ import httpx
from langgraph_sdk._shared.utilities import (
_get_run_metadata_from_response,
_quote_path_param,
_sse_to_v2_dict,
)
from langgraph_sdk._sync.http import SyncHttpClient
@@ -332,7 +333,7 @@ class SyncRunsClient:
"langsmith_tracer": langsmith_tracing,
}
endpoint = (
f"/threads/{thread_id}/runs/stream"
f"/threads/{_quote_path_param(thread_id)}/runs/stream"
if thread_id is not None
else "/runs/stream"
)
@@ -591,7 +592,7 @@ class SyncRunsClient:
on_run_created(metadata)
return self.http.post(
f"/threads/{thread_id}/runs" if thread_id else "/runs",
f"/threads/{_quote_path_param(thread_id)}/runs" if thread_id else "/runs",
json=payload,
params=params,
headers=headers,
@@ -825,7 +826,9 @@ class SyncRunsClient:
on_run_created(metadata)
endpoint = (
f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait"
f"/threads/{_quote_path_param(thread_id)}/runs/wait"
if thread_id is not None
else "/runs/wait"
)
return self.http.request_reconnect(
endpoint,
@@ -879,7 +882,9 @@ class SyncRunsClient:
if params:
query_params.update(params)
return self.http.get(
f"/threads/{thread_id}/runs", params=query_params, headers=headers
f"/threads/{_quote_path_param(thread_id)}/runs",
params=query_params,
headers=headers,
)
def get(
@@ -912,7 +917,9 @@ class SyncRunsClient:
"""
return self.http.get(
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
headers=headers,
params=params,
)
def cancel(
@@ -960,14 +967,14 @@ class SyncRunsClient:
query_params.update(params)
if wait:
return self.http.request_reconnect(
f"/threads/{thread_id}/runs/{run_id}/cancel",
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
"POST",
json=None,
params=query_params,
headers=headers,
)
return self.http.post(
f"/threads/{thread_id}/runs/{run_id}/cancel",
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
json=None,
params=query_params,
headers=headers,
@@ -1063,7 +1070,7 @@ class SyncRunsClient:
"""
return self.http.request_reconnect(
f"/threads/{thread_id}/runs/{run_id}/join",
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/join",
"GET",
headers=headers,
params=params,
@@ -1117,7 +1124,7 @@ class SyncRunsClient:
if params:
query_params.update(params)
return self.http.stream(
f"/threads/{thread_id}/runs/{run_id}/stream",
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/stream",
"GET",
params=query_params,
headers={
@@ -1158,5 +1165,7 @@ class SyncRunsClient:
"""
self.http.delete(
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
headers=headers,
params=params,
)
+19 -10
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Iterator, Mapping, Sequence
from typing import Any, Literal, overload
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk._sync.http import SyncHttpClient
from langgraph_sdk.schema import (
Checkpoint,
@@ -88,7 +89,7 @@ class SyncThreadsClient:
if params:
query_params.update(params)
return self.http.get(
f"/threads/{thread_id}",
f"/threads/{_quote_path_param(thread_id)}",
headers=headers,
params=query_params or None,
)
@@ -250,7 +251,7 @@ class SyncThreadsClient:
if return_minimal:
request_headers["Prefer"] = "return=minimal"
return self.http.patch(
f"/threads/{thread_id}",
f"/threads/{_quote_path_param(thread_id)}",
json=payload,
headers=request_headers or None,
params=params,
@@ -282,7 +283,9 @@ class SyncThreadsClient:
```
"""
self.http.delete(f"/threads/{thread_id}", headers=headers, params=params)
self.http.delete(
f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params
)
def search(
self,
@@ -421,7 +424,10 @@ class SyncThreadsClient:
"""
return self.http.post(
f"/threads/{thread_id}/copy", json=None, headers=headers, params=params
f"/threads/{_quote_path_param(thread_id)}/copy",
json=None,
headers=headers,
params=params,
)
def prune(
@@ -576,7 +582,7 @@ class SyncThreadsClient:
"""
if checkpoint:
return self.http.post(
f"/threads/{thread_id}/state/checkpoint",
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
headers=headers,
params=params,
@@ -586,7 +592,7 @@ class SyncThreadsClient:
if params:
get_params = {**get_params, **dict(params)}
return self.http.get(
f"/threads/{thread_id}/state/{checkpoint_id}",
f"/threads/{_quote_path_param(thread_id)}/state/{_quote_path_param(checkpoint_id)}",
params=get_params,
headers=headers,
)
@@ -595,7 +601,7 @@ class SyncThreadsClient:
if params:
get_params = {**get_params, **dict(params)}
return self.http.get(
f"/threads/{thread_id}/state",
f"/threads/{_quote_path_param(thread_id)}/state",
params=get_params,
headers=headers,
)
@@ -657,7 +663,10 @@ class SyncThreadsClient:
if as_node:
payload["as_node"] = as_node
return self.http.post(
f"/threads/{thread_id}/state", json=payload, headers=headers, params=params
f"/threads/{_quote_path_param(thread_id)}/state",
json=payload,
headers=headers,
params=params,
)
def get_history(
@@ -707,7 +716,7 @@ class SyncThreadsClient:
if checkpoint:
payload["checkpoint"] = checkpoint
return self.http.post(
f"/threads/{thread_id}/history",
f"/threads/{_quote_path_param(thread_id)}/history",
json=payload,
headers=headers,
params=params,
@@ -752,7 +761,7 @@ class SyncThreadsClient:
if params:
query_params.update(params)
return self.http.stream(
f"/threads/{thread_id}/stream",
f"/threads/{_quote_path_param(thread_id)}/stream",
"GET",
headers={
**({"Last-Event-ID": last_event_id} if last_event_id else {}),
+448
View File
@@ -0,0 +1,448 @@
"""Regression tests for path-segment encoding of caller-supplied identifiers.
Covers GHSA-w39p-vh2g-g8g5: identifier values interpolated into request paths
are encoded so the resulting request addresses the resource the SDK method
indicates, even if the identifier contains characters with special meaning in
URL paths.
"""
from __future__ import annotations
import httpx
import pytest
from langgraph_sdk._shared.utilities import _quote_path_param
from langgraph_sdk.client import (
AssistantsClient,
CronClient,
HttpClient,
RunsClient,
SyncAssistantsClient,
SyncCronClient,
SyncHttpClient,
SyncRunsClient,
SyncThreadsClient,
ThreadsClient,
)
class TestQuotePathParam:
"""Unit tests for the encoding helper itself."""
def test_uuid_round_trips_unchanged(self) -> None:
uuid_value = "550e8400-e29b-41d4-a716-446655440000"
assert _quote_path_param(uuid_value) == uuid_value
def test_simple_opaque_id_round_trips_unchanged(self) -> None:
assert _quote_path_param("thread_123") == "thread_123"
assert _quote_path_param("asst_abc") == "asst_abc"
def test_slash_is_encoded(self) -> None:
assert _quote_path_param("foo/bar") == "foo%2Fbar"
def test_bare_dot_segments_are_encoded(self) -> None:
# All-dot strings are encoded to make them opaque to HTTP stacks that
# collapse "./.." path segments client-side.
assert _quote_path_param(".") == "%2E"
assert _quote_path_param("..") == "%2E%2E"
assert _quote_path_param("...") == "%2E%2E%2E"
# Mixed values that happen to contain dots are not affected.
assert _quote_path_param("agent.v1") == "agent.v1"
# Subsequent ``/`` characters are encoded regardless.
assert _quote_path_param("../bar") == "..%2Fbar"
def test_full_pivot_payload_is_encoded(self) -> None:
# A caller-supplied identifier that, if interpolated raw, would route
# the request to a different resource type.
payload = "../assistants/abc-123"
encoded = _quote_path_param(payload)
assert encoded == "..%2Fassistants%2Fabc-123"
assert "/" not in encoded
def test_non_string_values_are_coerced_to_str(self) -> None:
import uuid
uid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000")
assert _quote_path_param(uid) == str(uid)
assert _quote_path_param(42) == "42"
def test_none_value_raises_type_error(self) -> None:
with pytest.raises(TypeError, match="must not be None"):
_quote_path_param(None)
def test_bytes_value_raises_type_error(self) -> None:
with pytest.raises(TypeError, match="must not be bytes"):
_quote_path_param(b"bytes")
with pytest.raises(TypeError, match="must not be bytes"):
_quote_path_param(bytearray(b"bytes"))
def _wire_path(request: httpx.Request) -> str:
"""Return the path as it goes on the wire (preserves percent-encoding)."""
return request.url.raw_path.decode("ascii")
@pytest.mark.asyncio
class TestAsyncPathEncoding:
"""Async-client tests that verify the encoded path actually lands on the wire.
Note: ``request.url.path`` is the percent-decoded display form. The bytes
that actually go on the wire are in ``request.url.raw_path``; that is what
the server's router sees and what these tests inspect.
"""
async def test_threads_get_with_pivot_payload_stays_on_threads(self) -> None:
captured: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"thread_id": "anything"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
threads_client = ThreadsClient(HttpClient(client))
await threads_client.get("../assistants/abc-123")
assert len(captured) == 1
wire = captured[0]
# The identifier is encoded so the wire path stays inside `/threads/...`.
# The encoded segment must not contain literal slashes that could let
# the server re-route to a different resource type.
assert wire.startswith("/threads/")
segment = wire[len("/threads/") :]
assert "/" not in segment
assert "%2F" in segment
assert segment == "..%2Fassistants%2Fabc-123"
async def test_threads_update_with_pivot_payload_stays_on_threads(self) -> None:
captured: list[tuple[str, str]] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append((request.method, _wire_path(request)))
return httpx.Response(200, json={"thread_id": "anything"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
threads_client = ThreadsClient(HttpClient(client))
await threads_client.update("../assistants/abc-123", metadata={"x": 1})
assert len(captured) == 1
method, wire = captured[0]
assert method == "PATCH"
assert wire.startswith("/threads/")
segment = wire[len("/threads/") :]
assert "/" not in segment
async def test_threads_delete_with_pivot_payload_stays_on_threads(self) -> None:
captured: list[tuple[str, str]] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append((request.method, _wire_path(request)))
return httpx.Response(200)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
threads_client = ThreadsClient(HttpClient(client))
await threads_client.delete("../runs/crons/some-cron-id")
assert len(captured) == 1
method, wire = captured[0]
assert method == "DELETE"
assert wire.startswith("/threads/")
segment = wire[len("/threads/") :]
assert "/" not in segment
async def test_assistants_get_with_pivot_payload_stays_on_assistants(
self,
) -> None:
captured: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"assistant_id": "anything"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
assistants_client = AssistantsClient(HttpClient(client))
await assistants_client.get("../threads/abc-123")
assert len(captured) == 1
wire = captured[0]
assert wire.startswith("/assistants/")
segment = wire[len("/assistants/") :]
assert "/" not in segment
async def test_runs_delete_double_id_pivot_stays_on_threads_runs(self) -> None:
captured: list[tuple[str, str]] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append((request.method, _wire_path(request)))
return httpx.Response(200)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
runs_client = RunsClient(HttpClient(client))
# Both identifier values supplied as path-traversal payloads.
await runs_client.delete("..", "../runs/crons/cron-id")
assert len(captured) == 1
method, wire = captured[0]
assert method == "DELETE"
# The path should match `/threads/{quoted_thread}/runs/{quoted_run}`
# exactly. Neither segment should contain literal slashes.
assert wire.startswith("/threads/")
assert "/runs/crons/" not in wire
parts = wire.split("/")
# Expected shape: ['', 'threads', '<encoded ..>', 'runs', '<encoded ..>']
assert len(parts) == 5
assert parts[1] == "threads"
assert parts[3] == "runs"
# Encoded thread_id and run_id are between literal slashes.
assert parts[2] == "%2E%2E"
assert parts[4] == "..%2Fruns%2Fcrons%2Fcron-id"
async def test_crons_delete_with_pivot_payload_stays_on_crons(self) -> None:
captured: list[tuple[str, str]] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append((request.method, _wire_path(request)))
return httpx.Response(200)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
crons_client = CronClient(HttpClient(client))
await crons_client.delete("../../assistants/abc-123")
assert len(captured) == 1
method, wire = captured[0]
assert method == "DELETE"
assert wire.startswith("/runs/crons/")
segment = wire[len("/runs/crons/") :]
assert "/" not in segment
async def test_threads_get_state_with_pivot_checkpoint_id_stays_on_state(
self,
) -> None:
captured: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
threads_client = ThreadsClient(HttpClient(client))
await threads_client.get_state(thread_id="tid-1", checkpoint_id="../runs")
assert len(captured) == 1
wire = captured[0]
# Wire path must stay on `/threads/{tid}/state/...`, not pivot to
# `/threads/tid-1/runs`.
assert wire.startswith("/threads/tid-1/state/")
# Strip query string before checking the checkpoint segment.
path_only = wire.split("?", 1)[0]
segment = path_only[len("/threads/tid-1/state/") :]
assert "/" not in segment
assert segment == "..%2Fruns"
async def test_assistants_get_subgraphs_with_pivot_namespace_stays_on_subgraphs(
self,
) -> None:
captured: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
assistants_client = AssistantsClient(HttpClient(client))
await assistants_client.get_subgraphs("aid-1", namespace="../foo")
assert len(captured) == 1
wire = captured[0]
# Wire path must stay on `/assistants/{aid}/subgraphs/...`.
assert wire.startswith("/assistants/aid-1/subgraphs/")
# Strip query string before checking the namespace segment.
path_only = wire.split("?", 1)[0]
segment = path_only[len("/assistants/aid-1/subgraphs/") :]
assert "/" not in segment
assert segment == "..%2Ffoo"
async def test_bare_double_dot_thread_id_survives_to_wire(self) -> None:
"""The all-dot encoding branch must survive httpx's relative-path collapse."""
captured: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"thread_id": "anything"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
threads_client = ThreadsClient(HttpClient(client))
await threads_client.get("..")
assert len(captured) == 1
# The all-dot identifier is fully percent-encoded so httpx does NOT
# collapse it client-side as a relative-path traversal.
assert captured[0].endswith("/threads/%2E%2E")
async def test_bare_single_dot_thread_id_survives_to_wire(self) -> None:
captured: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"thread_id": "anything"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
threads_client = ThreadsClient(HttpClient(client))
await threads_client.get(".")
assert len(captured) == 1
assert captured[0].endswith("/threads/%2E")
async def test_uuid_identifier_lands_on_intended_path(self) -> None:
"""Legitimate UUID identifiers round-trip without encoding artifacts."""
captured: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"thread_id": "anything"})
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
threads_client = ThreadsClient(HttpClient(client))
await threads_client.get("550e8400-e29b-41d4-a716-446655440000")
assert captured == ["/threads/550e8400-e29b-41d4-a716-446655440000"]
class TestSyncPathEncoding:
"""Sync-client tests that mirror the async coverage on a representative subset."""
def test_threads_get_with_pivot_payload_stays_on_threads(self) -> None:
captured: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"thread_id": "anything"})
transport = httpx.MockTransport(handler)
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
threads_client = SyncThreadsClient(SyncHttpClient(client))
threads_client.get("../assistants/abc-123")
assert len(captured) == 1
wire = captured[0]
assert wire.startswith("/threads/")
segment = wire[len("/threads/") :]
assert "/" not in segment
assert segment == "..%2Fassistants%2Fabc-123"
def test_assistants_get_with_pivot_payload_stays_on_assistants(self) -> None:
captured: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"assistant_id": "anything"})
transport = httpx.MockTransport(handler)
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
assistants_client = SyncAssistantsClient(SyncHttpClient(client))
assistants_client.get("../threads/abc-123")
assert len(captured) == 1
wire = captured[0]
assert wire.startswith("/assistants/")
segment = wire[len("/assistants/") :]
assert "/" not in segment
def test_runs_delete_double_id_pivot_stays_on_threads_runs(self) -> None:
captured: list[tuple[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append((request.method, _wire_path(request)))
return httpx.Response(200)
transport = httpx.MockTransport(handler)
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
runs_client = SyncRunsClient(SyncHttpClient(client))
runs_client.delete("..", "../runs/crons/cron-id")
assert len(captured) == 1
method, wire = captured[0]
assert method == "DELETE"
assert wire.startswith("/threads/")
assert "/runs/crons/" not in wire
parts = wire.split("/")
assert len(parts) == 5
assert parts[1] == "threads"
assert parts[3] == "runs"
assert parts[2] == "%2E%2E"
assert parts[4] == "..%2Fruns%2Fcrons%2Fcron-id"
def test_crons_delete_with_pivot_payload_stays_on_crons(self) -> None:
captured: list[tuple[str, str]] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append((request.method, _wire_path(request)))
return httpx.Response(200)
transport = httpx.MockTransport(handler)
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
crons_client = SyncCronClient(SyncHttpClient(client))
crons_client.delete("../../assistants/abc-123")
assert len(captured) == 1
method, wire = captured[0]
assert method == "DELETE"
assert wire.startswith("/runs/crons/")
segment = wire[len("/runs/crons/") :]
assert "/" not in segment
def test_uuid_identifier_lands_on_intended_path(self) -> None:
captured: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(_wire_path(request))
return httpx.Response(200, json={"thread_id": "anything"})
transport = httpx.MockTransport(handler)
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
threads_client = SyncThreadsClient(SyncHttpClient(client))
threads_client.get("550e8400-e29b-41d4-a716-446655440000")
assert captured == ["/threads/550e8400-e29b-41d4-a716-446655440000"]