Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .fern/metadata.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"cliVersion": "3.27.0",
"cliVersion": "3.32.0",
"generatorName": "fernapi/fern-python-sdk",
"generatorVersion": "4.46.3"
"generatorVersion": "4.46.6",
"sdkVersion": "0.1.16"
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ dynamic = ["version"]

[tool.poetry]
name = "credal"
version = "0.1.15"
version = "0.1.16"
description = ""
readme = "README.md"
authors = []
Expand Down
4 changes: 2 additions & 2 deletions src/credal/core/client_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ def __init__(

def get_headers(self) -> typing.Dict[str, str]:
headers: typing.Dict[str, str] = {
"User-Agent": "credal/0.1.15",
"User-Agent": "credal/0.1.16",
"X-Fern-Language": "Python",
"X-Fern-SDK-Name": "credal",
"X-Fern-SDK-Version": "0.1.15",
"X-Fern-SDK-Version": "0.1.16",
**(self.get_custom_headers() or {}),
}
headers["Authorization"] = f"Bearer {self._get_api_key()}"
Expand Down
33 changes: 28 additions & 5 deletions src/credal/core/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import re
import time
import typing
import urllib.parse
from contextlib import asynccontextmanager, contextmanager
from random import random

Expand Down Expand Up @@ -123,6 +122,30 @@ def _should_retry(response: httpx.Response) -> bool:
return response.status_code >= 500 or response.status_code in retryable_400s


def _build_url(base_url: str, path: typing.Optional[str]) -> str:
"""
Build a full URL by joining a base URL with a path.

This function correctly handles base URLs that contain path prefixes (e.g., tenant-based URLs)
by using string concatenation instead of urllib.parse.urljoin(), which would incorrectly
strip path components when the path starts with '/'.

Example:
>>> _build_url("https://cloud.example.com/org/tenant/api", "/users")
'https://cloud.example.com/org/tenant/api/users'

Args:
base_url: The base URL, which may contain path prefixes.
path: The path to append. Can be None or empty string.

Returns:
The full URL with base_url and path properly joined.
"""
if not path:
return base_url
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"


def _maybe_filter_none_from_multipart_data(
data: typing.Optional[typing.Any],
request_files: typing.Optional[RequestFiles],
Expand Down Expand Up @@ -294,7 +317,7 @@ def request(

response = self.httpx_client.request(
method=method,
url=urllib.parse.urljoin(f"{base_url}/", path),
url=_build_url(base_url, path),
headers=jsonable_encoder(
remove_none_from_dict(
{
Expand Down Expand Up @@ -397,7 +420,7 @@ def stream(

with self.httpx_client.stream(
method=method,
url=urllib.parse.urljoin(f"{base_url}/", path),
url=_build_url(base_url, path),
headers=jsonable_encoder(
remove_none_from_dict(
{
Expand Down Expand Up @@ -515,7 +538,7 @@ async def request(
# Add the input to each of these and do None-safety checks
response = await self.httpx_client.request(
method=method,
url=urllib.parse.urljoin(f"{base_url}/", path),
url=_build_url(base_url, path),
headers=jsonable_encoder(
remove_none_from_dict(
{
Expand Down Expand Up @@ -620,7 +643,7 @@ async def stream(

async with self.httpx_client.stream(
method=method,
url=urllib.parse.urljoin(f"{base_url}/", path),
url=_build_url(base_url, path),
headers=jsonable_encoder(
remove_none_from_dict(
{
Expand Down
36 changes: 35 additions & 1 deletion tests/utils/test_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@

import pytest

from credal.core.http_client import AsyncHttpClient, HttpClient, get_request_body, remove_none_from_dict
from credal.core.http_client import (
AsyncHttpClient,
HttpClient,
_build_url,
get_request_body,
remove_none_from_dict,
)
from credal.core.request_options import RequestOptions


Expand Down Expand Up @@ -264,3 +270,31 @@ async def test_async_http_client_passes_encoded_params_when_present() -> None:
params = dummy_client.last_request_kwargs["params"]
# For a simple dict, encode_query should give a single (key, value) tuple
assert params == [("after", "456")]


def test_basic_url_joining() -> None:
"""Test basic URL joining with a simple base URL and path."""
result = _build_url("https://api.example.com", "/users")
assert result == "https://api.example.com/users"


def test_basic_url_joining_trailing_slash() -> None:
"""Test basic URL joining with a simple base URL and path."""
result = _build_url("https://api.example.com/", "/users")
assert result == "https://api.example.com/users"


def test_preserves_base_url_path_prefix() -> None:
"""Test that path prefixes in base URL are preserved.

This is the critical bug fix - urllib.parse.urljoin() would strip
the path prefix when the path starts with '/'.
"""
result = _build_url("https://cloud.example.com/org/tenant/api", "/users")
assert result == "https://cloud.example.com/org/tenant/api/users"


def test_preserves_base_url_path_prefix_trailing_slash() -> None:
"""Test that path prefixes in base URL are preserved."""
result = _build_url("https://cloud.example.com/org/tenant/api/", "/users")
assert result == "https://cloud.example.com/org/tenant/api/users"