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
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
pub mod billing;
pub mod captcha;
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 if let Some(cookie) = &auth.clerk_client_cookie {
eprintln!("JWT expired, recovering Clerk session...");
match auth::clerk_token_exchange(&client, cookie).await {
Ok((session_id, jwt)) => {
auth.session_id = Some(session_id);
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(),
)
};
let (session_id, jwt) = if let Some(session_id) = session_id {
let jwt = auth::clerk_refresh_jwt(&self.client, &cookie, &session_id).await?;
(session_id, jwt)
} else {
auth::clerk_token_exchange(&self.client, &cookie).await?
};
// Re-lock briefly to write the new JWT and persist.
{
let mut auth = self.auth.lock().expect("auth mutex poisoned");
auth.session_id = Some(session_id);
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();
if looks_like_auth_expired(&body) {
return Err(CliError::AuthExpired);
}
return Err(CliError::Api {
code: "forbidden",
message: format!("HTTP 403 Forbidden: {body}"),
});
}
if status == 429 {
return Err(CliError::RateLimited);
}
if status == 404 {
return Err(classify_404(resp.url().path()));
}
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 looks_like_auth_expired(&body) {
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)
}
}
fn looks_like_auth_expired(body: &str) -> bool {
let lower = body.to_ascii_lowercase();
lower.contains("token validation failed")
|| lower.contains("jwt expired")
|| lower.contains("jwt is expired")
|| lower.contains("invalid jwt")
|| lower.contains("invalid token")
|| lower.contains("not authenticated")
|| lower.contains("unauthenticated")
}
/// A 404's meaning depends on the endpoint. On a fixed route (billing/info,
/// the captcha check, generate/concat, the feed listing) a 404 means the
/// endpoint moved or the schema drifted — retrying the same request won't
/// help, so it maps to `api_error` with an "update the CLI" suggestion, not
/// "fix the ID". On every other endpoint the 404 is ID-addressed (feed-by-id,
/// `/api/gen/{id}/…`, delete/set/publish) and means the caller's ID doesn't
/// exist — a permanent NotFound (exit 3).
fn classify_404(path: &str) -> CliError {
// Bare routes carry no caller-supplied resource id (feed-by-id polls
// `/api/feed/` with an `ids=` query, which is NOT in this set).
const ROUTE_LEVEL: &[&str] = &[
"/api/billing/info/",
"/api/c/check",
"/api/generate/v2-web/",
"/api/generate/concat/v2/",
"/api/generate/lyrics/",
"/api/feed/v3",
];
if ROUTE_LEVEL.contains(&path) {
return CliError::Api {
code: "api_error",
message: format!(
"HTTP 404 on {path} — this Suno endpoint moved or its schema changed; \
run `suno update` to pull the latest fix"
),
};
}
CliError::NotFound(format!("{path} (HTTP 404)"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_level_404_is_transient_not_notfound() {
// A moved/renamed route → api_error (retryable / update the CLI), never
// "fix the ID". The live /api/feed/v3 404 motivated this split.
for path in [
"/api/billing/info/",
"/api/c/check",
"/api/generate/v2-web/",
"/api/feed/v3",
] {
assert!(
matches!(
classify_404(path),
CliError::Api {
code: "api_error",
..
}
),
"{path} should be api_error"
);
}
}
#[test]
fn id_addressed_404_is_notfound() {
// Feed-by-id (info/download poll) hits `/api/feed/` with an ids query;
// the path alone is not a known route, so it's a bad-ID NotFound.
for path in [
"/api/feed/",
"/api/gen/abc-123/set_visibility/",
"/api/feed/trash",
"/api/edit/stems/abc-123",
] {
assert!(
matches!(classify_404(path), CliError::NotFound(_)),
"{path} should be NotFound"
);
}
}
}