1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
pub mod billing;
pub mod concat;
pub mod cover;
pub mod delete;
pub mod feed;
pub mod generate;
pub mod lyrics;
pub mod metadata;
pub mod persona;
pub mod remaster;
pub mod stems;
pub mod types;
use std::sync::Mutex;
use reqwest::Client;
use crate::auth::{self, AuthState};
use crate::errors::CliError;
pub struct SunoClient {
client: Client,
/// Auth state behind a sync mutex so `&self` methods can transparently
/// refresh the JWT mid-request when Suno returns
/// `Token validation failed.` (their server-side staleness threshold
/// kicks in well before the JWT's own `exp` claim). The lock is only
/// held briefly to read/clone auth fields — never across awaits.
auth: Mutex<AuthState>,
}
const BASE_URL: &str = "https://studio-api-prod.suno.com";
impl SunoClient {
/// Create a new client. If JWT is expired but we have a Clerk cookie,
/// auto-refresh the JWT transparently.
pub async fn new_with_refresh(mut auth: AuthState) -> Result<Self, CliError> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
.build()
.map_err(|e| CliError::Config(format!("HTTP client: {e}")))?;
if auth.is_jwt_expired() {
// Try auto-refresh via Clerk cookie
if let (Some(cookie), Some(session_id)) = (&auth.clerk_client_cookie, &auth.session_id)
{
eprintln!("JWT expired, refreshing via Clerk...");
match auth::clerk_refresh_jwt(&client, cookie, session_id).await {
Ok(jwt) => {
auth.jwt = Some(jwt);
auth.save()?;
eprintln!("JWT refreshed successfully");
}
Err(e) => {
eprintln!("JWT refresh failed: {e}");
return Err(CliError::AuthExpired);
}
}
} else {
return Err(CliError::AuthExpired);
}
}
Ok(Self {
client,
auth: Mutex::new(auth),
})
}
pub(crate) fn get(&self, path: &str) -> reqwest::RequestBuilder {
self.client
.get(format!("{BASE_URL}{path}"))
.headers(self.headers())
}
pub(crate) fn post(&self, path: &str) -> reqwest::RequestBuilder {
self.client
.post(format!("{BASE_URL}{path}"))
.headers(self.headers())
}
fn headers(&self) -> reqwest::header::HeaderMap {
let mut headers = reqwest::header::HeaderMap::new();
// Lock briefly, clone the strings we need, drop the guard before
// touching the header map. Never hold the lock across an await.
let (jwt, device) = {
let auth = self.auth.lock().expect("auth mutex poisoned");
(
auth.jwt.clone(),
auth.device_id
.clone()
.unwrap_or_else(|| "00000000-0000-0000-0000-000000000000".to_string()),
)
};
if let Some(jwt) = jwt
&& let Ok(val) = format!("Bearer {jwt}").parse()
{
headers.insert("authorization", val);
}
if let Ok(val) = device.parse() {
headers.insert("device-id", val);
}
if let Ok(val) = auth::browser_token().parse() {
headers.insert("browser-token", val);
}
if let Ok(val) = "https://suno.com".parse() {
headers.insert("origin", val);
}
if let Ok(val) = "https://suno.com/".parse() {
headers.insert("referer", val);
}
headers
}
/// Refresh the JWT via the stored Clerk session cookie. Used by the
/// in-process retry path in `with_auth_retry` when Suno's server-side
/// staleness check fires mid-request despite a still-valid `exp` claim.
pub(crate) async fn refresh_jwt(&self) -> Result<(), CliError> {
// Snapshot the cookie + session_id under the lock, then drop it
// before the network call.
let (cookie, session_id) = {
let auth = self.auth.lock().expect("auth mutex poisoned");
(
auth.clerk_client_cookie
.clone()
.ok_or(CliError::AuthExpired)?,
auth.session_id.clone().ok_or(CliError::AuthExpired)?,
)
};
let jwt = auth::clerk_refresh_jwt(&self.client, &cookie, &session_id).await?;
// Re-lock briefly to write the new JWT and persist.
{
let mut auth = self.auth.lock().expect("auth mutex poisoned");
auth.jwt = Some(jwt);
auth.save()?;
}
Ok(())
}
/// Run an async API call once. If it fails with `AuthExpired`, refresh
/// the JWT and try a single retry. Wraps the write/poll paths so
/// long-running waits (5–30+ minute generation queues) survive Suno's
/// JWT staleness window.
pub(crate) async fn with_auth_retry<F, Fut, T>(&self, mut f: F) -> Result<T, CliError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, CliError>>,
{
match f().await {
Err(CliError::AuthExpired) => {
self.refresh_jwt().await?;
f().await
}
other => other,
}
}
pub async fn check_response(
&self,
resp: reqwest::Response,
) -> Result<reqwest::Response, CliError> {
let status = resp.status();
if status == 401 {
return Err(CliError::AuthExpired);
}
if status == 403 {
let body = resp.text().await.unwrap_or_default();
return Err(CliError::Api {
code: "forbidden",
message: format!("HTTP 403 Forbidden: {body}"),
});
}
if status == 429 {
return Err(CliError::RateLimited);
}
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
// Map known Suno error patterns to actionable codes so callers
// get a meaningful suggestion instead of an opaque HTTP dump.
//
// `Token validation failed` is what Suno returns when the JWT
// has crossed their server-side staleness threshold (~30 min)
// even when the JWT's own `exp` claim is still valid. We treat
// it as `AuthExpired` so the next CLI invocation will refresh
// via the Clerk session cookie and pick up a fresh token.
if body.contains("Token validation failed") {
return Err(CliError::AuthExpired);
}
if body.contains("'loc': ['body', 'params'")
|| body.contains("\"loc\": [\"body\", \"params\"")
{
return Err(CliError::Api {
code: "schema_drift",
message: format!(
"HTTP {status}: Suno's request schema has changed — the CLI needs an update. Body: {body}"
),
});
}
return Err(CliError::Api {
code: "api_error",
message: format!("HTTP {status}: {body}"),
});
}
Ok(resp)
}
}