Skip to content

fix: add fallback logic for CDN urls#1524

Merged
roderickvd merged 5 commits into
librespot-org:devfrom
lemon-sh:cdnurl-fallback
Aug 8, 2025
Merged

fix: add fallback logic for CDN urls#1524
roderickvd merged 5 commits into
librespot-org:devfrom
lemon-sh:cdnurl-fallback

Conversation

@lemon-sh

@lemon-sh lemon-sh commented Aug 3, 2025

Copy link
Copy Markdown
Contributor
  • The solution here is to make CdnUrl return all non-expired URLs instead of just the first one. Then, in AudioFileStreaming all URLs are tried and the first working URL is selected for later use.
    • This means that the fallback logic is not implemented for following requests in AudioFileFetch::download_range, but that is also a synchronous function so implementing that would probably require an overhaul of the codebase anyway, unless I'm missing something.
      • This would only become an issue if the URL that worked for the first request stopped working for the following requests.
  • I tried minimizing API breakage, but it seems impossible to implement the fallback logic inside stream_from_cdn, as it is not async (it doesn't actually send the request), so I made it accept the singular URL instead of CdnUrl. It is the responsibility of the caller to implement URL fallback logic by inspecting the result of the streamer.
  • The main issue in this first version of the PR is that it's not impossible to use an arbitrary URL with stream_from_cdn anymore, but there are a few ways this could be fixed.
    • maybe adding a new type for the singular URLs, like SingleCdnUrl?
    • or adding a method to CdnUrl that would select the working URL and discard the rest?
Example log
[...]
[2025-08-03T16:49:40Z INFO  librespot_core::spclient] Resolved "gew4-spclient.spotify.com:443" as spclient access point
[2025-08-03T16:49:40Z INFO  librespot_connect::spirc] active device is <405ad53c8038c055e1b13c844c31f50d8bb4b136> with session <3b615ec204e9964c776ce57aa0896dac>
[2025-08-03T16:49:40Z WARN  librespot_connect::state::context] couldn't load context info because: context is not available. type: Default
[2025-08-03T16:49:40Z INFO  librespot_playback::player] Loading <No Fun> with Spotify URI <spotify:track:5ImCVtO1gvcD1ttdG5SrQT>
[2025-08-03T16:49:41Z WARN  librespot_audio::fetch] Fetching https://audio-akp.spotifycdn.com/audio/[...] failed with error Error { kind: Unavailable, error: hyper_util::client::legacy::Error(Connect, Custom { kind: Other, error: ConnectError("dns error", Os { code: 11001, kind: Uncategorized, message: "No such host is known." }) }) }, trying next
[2025-08-03T16:49:41Z INFO  librespot_playback::player] <No Fun> (411147 ms) loaded

Closes: #1521

@roderickvd

Copy link
Copy Markdown
Member

This seems like a pretty clean solution, I like the direction.

  • The solution here is to make CdnUrl return all non-expired URLs instead of just the first one. Then, in AudioFileStreaming all URLs are tried and the first working URL is selected for later use.

It's been a while since I coded all of this, so I forgot how timeouts in this code path work. Do we need to do a tokio::time::timeout when we're trying the URLs? Or are the HTTP requests already set up with a proper timeout?

* This would only become an issue if the URL that worked for the first request stopped working for the following requests.

This is acceptable behavior to me. Yes, it'd be great to all of this exactly when the request happens. But like you say, it'd probably be a lot of work when the user could simply retry the request in the UI.

  • I tried minimizing API breakage, but it seems impossible to implement the fallback logic inside stream_from_cdn, as it is not async (it doesn't actually send the request), so I made it accept the singular URL instead of CdnUrl. It is the responsibility of the caller to implement URL fallback logic by inspecting the result of the streamer.

Also acceptable to me. Just saying that API breakage should not hold you up from doing something better.

  • The main issue in this first version of the PR is that it's not impossible to use an arbitrary URL with stream_from_cdn anymore, but there are a few ways this could be fixed.

Although I'd like to use something like a Url instead of a String, could you help me out what'd be the actual problem if arbitrary URLs were passed in?

I vaguely remember that I created CdnUrls not out of security reasons, but to encapsulate behavior in a newtype.

@roderickvd roderickvd requested a review from Copilot August 3, 2025 21:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds fallback logic for CDN URLs to handle cases where the first CDN URL fails to connect. The solution involves returning all non-expired URLs from CdnUrl::try_get_urls() instead of just the first one, and implementing retry logic in AudioFileStreaming to try URLs sequentially until one works.

  • Adds a new try_get_urls() method to return all valid CDN URLs for fallback attempts
  • Modifies AudioFileStreaming to iterate through URLs and retry on connection failures
  • Changes stream_from_cdn API to accept a string URL instead of CdnUrl object

Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
core/src/cdn_url.rs Adds try_get_urls() method and deprecates try_get_url() for fallback support
core/src/spclient.rs Changes stream_from_cdn to accept string URL parameter instead of CdnUrl
audio/src/fetch/mod.rs Implements URL fallback logic in AudioFileStreaming and stores selected URL as string

Comment thread audio/src/fetch/mod.rs Outdated
Comment thread audio/src/fetch/mod.rs Outdated
@lemon-sh

lemon-sh commented Aug 4, 2025

Copy link
Copy Markdown
Contributor Author

It's been a while since I coded all of this, so I forgot how timeouts in this code path work. Do we need to do a tokio::time::timeout when we're trying the URLs? Or are the HTTP requests already set up with a proper timeout?

I'm not that familiar with the HTTP client part of librespot, but it seems like there is no timeout? Good catch, I'll take a look.

Although I'd like to use something like a Url instead of a String, could you help me out what'd be the actual problem if arbitrary URLs were passed in?

I guess using CdnUrl specifically made the API more foolproof by only allowing URLs returned from Spotify to be used, but that's not a big deal to be honest. Besides, if the user wants to use stream_from_cdn with an URL from a different source for whatever reason, it is now possible to do that.

In terms of just using a general URL type, well... Rust doesn't have a native one. hyper::Uri or http::Uri could be used, but that makes the API messy IMHO, unless we create a newtype.

@roderickvd

Copy link
Copy Markdown
Member

I'm not that familiar with the HTTP client part of librespot, but it seems like there is no timeout? Good catch, I'll take a look.

Cool, thanks.

Besides, if the user wants to use stream_from_cdn with an URL from a different source for whatever reason, it is now possible to do that.

This may in fact be necessary for external podcasts.

In terms of just using a general URL type, well... Rust doesn't have a native one. hyper::Uri or http::Uri could be used, but that makes the API messy IMHO, unless we create a newtype.

It will be converted into Uri at some point as hyper probably accepts something like url: Into<Uri>. And throw an error when it’s invalid. So it’s a question of whether we want to catch that early on or not.

@lemon-sh

lemon-sh commented Aug 6, 2025

Copy link
Copy Markdown
Contributor Author

I'll look at the timeout as soon as possible, currently getting hit by #1527 which makes it difficult to test lol.

It will be converted into Uri at some point as hyper probably accepts something like url: Into<Uri>. And throw an error when it’s invalid. So it’s a question of whether we want to catch that early on or not.

It is converted to http::Uri in stream_from_cdn as the request builder accepts a TryInto<Uri>, so IMO at best we could just accept impl Into<Uri> in stream_from_cdn.

@roderickvd

Copy link
Copy Markdown
Member

Yes, I also think it's best to do that so we can fail early on an invalid URI.

tdgroot added a commit to tdgroot/librespot that referenced this pull request Aug 7, 2025
@lemon-sh

lemon-sh commented Aug 7, 2025

Copy link
Copy Markdown
Contributor Author

Yes, I also think it's best to do that so we can fail early on an invalid URI.

It seems that we do already though, stream_from_cdn fails with a malformed URL before anything is ever awaited. This:

session.spclient().stream_from_cdn("m:?c", 0, 1024)

Fails with:

Client specified an invalid argument { invalid format }

Is this the early fail behavior you're talking about?

After considering it for a while, I think there are only two sane options here. Either we keep &str or we do this (generic copied from the request builder method we use the URI in):

    pub fn stream_from_cdn<U>(&self, cdn_url: U, offset: usize, length: usize) -> Result<IntoStream<ResponseFuture>, Error>
    where
        U: TryInto<Uri>,
        <U as TryInto<Uri>>::Error: Into<http::Error>

The fail behavior is the same, but this allows the consumer to pass anything that the request builder would accept, not just &str.

@lemon-sh

lemon-sh commented Aug 7, 2025

Copy link
Copy Markdown
Contributor Author

In terms of timeouts, I tried firewalling off one of the the CDN hosts to see how librespot would behave, and I only hit the OS timeout after like 2 minutes. So we probably do want to wrap the call in a custom timeout with a more sane value like 30 seconds maybe?

Log:

[2025-08-07T18:16:42Z WARN  librespot_audio::fetch] Fetching https://audio-cf.spotifycdn.com/audio/f0a65b44aff025a6ea5a8f6d9dcbd43baff09865?verify=1754676734-Dli%2BvoShXhIsKLxw8wlyVEMQ3fZtc3P11WLqZXQRA20%3D
failed with error Error { kind: Unavailable, error: hyper_util::client::legacy::Error(Connect, Custom { kind: Other, error: ConnectError("tcp connect error", Os { code: 110, kind: TimedOut, message: "Connection timed out" }) }) }, trying next

@kingosticks

Copy link
Copy Markdown
Contributor

If you go for this, I think a much shorter timeout. If something takes 30 seconds I think people have already restarted it. Maybe 5?

@lemon-sh

lemon-sh commented Aug 7, 2025

Copy link
Copy Markdown
Contributor Author

For now I went for 10 seconds, I personally feel like 5s is too short and with Spotify's long history of abysmal infrastructure performance I can easily imagine a download taking a bit longer and succeeding anyway.

@roderickvd

Copy link
Copy Markdown
Member

Is this the early fail behavior you're talking about?

After considering it for a while, I think there are only two sane options here. Either we keep &str or we do this (generic copied from the request builder method we use the URI in):

    pub fn stream_from_cdn<U>(&self, cdn_url: U, offset: usize, length: usize) -> Result<IntoStream<ResponseFuture>, Error>
    where
        U: TryInto<Uri>,
        <U as TryInto<Uri>>::Error: Into<http::Error>

The fail behavior is the same, but this allows the consumer to pass anything that the request builder would accept, not just &str.

Yes, I like the second. Catch invalid input, and throw an error when we do, as fast as we can.

@roderickvd roderickvd merged commit 3a700f0 into librespot-org:dev Aug 8, 2025
13 checks passed
@roderickvd

Copy link
Copy Markdown
Member

Great, thanks a lot. Merged.

@kingosticks

Copy link
Copy Markdown
Contributor

Awesome, thanks both.

@lemon-sh lemon-sh deleted the cdnurl-fallback branch August 8, 2025 16:25
@lemon-sh

lemon-sh commented Aug 8, 2025

Copy link
Copy Markdown
Contributor Author

Whoops, looks like I forgot to update the changelog after the last commit. It still says &str ^^"

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

audio-akp.spotifycdn.com cannot be resolved

4 participants