mirror of
https://github.com/rustfs/console.git
synced 2026-08-29 03:52:28 +08:00
fix: preserve S3 XML error codes
This commit is contained in:
+63
-2
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"
|
||||
import { S3Client } from "@aws-sdk/client-s3"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { configManager } from "@/lib/config"
|
||||
import { getServiceErrorMessage, getXmlErrorMessage } from "@/lib/error-handler"
|
||||
import type { SiteConfig } from "@/types/config"
|
||||
|
||||
interface S3Response {
|
||||
@@ -12,6 +13,39 @@ interface S3Response {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type StreamCollector = (streamBody: unknown) => Promise<Uint8Array>
|
||||
|
||||
const readResponseBodyText = async (
|
||||
body: unknown,
|
||||
streamCollector: StreamCollector | undefined,
|
||||
): Promise<string | null> => {
|
||||
if (typeof body === "string") {
|
||||
return body
|
||||
}
|
||||
|
||||
if (body instanceof Uint8Array) {
|
||||
return new TextDecoder("utf-8").decode(body)
|
||||
}
|
||||
|
||||
if (!body || !streamCollector) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bytes = await streamCollector(body)
|
||||
return new TextDecoder("utf-8").decode(bytes)
|
||||
}
|
||||
|
||||
const createS3ServiceError = (message: string, statusCode: number) => {
|
||||
const error = new Error(message) as Error & {
|
||||
Code?: string
|
||||
$metadata?: { httpStatusCode?: number }
|
||||
}
|
||||
error.name = message
|
||||
error.Code = message
|
||||
error.$metadata = { httpStatusCode: statusCode }
|
||||
return error
|
||||
}
|
||||
|
||||
function patchReplicationBody(
|
||||
body: string | undefined,
|
||||
config: { Rules?: Array<{ DeleteReplication?: { Status?: string } }> } | undefined,
|
||||
@@ -95,6 +129,31 @@ export function S3Provider({ children }: { children: React.ReactNode }) {
|
||||
}) as any,
|
||||
{ step: "serialize", name: "injectDeleteReplication", priority: "low" },
|
||||
)
|
||||
client.middlewareStack.addRelativeTo(
|
||||
((next: any) => async (args: any) => {
|
||||
const result = await next(args)
|
||||
const response = result?.response
|
||||
const statusCode = response?.statusCode
|
||||
|
||||
if (typeof statusCode === "number" && statusCode >= 300) {
|
||||
const streamCollector = client.config.streamCollector as StreamCollector | undefined
|
||||
const bodyText = await readResponseBodyText(response.body, streamCollector)
|
||||
const errorMessage = bodyText ? (getXmlErrorMessage(bodyText) ?? bodyText.trim()) : null
|
||||
|
||||
if (errorMessage) {
|
||||
throw createS3ServiceError(errorMessage, statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}) as any,
|
||||
{
|
||||
name: "normalizeXmlErrorResponse",
|
||||
relation: "after",
|
||||
toMiddleware: "deserializerMiddleware",
|
||||
override: true,
|
||||
},
|
||||
)
|
||||
client.middlewareStack.add(
|
||||
((next: any) => async (args: any) => {
|
||||
try {
|
||||
@@ -137,8 +196,10 @@ export function S3Provider({ children }: { children: React.ReactNode }) {
|
||||
return { response: { statusCode: 401, headers: {} } }
|
||||
}
|
||||
}
|
||||
if (err?.Code) {
|
||||
throw new Error(err.Code)
|
||||
|
||||
const serviceErrorMessage = getServiceErrorMessage(error)
|
||||
if (serviceErrorMessage) {
|
||||
throw new Error(serviceErrorMessage)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
+91
-2
@@ -5,6 +5,96 @@ export interface ApiError {
|
||||
originalError?: Error
|
||||
}
|
||||
|
||||
const GENERIC_ERROR_MESSAGES = new Set(["error", "unknown", "unknownerror"])
|
||||
|
||||
const normalizeErrorText = (value: unknown): string | null => {
|
||||
if (typeof value !== "string") {
|
||||
return null
|
||||
}
|
||||
|
||||
const trimmed = value.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
const isSpecificErrorText = (value: string | null): value is string => {
|
||||
return !!value && !GENERIC_ERROR_MESSAGES.has(value.toLowerCase())
|
||||
}
|
||||
|
||||
const getXmlTagText = (xml: string, tagName: string): string | null => {
|
||||
const match = xml.match(new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)</${tagName}>`, "i"))
|
||||
return normalizeErrorText(match?.[1])
|
||||
}
|
||||
|
||||
export const getXmlErrorMessage = (xml: string): string | null => {
|
||||
const trimmed = xml.trim()
|
||||
if (!trimmed.startsWith("<")) {
|
||||
return null
|
||||
}
|
||||
|
||||
const code = getXmlTagText(trimmed, "Code")
|
||||
if (isSpecificErrorText(code)) {
|
||||
return code
|
||||
}
|
||||
|
||||
const message = getXmlTagText(trimmed, "Message") ?? getXmlTagText(trimmed, "Error")
|
||||
if (isSpecificErrorText(message)) {
|
||||
return message
|
||||
}
|
||||
|
||||
return code ?? message
|
||||
}
|
||||
|
||||
export const getServiceErrorMessage = (error: unknown): string | null => {
|
||||
if (error instanceof Error) {
|
||||
const directMessage = normalizeErrorText(error.message)
|
||||
if (isSpecificErrorText(directMessage)) {
|
||||
return directMessage
|
||||
}
|
||||
}
|
||||
|
||||
if (!error || typeof error !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
const err = error as {
|
||||
Code?: unknown
|
||||
Message?: unknown
|
||||
name?: unknown
|
||||
message?: unknown
|
||||
Error?: {
|
||||
Code?: unknown
|
||||
Message?: unknown
|
||||
message?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
const codeCandidates = [
|
||||
normalizeErrorText(err.Code),
|
||||
normalizeErrorText(err.Error?.Code),
|
||||
normalizeErrorText(err.name),
|
||||
]
|
||||
const messageCandidates = [
|
||||
normalizeErrorText(err.Message),
|
||||
normalizeErrorText(err.Error?.Message),
|
||||
normalizeErrorText(err.Error?.message),
|
||||
normalizeErrorText(err.message),
|
||||
]
|
||||
|
||||
for (const candidate of codeCandidates) {
|
||||
if (isSpecificErrorText(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of messageCandidates) {
|
||||
if (isSpecificErrorText(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return codeCandidates.find(Boolean) ?? messageCandidates.find(Boolean) ?? null
|
||||
}
|
||||
|
||||
export class ConfigLoadError extends Error {
|
||||
code: "INVALID_URL" | "STORAGE_ERROR" | "NETWORK_ERROR" | "UNKNOWN_ERROR"
|
||||
originalError?: Error
|
||||
@@ -26,8 +116,7 @@ export const parseApiError = async (response: Response): Promise<string> => {
|
||||
const text = await response.clone().text()
|
||||
if (text) {
|
||||
if (text.trim().startsWith("<")) {
|
||||
const match = text.match(/<Message>(.*?)<\/Message>/i) || text.match(/<Error>(.*?)<\/Error>/i)
|
||||
return match?.[1] ?? text
|
||||
return getXmlErrorMessage(text) ?? text
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
const loadErrorHandler = () => import(new URL("../../lib/error-handler.ts", import.meta.url).href)
|
||||
|
||||
test("getServiceErrorMessage prefers a specific error code over UnknownError", async () => {
|
||||
const { getServiceErrorMessage } = await loadErrorHandler()
|
||||
const error = {
|
||||
name: "UnknownError",
|
||||
message: "UnknownError",
|
||||
Error: {
|
||||
Code: "InvalidBucketName",
|
||||
Message: "The specified bucket is not valid.",
|
||||
},
|
||||
}
|
||||
|
||||
assert.equal(getServiceErrorMessage(error), "InvalidBucketName")
|
||||
})
|
||||
|
||||
test("getServiceErrorMessage falls back to a specific nested message when the code is generic", async () => {
|
||||
const { getServiceErrorMessage } = await loadErrorHandler()
|
||||
const error = {
|
||||
name: "UnknownError",
|
||||
message: "UnknownError",
|
||||
Error: {
|
||||
Code: "UnknownError",
|
||||
Message: "Bucket names cannot contain Chinese characters.",
|
||||
},
|
||||
}
|
||||
|
||||
assert.equal(getServiceErrorMessage(error), "Bucket names cannot contain Chinese characters.")
|
||||
})
|
||||
|
||||
test("getServiceErrorMessage keeps plain Error messages intact", async () => {
|
||||
const { getServiceErrorMessage } = await loadErrorHandler()
|
||||
assert.equal(getServiceErrorMessage(new Error("network timeout")), "network timeout")
|
||||
})
|
||||
|
||||
test("getXmlErrorMessage extracts an error code when the XML has no message", async () => {
|
||||
const { getXmlErrorMessage } = await loadErrorHandler()
|
||||
const xml = '<?xml version="1.0" encoding="UTF-8"?><Error><Code>InvalidBucketName</Code></Error>'
|
||||
|
||||
assert.equal(getXmlErrorMessage(xml), "InvalidBucketName")
|
||||
})
|
||||
Reference in New Issue
Block a user