Skip to content

Trailing slash stripped from relative URLs in BaseHTTPClient._prepare_url #80

@andrii-novikov

Description

@andrii-novikov

Name and Version

aidial-client-python 0.7.1

What steps will reproduce the bug?

  1. In a project that depends on aidial-client and a DIAL Core instance with at least one file uploaded under an appdata folder (so the folder physically exists).
  2. Configure an AsyncDial client against that core, e.g. dial = AsyncDial(base_url="http://localhost:8090", api_key="...").
  3. Call the folder metadata endpoint with a trailing slash, as required by DIAL Core to distinguish a folder listing from a file lookup:
    await dial.metadata.get("files", "files/<bucket>/appdata/<app>/")
  4. Observe the resulting outbound HTTP request and the raised exception.

The same effect occurs for any relative URL passed to the SDK whose trailing / is semantically meaningful — folder listings under /v1/metadata/files/... are the most visible case. The bug affects both Dial and AsyncDial since it lives in the shared BaseHTTPClient.

What is the expected behavior?

The trailing slash supplied by the caller should be preserved on the wire. The above call should reach DIAL Core as:

GET /v1/metadata/files/<bucket>/appdata/<app>/   -> 200 OK

and return the folder's FileMetadata (the same response that a plain curl against the URL with trailing slash returns).

What do you see instead?

BaseHTTPClient._prepare_url in aidial_client/_http_client/_base.py strips the trailing / from the merged path:

def _prepare_url(self, url: str) -> httpx.URL:
    parsed_url = httpx.URL(url)
    if parsed_url.is_relative_url:
        merge_raw_path = self.base_url.raw_path + parsed_url.raw_path.lstrip(b"/")
        return self.base_url.copy_with(raw_path=merge_raw_path.rstrip(b"/"))  # ← drops the trailing slash
    return parsed_url

so the outgoing request becomes:

GET /v1/metadata/files/<bucket>/appdata/<app>    -> 404 Not Found

and the SDK raises:

aidial_client._exception.DialException(message='', status_code=404, type='runtime_error', ...)

Verified independently with curl against the same DIAL Core:

curl …/v1/metadata/files/<bucket>/appdata/<app>    -> 404
curl …/v1/metadata/files/<bucket>/appdata/<app>/   -> 200

Absolute URLs are unaffected because they bypass the is_relative_url branch — the bug only hits relative URLs joined against base_url.

Additional information

The rstrip(b"/") looks like a guard against double slashes at the join boundary, but it also removes the significant trailing slash on the user-supplied relative path. Because base_url is already normalized via enforce_trailing_slash and the relative part is lstrip-ed, no rstrip is needed:

def _prepare_url(self, url: str) -> httpx.URL:
    parsed_url = httpx.URL(url)
    if parsed_url.is_relative_url:
        base = self.base_url.raw_path  # already ends with b"/"
        tail = parsed_url.raw_path.lstrip(b"/")
        return self.base_url.copy_with(raw_path=base + tail)
    return parsed_url

Additional edge cases the proposed fix also addresses:

  • Empty relative URL "" — current code strips the base's own trailing / (e.g. /v1//v1); proposed fix preserves it.
  • Relative URL equal to "/" — same as above.
  • Multiple meaningful trailing slashes (files/x//) — current rstrip(b"/") collapses all of them; proposed fix preserves caller intent.
  • Relative URL containing a query string (files/x/?foo=1) — not broken today, since httpx.URL.raw_path includes the query string so / is no longer the last byte; the fix doesn't regress it.

Environment: aidial-client-python 0.7.1, Python 3.13, httpx (transitive).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions