fix(musicbrainz): query MusicBrainz directly per-IP instead of via the proxy

The Cloudflare proxy funnelled every user's MusicBrainz traffic onto a few
egress IPs, and MusicBrainz rate-limits 1 req/s per IP, so the budget was
shared across the whole userbase and produced 503s that worsen with growth.
MB read access needs no key and authentication does not raise the limit, so
the proxy added only the funnel; going per-IP makes the budget per-user.

- qbz-integrations MusicBrainzConfig default is now use_proxy=false (direct).
- client: handle HTTP 503 + Retry-After (not just 429, which the proxy used
  to translate); add parse_retry_after; route search_recording_by_isrc and
  search_artist through handle_response_status.
- src-tauri session_lifecycle: MusicBrainz V2 init/teardown pass use_proxy=false.

Slint inherits this via CoreBridge's default-constructed client.

cc
This commit is contained in:
vicrodh committed 2026-05-28 14:12:12 -06:00
1 parent 78b1a9c508
commit a2d8e43b1c
2 files changed
+34 -32

No files matched your search

@@ -72,7 +72,12 @@ impl Default for MusicBrainzConfig {
fn default() -> Self {
Self {
enabled: true,
use_proxy: true,
// Direct-to-MusicBrainz by default: each client uses its OWN IP, so
// the per-IP 1 req/s budget is per-user instead of shared across all
// QBZ users behind the proxy's Cloudflare egress IPs (which is what
// triggered the 503s). MB read access needs no key, so the proxy
// added only the funnel. Flip to true to route via the proxy again.
use_proxy: false,
}
}
}
@@ -160,19 +165,7 @@ impl MusicBrainzClient {
let url = format!("{}/recording?query=isrc:{}&fmt=json", base, isrc);
let response = self.client.get(&url).send().await?;
if !response.status().is_success() {
let status = response.status();
if status.as_u16() == 429 {
return Err(IntegrationError::RateLimited(60));
}
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"MusicBrainz search failed: {} - {}",
status, text
)));
}
let response = self.handle_response_status(response).await?;
response.json().await.map_err(Into::into)
}
@@ -198,19 +191,7 @@ impl MusicBrainzClient {
);
let response = self.client.get(&url).send().await?;
if !response.status().is_success() {
let status = response.status();
if status.as_u16() == 429 {
return Err(IntegrationError::RateLimited(60));
}
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"MusicBrainz artist search failed: {} - {}",
status, text
)));
}
let response = self.handle_response_status(response).await?;
response.json().await.map_err(Into::into)
}
@@ -757,8 +738,13 @@ impl MusicBrainzClient {
return Ok(response);
}
let status = response.status();
if status.as_u16() == 429 {
return Err(IntegrationError::RateLimited(60));
// 429 (proxy-translated) and 503 (direct MusicBrainz) both signal that
// the rate limit was hit. Surface the server's Retry-After so the caller
// can back off instead of treating it as a generic error.
if matches!(status.as_u16(), 429 | 503) {
return Err(IntegrationError::RateLimited(Self::parse_retry_after(
&response,
)));
}
let text = response.text().await.unwrap_or_default();
Err(IntegrationError::internal(format!(
@@ -767,6 +753,19 @@ impl MusicBrainzClient {
)))
}
/// Parse the `Retry-After` header (whole seconds). MusicBrainz sends it on
/// HTTP 503 when the per-IP rate limit is exceeded. Defaults to 1s because
/// MB's per-IP limiter recovers within ~1 second.
fn parse_retry_after(response: &reqwest::Response) -> u64 {
response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|&s| s > 0)
.unwrap_or(1)
}
/// Escape special characters in Lucene queries
fn escape_query(s: &str) -> String {
s.replace('\\', "\\\\")