fix(frontend): prefer upstream payload for generic ops error body
This commit is contained in:
@@ -167,6 +167,7 @@ import Icon from '@/components/icons/Icon.vue'
|
|||||||
import { useAppStore } from '@/stores'
|
import { useAppStore } from '@/stores'
|
||||||
import { opsAPI, type OpsErrorDetail } from '@/api/admin/ops'
|
import { opsAPI, type OpsErrorDetail } from '@/api/admin/ops'
|
||||||
import { formatDateTime } from '@/utils/format'
|
import { formatDateTime } from '@/utils/format'
|
||||||
|
import { resolvePrimaryResponseBody, resolveUpstreamPayload } from '../utils/errorDetailResponse'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
show: boolean
|
show: boolean
|
||||||
@@ -192,11 +193,7 @@ const showUpstreamList = computed(() => props.errorType === 'request')
|
|||||||
const requestId = computed(() => detail.value?.request_id || detail.value?.client_request_id || '')
|
const requestId = computed(() => detail.value?.request_id || detail.value?.client_request_id || '')
|
||||||
|
|
||||||
const primaryResponseBody = computed(() => {
|
const primaryResponseBody = computed(() => {
|
||||||
if (!detail.value) return ''
|
return resolvePrimaryResponseBody(detail.value, props.errorType)
|
||||||
if (props.errorType === 'upstream') {
|
|
||||||
return detail.value.upstream_error_detail || detail.value.upstream_errors || detail.value.upstream_error_message || detail.value.error_body || ''
|
|
||||||
}
|
|
||||||
return detail.value.error_body || ''
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -224,7 +221,9 @@ const correlatedUpstreamErrors = computed<OpsErrorDetail[]>(() => correlatedUpst
|
|||||||
const expandedUpstreamDetailIds = ref(new Set<number>())
|
const expandedUpstreamDetailIds = ref(new Set<number>())
|
||||||
|
|
||||||
function getUpstreamResponsePreview(ev: OpsErrorDetail): string {
|
function getUpstreamResponsePreview(ev: OpsErrorDetail): string {
|
||||||
return String(ev.upstream_error_detail || ev.error_body || ev.upstream_error_message || '').trim()
|
const upstreamPayload = resolveUpstreamPayload(ev)
|
||||||
|
if (upstreamPayload) return upstreamPayload
|
||||||
|
return String(ev.error_body || '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleUpstreamDetail(id: number) {
|
function toggleUpstreamDetail(id: number) {
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { OpsErrorDetail } from '@/api/admin/ops'
|
||||||
|
import { resolvePrimaryResponseBody, resolveUpstreamPayload } from '../errorDetailResponse'
|
||||||
|
|
||||||
|
function makeDetail(overrides: Partial<OpsErrorDetail>): OpsErrorDetail {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
phase: 'request',
|
||||||
|
type: 'api_error',
|
||||||
|
error_owner: 'platform',
|
||||||
|
error_source: 'gateway',
|
||||||
|
severity: 'P2',
|
||||||
|
status_code: 502,
|
||||||
|
platform: 'openai',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
is_retryable: true,
|
||||||
|
retry_count: 0,
|
||||||
|
resolved: false,
|
||||||
|
client_request_id: 'crid-1',
|
||||||
|
request_id: 'rid-1',
|
||||||
|
message: 'Upstream request failed',
|
||||||
|
user_email: 'user@example.com',
|
||||||
|
account_name: 'acc',
|
||||||
|
group_name: 'group',
|
||||||
|
error_body: '',
|
||||||
|
user_agent: '',
|
||||||
|
request_body: '',
|
||||||
|
request_body_truncated: false,
|
||||||
|
is_business_limited: false,
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('errorDetailResponse', () => {
|
||||||
|
it('prefers upstream payload for request modal when error_body is generic gateway wrapper', () => {
|
||||||
|
const detail = makeDetail({
|
||||||
|
error_body: JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'upstream_error',
|
||||||
|
message: 'Upstream request failed'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
upstream_error_detail: '{"provider_message":"real upstream detail"}'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolvePrimaryResponseBody(detail, 'request')).toBe('{"provider_message":"real upstream detail"}')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps error_body for request modal when body is not generic wrapper', () => {
|
||||||
|
const detail = makeDetail({
|
||||||
|
error_body: JSON.stringify({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'upstream_error',
|
||||||
|
message: 'Upstream authentication failed, please contact administrator'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
upstream_error_detail: '{"provider_message":"real upstream detail"}'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolvePrimaryResponseBody(detail, 'request')).toBe(detail.error_body)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses upstream payload first in upstream modal', () => {
|
||||||
|
const detail = makeDetail({
|
||||||
|
phase: 'upstream',
|
||||||
|
upstream_error_message: 'provider 503 overloaded',
|
||||||
|
error_body: '{"type":"error","error":{"type":"upstream_error","message":"Upstream request failed"}}'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolvePrimaryResponseBody(detail, 'upstream')).toBe('provider 503 overloaded')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to upstream payload when request error_body is empty', () => {
|
||||||
|
const detail = makeDetail({
|
||||||
|
error_body: '',
|
||||||
|
upstream_error_message: 'dial tcp timeout'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolvePrimaryResponseBody(detail, 'request')).toBe('dial tcp timeout')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves upstream payload by detail -> events -> message priority', () => {
|
||||||
|
expect(resolveUpstreamPayload(makeDetail({
|
||||||
|
upstream_error_detail: 'detail payload',
|
||||||
|
upstream_errors: '[{"message":"event payload"}]',
|
||||||
|
upstream_error_message: 'message payload'
|
||||||
|
}))).toBe('detail payload')
|
||||||
|
|
||||||
|
expect(resolveUpstreamPayload(makeDetail({
|
||||||
|
upstream_error_detail: '',
|
||||||
|
upstream_errors: '[{"message":"event payload"}]',
|
||||||
|
upstream_error_message: 'message payload'
|
||||||
|
}))).toBe('[{"message":"event payload"}]')
|
||||||
|
|
||||||
|
expect(resolveUpstreamPayload(makeDetail({
|
||||||
|
upstream_error_detail: '',
|
||||||
|
upstream_errors: '',
|
||||||
|
upstream_error_message: 'message payload'
|
||||||
|
}))).toBe('message payload')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats empty JSON placeholders in upstream payload as empty', () => {
|
||||||
|
expect(resolveUpstreamPayload(makeDetail({
|
||||||
|
upstream_error_detail: '',
|
||||||
|
upstream_errors: '[]',
|
||||||
|
upstream_error_message: ''
|
||||||
|
}))).toBe('')
|
||||||
|
|
||||||
|
expect(resolveUpstreamPayload(makeDetail({
|
||||||
|
upstream_error_detail: '',
|
||||||
|
upstream_errors: '{}',
|
||||||
|
upstream_error_message: ''
|
||||||
|
}))).toBe('')
|
||||||
|
|
||||||
|
expect(resolveUpstreamPayload(makeDetail({
|
||||||
|
upstream_error_detail: '',
|
||||||
|
upstream_errors: 'null',
|
||||||
|
upstream_error_message: ''
|
||||||
|
}))).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips placeholder candidates and falls back to the next upstream field', () => {
|
||||||
|
expect(resolveUpstreamPayload(makeDetail({
|
||||||
|
upstream_error_detail: '',
|
||||||
|
upstream_errors: '[]',
|
||||||
|
upstream_error_message: 'fallback message'
|
||||||
|
}))).toBe('fallback message')
|
||||||
|
|
||||||
|
expect(resolveUpstreamPayload(makeDetail({
|
||||||
|
upstream_error_detail: 'null',
|
||||||
|
upstream_errors: '',
|
||||||
|
upstream_error_message: 'fallback message'
|
||||||
|
}))).toBe('fallback message')
|
||||||
|
})
|
||||||
|
})
|
||||||
91
frontend/src/views/admin/ops/utils/errorDetailResponse.ts
Normal file
91
frontend/src/views/admin/ops/utils/errorDetailResponse.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { OpsErrorDetail } from '@/api/admin/ops'
|
||||||
|
|
||||||
|
const GENERIC_UPSTREAM_MESSAGES = new Set([
|
||||||
|
'upstream request failed',
|
||||||
|
'upstream request failed after retries',
|
||||||
|
'upstream gateway error',
|
||||||
|
'upstream service temporarily unavailable'
|
||||||
|
])
|
||||||
|
|
||||||
|
type ParsedGatewayError = {
|
||||||
|
type: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGatewayErrorBody(raw: string): ParsedGatewayError | null {
|
||||||
|
const text = String(raw || '').trim()
|
||||||
|
if (!text) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as Record<string, any>
|
||||||
|
const err = parsed?.error as Record<string, any> | undefined
|
||||||
|
if (!err || typeof err !== 'object') return null
|
||||||
|
|
||||||
|
const type = typeof err.type === 'string' ? err.type.trim() : ''
|
||||||
|
const message = typeof err.message === 'string' ? err.message.trim() : ''
|
||||||
|
if (!type && !message) return null
|
||||||
|
|
||||||
|
return { type, message }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGenericGatewayUpstreamError(raw: string): boolean {
|
||||||
|
const parsed = parseGatewayErrorBody(raw)
|
||||||
|
if (!parsed) return false
|
||||||
|
if (parsed.type !== 'upstream_error') return false
|
||||||
|
return GENERIC_UPSTREAM_MESSAGES.has(parsed.message.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveUpstreamPayload(
|
||||||
|
detail: Pick<OpsErrorDetail, 'upstream_error_detail' | 'upstream_errors' | 'upstream_error_message'> | null | undefined
|
||||||
|
): string {
|
||||||
|
if (!detail) return ''
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
detail.upstream_error_detail,
|
||||||
|
detail.upstream_errors,
|
||||||
|
detail.upstream_error_message
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const payload = String(candidate || '').trim()
|
||||||
|
if (!payload) continue
|
||||||
|
|
||||||
|
// Normalize common "empty but present" JSON placeholders.
|
||||||
|
if (payload === '[]' || payload === '{}' || payload.toLowerCase() === 'null') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePrimaryResponseBody(
|
||||||
|
detail: OpsErrorDetail | null,
|
||||||
|
errorType?: 'request' | 'upstream'
|
||||||
|
): string {
|
||||||
|
if (!detail) return ''
|
||||||
|
|
||||||
|
const upstreamPayload = resolveUpstreamPayload(detail)
|
||||||
|
const errorBody = String(detail.error_body || '').trim()
|
||||||
|
|
||||||
|
if (errorType === 'upstream') {
|
||||||
|
return upstreamPayload || errorBody
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errorBody) {
|
||||||
|
return upstreamPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
// For request detail modal, keep client-visible body by default.
|
||||||
|
// But if that body is a generic gateway wrapper, show upstream payload first.
|
||||||
|
if (upstreamPayload && isGenericGatewayUpstreamError(errorBody)) {
|
||||||
|
return upstreamPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorBody
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user