decolua/9router · error · Error
Failed to save tokens
Error message
Failed to save tokens
What it means
GeminiCLIService.saveTokens() POSTs the OAuth tokens, user email and projectId to the local 9Router server at `/api/cli/providers/gemini-cli`. When that HTTP response is not OK, it parses the JSON body and re-throws the server's `error` field; if the body has no `error` field (or the body is not valid JSON), the generic fallback message "Failed to save tokens" is thrown. So this error means the token upload to the local gateway server failed, and the server did not report a specific reason.
Source
Thrown at src/lib/oauth/services/gemini.js:149
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-User-Id": userId,
},
body: JSON.stringify({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
email: userInfo.email,
projectId: projectId,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to save tokens");
}
return await response.json();
}
/**
* Complete Gemini OAuth flow
*/
async connect() {
const spinner = createSpinner("Starting Gemini OAuth...").start();
try {
spinner.text = "Starting local server...";
// Start local server for callback
let callbackParams = null;
const { port, close } = await startLocalServer((params) => {
callbackParams = params;View on GitHub (pinned to 90b52e06ff)
Solutions
- Verify the 9Router server is running and that the `server` URL from getServerCredentials() (env/config) matches its actual host and port (default http://localhost:20128).
- Re-login to the CLI or regenerate the session token so the `Authorization: Bearer <token>` and `X-User-Id` headers are valid.
- Check the server logs for the /api/cli/providers/gemini-cli route to see the real status code, since this message hides the HTTP status.
- If the server returns non-JSON on error, fix/patch saveTokens to use response.text() before parsing so the real error surfaces.
Example fix
// before
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to save tokens");
}
// after
if (!response.ok) {
const text = await response.text();
let msg;
try { msg = JSON.parse(text).error; } catch { msg = text; }
throw new Error(msg || `Failed to save tokens (HTTP ${response.status})`);
} Defensive patterns
Strategy: validation
Validate before calling
const { server, token, userId } = getServerCredentials();
if (!server || !token) throw new Error('CLI not logged in: missing server URL or session token');
const health = await fetch(`${server}/api/health`).catch(() => null);
if (!health || !health.ok) throw new Error(`9Router server unreachable at ${server}`); Type guard
function isTokenSaveErrorBody(body) {
return body !== null && typeof body === 'object' && typeof body.error === 'string' && body.error.length > 0;
} Try / catch
try {
await service.saveTokens(tokens, userInfo, projectId);
} catch (err) {
if (err.message === 'Failed to save tokens') {
console.error('Token upload failed — is the 9Router server running and are you logged in? Run the login flow and retry.');
}
throw err;
} Prevention
- Run the login/health-check step before attempting any provider OAuth connect so server URL and session token are known-good.
- Log response.status for non-OK server calls; the current message drops the HTTP status.
- Use response.text() before JSON.parse so non-JSON error bodies (HTML 404/502) surface verbatim.
- Wrap saveTokens in a retry with backoff for transient 5xx from the local server.
When it happens
Trigger: saveTokens(tokens, userInfo, projectId) called at the end of GeminiCLIService.connect() when fetch() returns a non-2xx status from `${server}/api/cli/providers/gemini-cli` and response.json() either has no `error` property or fails to parse.
Common situations: The local server is not running or `server` from getServerCredentials() points at the wrong host/port; the CLI session token (`token`) is stale so the server returns 401 without an `error` field; the server route returns an HTML error page (e.g. 404/502 from a proxy) instead of JSON, making response.json() throw or return an object without `error`.
Understand the failure class
Background: 'Something went wrong' / 'Request failed (500)' / 'HTTP error! status: 404' — what failed HTTP requests actually mean and how to find the real cause — this error's family across 28 libraries.
Related errors
- loadCodeAssist failed: HTTP ${response.status} ${errorText.s
- onboardUser HTTP ${response.status}: ${errorText.slice(0, 20
- Failed to get device code: ${error}
- Failed to connect to server
- Token exchange failed: ${error}
AI-assisted analysis of decolua/9router@90b52e06ff (2026-08-30).
Data as JSON: /api/errors/2417ea8ba952e31a.
Report an issue: GitHub.