fix: preserve S3 XML error codes

This commit is contained in:
马登山
2026-04-28 11:37:00 +08:00
parent 1cb074c95a
commit e20cc7f81a
3 changed files with 198 additions and 4 deletions
+63 -2
View File
@@ -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
View File
@@ -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
}
+44
View File
@@ -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")
})