Skip to content

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

Closed

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=""))

Activity

  1. added
    t-toolingIssues with this label are in the ownership of the tooling team.
    on Aug 7, 2026
  2. vdusek commented on Aug 10, 2026

    @vdusek
    Contributor

    Hi, thanks for the report.

    The datamodel-codegen generates the AnyUrl field type from the format: uri specification. That's where it comes from.

    An extra trailing slash is not the only thing; see:

    Image

    This is unfortunate; avoiding normalization of URL fields would require changing their type from AnyUrl to str, which would be a breaking change.

    cc @Pijukatel @janbuchar, I'd like to hear your thoughts

  3. Pijukatel commented on Aug 10, 2026

    @Pijukatel
    Contributor

    Not sure, but I would probably lean towards this:

    Update the change log and explicitly mention this in the upgrade guide:
    You should not compare the string saved URL with the string representation of AnyUrl. The correct approach is to convert any string-based URL to AnyUrl and only then compare it.

    So, for example, if you already have a string-based URL:

    url_as_string = "https://www.žluty.cz"
    url_normalized = AnyUrl("https://www.žluty.cz")
    
    # DO NOT COMPARE LIKE THIS:
    url_as_string == str(url_normalized)
    
    # COMPARE LIKE THIS:
    AnyUrl(url_as_string) == url_normalized
    

    But to be honest, I am not sure if we consistently stick to this advice in the SDK or if we do this somewhere by mistake as well...

  4. vdusek commented on Aug 12, 2026

    @vdusek
    Contributor

    After the internal discussion, we consider this behavior correct and will document it rather than change it. The best practise should be what @Pijukatel suggested - comparing URLs in their normalized form.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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