Skip to content

URL fields get an extra trailing slash in v3, changing the value the API sent #999

Description

@jakcinmarina

Title: URL fields get an extra trailing slash in v3, changing the value the API sent

Issue description

In v3, URL fields are typed as pydantic.AnyUrl. Pydantic appends / to a URL
that has no path, so the string the client gives back is not the string the API
sent.

Fetching the same actor run two ways shows the difference. The raw API response
returns containerUrl as https://<id>.runs.apify.net, with no trailing slash.
Reading the same field through client.run(...).get().container_url in v3 gives
https://<id>.runs.apify.net/, with one added.

The change happens during validation, not serialization, so every access path
carries it - attribute access, model_dump() in either mode, and
model_dump_json().

This is a change from v2

v2 returned plain dicts (ActorClient.call() and RunClient.get() are
annotated dict | None) and did not depend on pydantic at all, so the value
passed through untouched. Code that upgrades to v3 silently starts seeing a
different string for the same run.

Scope

18 AnyUrl-typed fields across apify_client._models are affected, including
Run.container_url, Task.standby_url, Dataset.console_url,
KeyValueStore.records_public_url, RequestQueue.console_url, and
Webhook.request_url.

Why this is a problem

I realize https://example.com and https://example.com/ are equivalent per
RFC 3986 section 6.2.3, so this may look harmless. Two reasons why it still matters:

  1. It changes silently across a major version. Any code that stored a URL
    under v2 and compares it against a freshly fetched one under v3 breaks on
    upgrade, with nothing in the changelog to explain it.
  2. A client shouldn't alter values the server sent. Even where the two forms
    are equivalent as URLs, they are not equal as strings, and callers reasonably
    expect the field to match the API response.

Anywhere the value is used as a base to build longer URLs, the added slash also
produces a doubled separator (...net//path), which is not equivalent.

We hit this in the Apify Dify plugin and work around it like so:

parts = urlsplit(container_url)
if parts.path == "/":
    payload["containerUrl"] = urlunsplit(parts._replace(path=""))

Metadata

Metadata

Assignees

No one assigned

    Labels

    t-toolingIssues with this label are in the ownership of the tooling team.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions