feat: add CLI-server protocol version mismatch detection

- Define PROTOCOL_VERSION constant in shared/src/version.ts
- Server sets X-Hapi-Protocol-Version header on /cli/* responses
- Server includes protocolVersion in /health endpoint
- CLI extracts serverProtocolVersion from API responses
- CLI shows version mismatch hints in both directions
- Add tests for error utilities and version extraction

close #104
This commit is contained in:
weishu
2026-01-27 16:28:52 +08:00
parent 87c79c3b94
commit 1b22fe0f82
9 changed files with 147 additions and 6 deletions
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { extractErrorInfo, apiValidationError } from './errorUtils'
describe('extractErrorInfo', () => {
it('extracts serverProtocolVersion from axios-style response header', () => {
const error = {
message: 'Request failed with status code 400',
response: {
status: 400,
headers: { 'x-hapi-protocol-version': '2' },
data: { error: 'Invalid body' }
}
}
const info = extractErrorInfo(error)
expect(info.serverProtocolVersion).toBe(2)
expect(info.httpStatus).toBe(400)
expect(info.responseErrorText).toBe('Invalid body')
})
it('extracts serverProtocolVersion from direct property (apiValidationError)', () => {
const error = new Error('Invalid /cli/machines response')
;(error as unknown as Record<string, unknown>).serverProtocolVersion = 1
const info = extractErrorInfo(error)
expect(info.serverProtocolVersion).toBe(1)
expect(info.message).toBe('Invalid /cli/machines response')
})
it('prefers direct property over header', () => {
const error = Object.assign(new Error('test'), {
serverProtocolVersion: 3,
response: {
status: 200,
headers: { 'x-hapi-protocol-version': '5' },
data: {}
}
})
const info = extractErrorInfo(error)
expect(info.serverProtocolVersion).toBe(3)
})
it('returns undefined serverProtocolVersion when neither source present', () => {
const error = new Error('some error')
const info = extractErrorInfo(error)
expect(info.serverProtocolVersion).toBeUndefined()
})
it('handles non-numeric protocol header gracefully', () => {
const error = {
message: 'fail',
response: {
status: 200,
headers: { 'x-hapi-protocol-version': 'abc' },
data: {}
}
}
const info = extractErrorInfo(error)
expect(info.serverProtocolVersion).toBeUndefined()
})
})
describe('apiValidationError', () => {
it('creates error with serverProtocolVersion from response header', () => {
const fakeResponse = {
headers: { 'x-hapi-protocol-version': '1' }
}
const err = apiValidationError('Invalid /cli/machines response', fakeResponse as any)
expect(err.message).toBe('Invalid /cli/machines response')
expect((err as any).serverProtocolVersion).toBe(1)
})
it('creates error without serverProtocolVersion when header missing', () => {
const fakeResponse = { headers: {} }
const err = apiValidationError('Invalid /cli/sessions response', fakeResponse as any)
expect(err.message).toBe('Invalid /cli/sessions response')
expect((err as any).serverProtocolVersion).toBeUndefined()
})
it('round-trips through extractErrorInfo', () => {
const fakeResponse = {
headers: { 'x-hapi-protocol-version': '2' }
}
const err = apiValidationError('Invalid /cli/machines response', fakeResponse as any)
const info = extractErrorInfo(err)
expect(info.serverProtocolVersion).toBe(2)
expect(info.messageLower).toContain('invalid /cli/')
})
})
+37 -1
View File
@@ -2,12 +2,31 @@
* Error handling utilities for API requests
*/
import type { AxiosResponse } from 'axios'
export type ErrorInfo = {
message: string
messageLower: string
axiosCode?: string
httpStatus?: number
responseErrorText: string
serverProtocolVersion?: number
}
/**
* Create an Error for a successful HTTP response whose body fails schema validation.
* Attaches the protocol version header so callers can detect version mismatch.
*/
export function apiValidationError(message: string, response: AxiosResponse): Error {
const err = new Error(message)
const raw = response.headers?.['x-hapi-protocol-version']
if (raw != null) {
const pv = Number(raw)
if (Number.isFinite(pv)) {
;(err as unknown as Record<string, unknown>).serverProtocolVersion = pv
}
}
return err
}
/**
@@ -33,12 +52,29 @@ export function extractErrorInfo(error: unknown): ErrorInfo {
: undefined
const responseErrorText = typeof responseError === 'string' ? responseError : ''
// Protocol version: prefer direct property (set by apiValidationError),
// fall back to axios response header (set by server on error responses)
let serverProtocolVersion: number | undefined
if (typeof record.serverProtocolVersion === 'number' && Number.isFinite(record.serverProtocolVersion)) {
serverProtocolVersion = record.serverProtocolVersion
} else {
const headers = typeof response?.headers === 'object' && response.headers !== null
? (response.headers as Record<string, unknown>)
: undefined
const protocolHeader = headers?.['x-hapi-protocol-version']
if (typeof protocolHeader === 'string' && protocolHeader !== '') {
const pv = Number(protocolHeader)
if (Number.isFinite(pv)) serverProtocolVersion = pv
}
}
return {
message,
messageLower,
axiosCode,
httpStatus,
responseErrorText
responseErrorText,
serverProtocolVersion
}
}