diff --git a/dev/src/server/adk_api_server.ts b/dev/src/server/adk_api_server.ts index a52ad63ab..2e638ad33 100644 --- a/dev/src/server/adk_api_server.ts +++ b/dev/src/server/adk_api_server.ts @@ -75,6 +75,45 @@ interface ServerOptions { registerProcessors?: (tracerProvider: TracerProvider) => void; } +/** + * A Node system error: an `Error` that carries an errno string in `code`. + * + * The `@types/node` package declares this shape only as + * `NodeJS.ErrnoException`, in an ambient namespace that the repository's + * `no-undef` lint rule rejects. + */ +interface SystemError extends Error { + code?: string; +} + +/** + * Converts a `listen()` failure into the error the caller reports. + * + * The CLI prints only `error.message`, so a cause the operator can act on has + * to be in the message itself. An errno with no better wording than the system + * error is returned unchanged. + */ +export function toListenError( + err: SystemError, + host: string, + port: number, +): Error { + switch (err.code) { + case 'EADDRINUSE': + return new Error(`Port ${port} is already in use`, {cause: err}); + case 'EACCES': + return new Error( + `Permission denied binding ${host}:${port}. Ports below 1024 need ` + + `elevated privileges. Windows also reserves blocks of ports; list ` + + `them with "netsh interface ipv4 show excludedportrange ` + + `protocol=tcp". Use a different port, or port 0 for any free port.`, + {cause: err}, + ); + default: + return err; + } +} + export class AdkApiServer { private readonly host: string; private readonly port: number; @@ -973,15 +1012,8 @@ export class AdkApiServer { } }); - this.server.on('error', (err: unknown) => { - if ((err as {code: string}).code === 'EADDRINUSE') { - const error = new Error(); - error.cause = err; - error.message = `Port ${this.port} is already in use`; - reject(error); - } else { - reject(err); - } + this.server.on('error', (err: SystemError) => { + reject(toListenError(err, this.host, this.port)); }); }); } diff --git a/dev/test/server/adk_api_server_test.ts b/dev/test/server/adk_api_server_test.ts index d6db712d2..2adad341f 100644 --- a/dev/test/server/adk_api_server_test.ts +++ b/dev/test/server/adk_api_server_test.ts @@ -28,6 +28,7 @@ import {z} from 'zod'; import { A2A_AUTH_TOKEN_ENV_VAR, AdkApiServer, + toListenError, } from '../../src/server/adk_api_server.js'; import {AgentLoader} from '../../src/utils/agent_loader.js'; @@ -1256,3 +1257,49 @@ describe('AdkWebServer', () => { }); }); }); + +describe('toListenError', () => { + it('keeps the in-use wording for EADDRINUSE', () => { + const err = Object.assign( + new Error('listen EADDRINUSE: address already in use ::1:8000'), + {code: 'EADDRINUSE'}, + ); + + const error = toListenError(err, 'localhost', 8000); + + expect(error.message).toBe('Port 8000 is already in use'); + expect(error.cause).toBe(err); + }); + + it('explains both causes of EACCES and how to work around them', () => { + const err = Object.assign( + new Error('listen EACCES: permission denied ::1:80'), + {code: 'EACCES'}, + ); + + const error = toListenError(err, 'localhost', 80); + + expect(error.message).toContain('localhost:80'); + expect(error.message).toContain('below 1024'); + expect(error.message).toContain( + 'netsh interface ipv4 show excludedportrange protocol=tcp', + ); + expect(error.message).toContain('port 0'); + expect(error.message).not.toContain('--port'); + expect(error.cause).toBe(err); + }); + + it('passes an unrelated errno through unchanged', () => { + const err = Object.assign(new Error('read ECONNRESET'), { + code: 'ECONNRESET', + }); + + expect(toListenError(err, 'localhost', 8000)).toBe(err); + }); + + it('passes an error with no code through unchanged', () => { + const err = new Error('something else went wrong'); + + expect(toListenError(err, 'localhost', 8000)).toBe(err); + }); +});