Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/oauth-connection-error-cause.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Include the underlying network cause (DNS failure, refused connection, TLS or timeout errors) in OAuth connection error messages instead of a bare "fetch failed".
8 changes: 4 additions & 4 deletions packages/oauth/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
*/

export class OAuthError extends Error {
constructor(message: string) {
super(message);
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'OAuthError';
}
}
Expand All @@ -30,8 +30,8 @@ export class OAuthUnauthorizedError extends OAuthError {
}

export class OAuthConnectionError extends OAuthError {
constructor(message: string) {
super(message);
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'OAuthConnectionError';
}
}
Expand Down
20 changes: 19 additions & 1 deletion packages/oauth/src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ async function postForm(
});
} catch (error) {
throw new OAuthConnectionError(
`OAuth request to ${url} failed: ${error instanceof Error ? error.message : String(error)}`,
`OAuth request to ${url} failed: ${describeFetchFailure(error)}`,
{ cause: error },
);
}
const status = response.status;
Expand All @@ -96,6 +97,23 @@ async function postForm(
return { status, data };
}

/**
* Flatten a fetch rejection into a readable message. undici throws a generic
* `TypeError: fetch failed` and hides the real reason (DNS, refused, TLS,
* timeout) in a nested `cause` chain — walk it so the surfaced message names
* the actual failure instead of just "fetch failed".
*/
function describeFetchFailure(error: unknown): string {
if (!(error instanceof Error)) return String(error);
const messages = new Set<string>();
let current: Error | undefined = error;
while (current !== undefined) {
messages.add(current.message);
current = current.cause instanceof Error ? current.cause : undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Traverse AggregateError entries when describing fetch failures

When connection attempts cover multiple resolved addresses, Node 24.15 can reject fetch with a TypeError whose cause is an AggregateError; the individual ECONNREFUSED details are in AggregateError.errors, while its own message is empty and it has no cause. This loop therefore produces fetch failed: and still hides the transport reasons this change is meant to expose. Flatten AggregateError.errors as well as the linear cause chain.

Useful? React with 👍 / 👎.

}
return [...messages].join(': ');
}

// ── requestDeviceAuthorization ────────────────────────────────────────

export async function requestDeviceAuthorization(
Expand Down
21 changes: 21 additions & 0 deletions packages/oauth/test/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,27 @@ describe('refreshAccessToken', () => {
).rejects.toBeInstanceOf(OAuthConnectionError);
});

it('names the transport root cause in the connection error message', async () => {
const badConfig: OAuthFlowConfig = {
...flowConfig(),
oauthHost: 'http://127.0.0.1:1', // reserved port, ECONNREFUSED
};

const failure = await refreshToken(badConfig, 'rt', {
maxRetries: 1,
backoffMs: () => 0,
}).catch((error: unknown) => error);

expect(failure).toBeInstanceOf(OAuthConnectionError);
const error = failure as OAuthConnectionError;
expect(error.cause).toBeInstanceOf(Error);
// The undici root cause (e.g. `connect ECONNREFUSED 127.0.0.1:1`) must be
// visible in the surfaced message, not just the generic "fetch failed".
const rootCause = error.cause instanceof Error ? error.cause : undefined;
expect(rootCause?.message).toBeTruthy();
expect(error.message).toContain(rootCause?.message);
});

it('sends grant_type=refresh_token + refresh_token', async () => {
server.enqueue('/api/oauth/token', {
status: 200,
Expand Down
Loading