Skip to content

Commit e20cc7f

Browse files
committed
fix: preserve S3 XML error codes
1 parent 1cb074c commit e20cc7f

3 files changed

Lines changed: 198 additions & 4 deletions

File tree

contexts/s3-context.tsx

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,47 @@ import { useRouter } from "next/navigation"
55
import { S3Client } from "@aws-sdk/client-s3"
66
import { useAuth } from "@/contexts/auth-context"
77
import { configManager } from "@/lib/config"
8+
import { getServiceErrorMessage, getXmlErrorMessage } from "@/lib/error-handler"
89
import type { SiteConfig } from "@/types/config"
910

1011
interface S3Response {
1112
response?: { body?: string }
1213
[key: string]: unknown
1314
}
1415

16+
type StreamCollector = (streamBody: unknown) => Promise<Uint8Array>
17+
18+
const readResponseBodyText = async (
19+
body: unknown,
20+
streamCollector: StreamCollector | undefined,
21+
): Promise<string | null> => {
22+
if (typeof body === "string") {
23+
return body
24+
}
25+
26+
if (body instanceof Uint8Array) {
27+
return new TextDecoder("utf-8").decode(body)
28+
}
29+
30+
if (!body || !streamCollector) {
31+
return null
32+
}
33+
34+
const bytes = await streamCollector(body)
35+
return new TextDecoder("utf-8").decode(bytes)
36+
}
37+
38+
const createS3ServiceError = (message: string, statusCode: number) => {
39+
const error = new Error(message) as Error & {
40+
Code?: string
41+
$metadata?: { httpStatusCode?: number }
42+
}
43+
error.name = message
44+
error.Code = message
45+
error.$metadata = { httpStatusCode: statusCode }
46+
return error
47+
}
48+
1549
function patchReplicationBody(
1650
body: string | undefined,
1751
config: { Rules?: Array<{ DeleteReplication?: { Status?: string } }> } | undefined,
@@ -95,6 +129,31 @@ export function S3Provider({ children }: { children: React.ReactNode }) {
95129
}) as any,
96130
{ step: "serialize", name: "injectDeleteReplication", priority: "low" },
97131
)
132+
client.middlewareStack.addRelativeTo(
133+
((next: any) => async (args: any) => {
134+
const result = await next(args)
135+
const response = result?.response
136+
const statusCode = response?.statusCode
137+
138+
if (typeof statusCode === "number" && statusCode >= 300) {
139+
const streamCollector = client.config.streamCollector as StreamCollector | undefined
140+
const bodyText = await readResponseBodyText(response.body, streamCollector)
141+
const errorMessage = bodyText ? (getXmlErrorMessage(bodyText) ?? bodyText.trim()) : null
142+
143+
if (errorMessage) {
144+
throw createS3ServiceError(errorMessage, statusCode)
145+
}
146+
}
147+
148+
return result
149+
}) as any,
150+
{
151+
name: "normalizeXmlErrorResponse",
152+
relation: "after",
153+
toMiddleware: "deserializerMiddleware",
154+
override: true,
155+
},
156+
)
98157
client.middlewareStack.add(
99158
((next: any) => async (args: any) => {
100159
try {
@@ -137,8 +196,10 @@ export function S3Provider({ children }: { children: React.ReactNode }) {
137196
return { response: { statusCode: 401, headers: {} } }
138197
}
139198
}
140-
if (err?.Code) {
141-
throw new Error(err.Code)
199+
200+
const serviceErrorMessage = getServiceErrorMessage(error)
201+
if (serviceErrorMessage) {
202+
throw new Error(serviceErrorMessage)
142203
}
143204
throw error
144205
}

lib/error-handler.ts

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,96 @@ export interface ApiError {
55
originalError?: Error
66
}
77

8+
const GENERIC_ERROR_MESSAGES = new Set(["error", "unknown", "unknownerror"])
9+
10+
const normalizeErrorText = (value: unknown): string | null => {
11+
if (typeof value !== "string") {
12+
return null
13+
}
14+
15+
const trimmed = value.trim()
16+
return trimmed ? trimmed : null
17+
}
18+
19+
const isSpecificErrorText = (value: string | null): value is string => {
20+
return !!value && !GENERIC_ERROR_MESSAGES.has(value.toLowerCase())
21+
}
22+
23+
const getXmlTagText = (xml: string, tagName: string): string | null => {
24+
const match = xml.match(new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)</${tagName}>`, "i"))
25+
return normalizeErrorText(match?.[1])
26+
}
27+
28+
export const getXmlErrorMessage = (xml: string): string | null => {
29+
const trimmed = xml.trim()
30+
if (!trimmed.startsWith("<")) {
31+
return null
32+
}
33+
34+
const code = getXmlTagText(trimmed, "Code")
35+
if (isSpecificErrorText(code)) {
36+
return code
37+
}
38+
39+
const message = getXmlTagText(trimmed, "Message") ?? getXmlTagText(trimmed, "Error")
40+
if (isSpecificErrorText(message)) {
41+
return message
42+
}
43+
44+
return code ?? message
45+
}
46+
47+
export const getServiceErrorMessage = (error: unknown): string | null => {
48+
if (error instanceof Error) {
49+
const directMessage = normalizeErrorText(error.message)
50+
if (isSpecificErrorText(directMessage)) {
51+
return directMessage
52+
}
53+
}
54+
55+
if (!error || typeof error !== "object") {
56+
return null
57+
}
58+
59+
const err = error as {
60+
Code?: unknown
61+
Message?: unknown
62+
name?: unknown
63+
message?: unknown
64+
Error?: {
65+
Code?: unknown
66+
Message?: unknown
67+
message?: unknown
68+
}
69+
}
70+
71+
const codeCandidates = [
72+
normalizeErrorText(err.Code),
73+
normalizeErrorText(err.Error?.Code),
74+
normalizeErrorText(err.name),
75+
]
76+
const messageCandidates = [
77+
normalizeErrorText(err.Message),
78+
normalizeErrorText(err.Error?.Message),
79+
normalizeErrorText(err.Error?.message),
80+
normalizeErrorText(err.message),
81+
]
82+
83+
for (const candidate of codeCandidates) {
84+
if (isSpecificErrorText(candidate)) {
85+
return candidate
86+
}
87+
}
88+
89+
for (const candidate of messageCandidates) {
90+
if (isSpecificErrorText(candidate)) {
91+
return candidate
92+
}
93+
}
94+
95+
return codeCandidates.find(Boolean) ?? messageCandidates.find(Boolean) ?? null
96+
}
97+
898
export class ConfigLoadError extends Error {
999
code: "INVALID_URL" | "STORAGE_ERROR" | "NETWORK_ERROR" | "UNKNOWN_ERROR"
10100
originalError?: Error
@@ -26,8 +116,7 @@ export const parseApiError = async (response: Response): Promise<string> => {
26116
const text = await response.clone().text()
27117
if (text) {
28118
if (text.trim().startsWith("<")) {
29-
const match = text.match(/<Message>(.*?)<\/Message>/i) || text.match(/<Error>(.*?)<\/Error>/i)
30-
return match?.[1] ?? text
119+
return getXmlErrorMessage(text) ?? text
31120
}
32121
return text
33122
}

tests/lib/error-handler.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import test from "node:test"
2+
import assert from "node:assert/strict"
3+
4+
const loadErrorHandler = () => import(new URL("../../lib/error-handler.ts", import.meta.url).href)
5+
6+
test("getServiceErrorMessage prefers a specific error code over UnknownError", async () => {
7+
const { getServiceErrorMessage } = await loadErrorHandler()
8+
const error = {
9+
name: "UnknownError",
10+
message: "UnknownError",
11+
Error: {
12+
Code: "InvalidBucketName",
13+
Message: "The specified bucket is not valid.",
14+
},
15+
}
16+
17+
assert.equal(getServiceErrorMessage(error), "InvalidBucketName")
18+
})
19+
20+
test("getServiceErrorMessage falls back to a specific nested message when the code is generic", async () => {
21+
const { getServiceErrorMessage } = await loadErrorHandler()
22+
const error = {
23+
name: "UnknownError",
24+
message: "UnknownError",
25+
Error: {
26+
Code: "UnknownError",
27+
Message: "Bucket names cannot contain Chinese characters.",
28+
},
29+
}
30+
31+
assert.equal(getServiceErrorMessage(error), "Bucket names cannot contain Chinese characters.")
32+
})
33+
34+
test("getServiceErrorMessage keeps plain Error messages intact", async () => {
35+
const { getServiceErrorMessage } = await loadErrorHandler()
36+
assert.equal(getServiceErrorMessage(new Error("network timeout")), "network timeout")
37+
})
38+
39+
test("getXmlErrorMessage extracts an error code when the XML has no message", async () => {
40+
const { getXmlErrorMessage } = await loadErrorHandler()
41+
const xml = '<?xml version="1.0" encoding="UTF-8"?><Error><Code>InvalidBucketName</Code></Error>'
42+
43+
assert.equal(getXmlErrorMessage(xml), "InvalidBucketName")
44+
})

0 commit comments

Comments
 (0)