@umituz/react-native-ai-fal-provider 3.1.5 → 3.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/infrastructure/services/fal-provider-subscription.ts +85 -47
- package/src/infrastructure/services/fal-queue-operations.ts +45 -38
- package/src/infrastructure/services/fal-status-mapper.ts +31 -21
- package/src/infrastructure/services/request-store.ts +32 -135
- package/src/infrastructure/utils/fal-error-handler.util.ts +92 -103
- package/src/infrastructure/utils/index.ts +0 -1
- package/src/infrastructure/utils/parsers/object-transformers.util.ts +4 -20
package/package.json
CHANGED
|
@@ -3,14 +3,74 @@
|
|
|
3
3
|
* Handles subscribe, run methods and cancellation logic
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { fal } from "@fal-ai/client";
|
|
6
|
+
import { fal, ApiError, ValidationError } from "@fal-ai/client";
|
|
7
7
|
import type { SubscribeOptions, RunOptions } from "../../domain/types";
|
|
8
|
-
import type { FalQueueStatus } from "../../domain/entities/fal.types";
|
|
9
8
|
import { DEFAULT_FAL_CONFIG } from "./fal-provider.constants";
|
|
10
9
|
import { mapFalStatusToJobStatus } from "./fal-status-mapper";
|
|
11
10
|
import { validateNSFWContent } from "../validators/nsfw-validator";
|
|
12
11
|
import { NSFWContentError } from "./nsfw-content-error";
|
|
13
|
-
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Unwrap fal.subscribe / fal.run Result<T> = { data: T, requestId: string }
|
|
15
|
+
* Throws if response format is unexpected - no silent fallbacks
|
|
16
|
+
*/
|
|
17
|
+
function unwrapFalResult<T>(rawResult: unknown): { data: T; requestId: string } {
|
|
18
|
+
if (!rawResult || typeof rawResult !== "object") {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Unexpected fal response: expected object, got ${typeof rawResult}`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const result = rawResult as Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
if (!("data" in result)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Unexpected fal response format: missing 'data' property. Keys: ${Object.keys(result).join(", ")}`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!("requestId" in result) || typeof result.requestId !== "string") {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Unexpected fal response format: missing or invalid 'requestId'. Keys: ${Object.keys(result).join(", ")}`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { data: result.data as T, requestId: result.requestId };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Format fal-ai SDK errors into user-readable messages
|
|
43
|
+
* Uses proper @fal-ai/client error types (ApiError, ValidationError)
|
|
44
|
+
*/
|
|
45
|
+
function formatFalError(error: unknown): string {
|
|
46
|
+
if (error instanceof ValidationError) {
|
|
47
|
+
const details = error.fieldErrors;
|
|
48
|
+
if (details.length > 0) {
|
|
49
|
+
return details.map((d) => d.msg).filter(Boolean).join("; ") || error.message;
|
|
50
|
+
}
|
|
51
|
+
return error.message;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (error instanceof ApiError) {
|
|
55
|
+
// ApiError has .status, .body, .message
|
|
56
|
+
if (error.status === 401 || error.status === 403) {
|
|
57
|
+
return "Authentication failed. Please check your API key.";
|
|
58
|
+
}
|
|
59
|
+
if (error.status === 429) {
|
|
60
|
+
return "Rate limit exceeded. Please wait and try again.";
|
|
61
|
+
}
|
|
62
|
+
if (error.status === 402) {
|
|
63
|
+
return "Insufficient credits. Please check your billing.";
|
|
64
|
+
}
|
|
65
|
+
return error.message || `API error (${error.status})`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (error instanceof Error) {
|
|
69
|
+
return error.message;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return String(error);
|
|
73
|
+
}
|
|
14
74
|
|
|
15
75
|
/**
|
|
16
76
|
* Handle FAL subscription with timeout and cancellation
|
|
@@ -20,10 +80,9 @@ export async function handleFalSubscription<T = unknown>(
|
|
|
20
80
|
input: Record<string, unknown>,
|
|
21
81
|
options?: SubscribeOptions<T>,
|
|
22
82
|
signal?: AbortSignal
|
|
23
|
-
): Promise<{ result: T; requestId: string
|
|
83
|
+
): Promise<{ result: T; requestId: string }> {
|
|
24
84
|
const timeoutMs = options?.timeoutMs ?? DEFAULT_FAL_CONFIG.defaultTimeoutMs;
|
|
25
85
|
|
|
26
|
-
// Validate timeout is a positive integer within reasonable bounds
|
|
27
86
|
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 3600000) {
|
|
28
87
|
throw new Error(
|
|
29
88
|
`Invalid timeout: ${timeoutMs}ms. Must be a positive integer between 1 and 3600000ms (1 hour)`
|
|
@@ -31,14 +90,11 @@ export async function handleFalSubscription<T = unknown>(
|
|
|
31
90
|
}
|
|
32
91
|
|
|
33
92
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
34
|
-
let currentRequestId: string | null = null;
|
|
35
93
|
let abortHandler: (() => void) | null = null;
|
|
36
94
|
let listenerAdded = false;
|
|
37
|
-
|
|
38
95
|
let lastStatus = "";
|
|
39
96
|
|
|
40
97
|
try {
|
|
41
|
-
// Check if signal is already aborted BEFORE starting any async work
|
|
42
98
|
if (signal?.aborted) {
|
|
43
99
|
throw new Error("Request cancelled by user");
|
|
44
100
|
}
|
|
@@ -49,20 +105,17 @@ export async function handleFalSubscription<T = unknown>(
|
|
|
49
105
|
logs: false,
|
|
50
106
|
pollInterval: DEFAULT_FAL_CONFIG.pollInterval,
|
|
51
107
|
onQueueUpdate: (update: { status: string; logs?: unknown[]; request_id?: string; queue_position?: number }) => {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
logs
|
|
57
|
-
|
|
58
|
-
|
|
108
|
+
const jobStatus = mapFalStatusToJobStatus(
|
|
109
|
+
update.status,
|
|
110
|
+
update.request_id,
|
|
111
|
+
update.queue_position,
|
|
112
|
+
Array.isArray(update.logs) ? update.logs : undefined,
|
|
113
|
+
);
|
|
114
|
+
|
|
59
115
|
if (jobStatus.status !== lastStatus) {
|
|
60
116
|
lastStatus = jobStatus.status;
|
|
61
|
-
|
|
62
|
-
// Emit status changes without fake progress percentages
|
|
63
117
|
if (options?.onProgress) {
|
|
64
118
|
if (jobStatus.status === "IN_QUEUE" || jobStatus.status === "IN_PROGRESS") {
|
|
65
|
-
// Indeterminate progress - let UI show spinner/loading
|
|
66
119
|
options.onProgress({ progress: -1, status: jobStatus.status });
|
|
67
120
|
} else if (jobStatus.status === "COMPLETED") {
|
|
68
121
|
options.onProgress({ progress: 100, status: "COMPLETED" });
|
|
@@ -76,21 +129,17 @@ export async function handleFalSubscription<T = unknown>(
|
|
|
76
129
|
}),
|
|
77
130
|
new Promise<never>((_, reject) => {
|
|
78
131
|
timeoutId = setTimeout(() => {
|
|
79
|
-
reject(new Error(
|
|
132
|
+
reject(new Error(`FAL subscription timeout after ${timeoutMs}ms for model ${model}`));
|
|
80
133
|
}, timeoutMs);
|
|
81
134
|
}),
|
|
82
135
|
];
|
|
83
136
|
|
|
84
|
-
// Set up abort listener BEFORE checking aborted state again to avoid race
|
|
85
137
|
if (signal) {
|
|
86
138
|
const abortPromise = new Promise<never>((_, reject) => {
|
|
87
|
-
abortHandler = () =>
|
|
88
|
-
reject(new Error("Request cancelled by user"));
|
|
89
|
-
};
|
|
139
|
+
abortHandler = () => reject(new Error("Request cancelled by user"));
|
|
90
140
|
signal.addEventListener("abort", abortHandler);
|
|
91
141
|
listenerAdded = true;
|
|
92
|
-
|
|
93
|
-
// Check again after adding listener to catch signals that arrived during setup
|
|
142
|
+
// Re-check after listener to handle race
|
|
94
143
|
if (signal.aborted) {
|
|
95
144
|
abortHandler();
|
|
96
145
|
}
|
|
@@ -99,26 +148,19 @@ export async function handleFalSubscription<T = unknown>(
|
|
|
99
148
|
}
|
|
100
149
|
|
|
101
150
|
const rawResult = await Promise.race(promises);
|
|
151
|
+
const { data, requestId } = unwrapFalResult<T>(rawResult);
|
|
102
152
|
|
|
103
|
-
|
|
104
|
-
const falResult = rawResult as { data?: unknown; requestId?: string };
|
|
105
|
-
const actualData = (falResult?.data ?? rawResult) as T;
|
|
106
|
-
const falRequestId = falResult?.requestId ?? currentRequestId;
|
|
107
|
-
|
|
108
|
-
validateNSFWContent(actualData as Record<string, unknown>);
|
|
153
|
+
validateNSFWContent(data as Record<string, unknown>);
|
|
109
154
|
|
|
110
|
-
options?.onResult?.(
|
|
111
|
-
return { result:
|
|
155
|
+
options?.onResult?.(data);
|
|
156
|
+
return { result: data, requestId };
|
|
112
157
|
} catch (error) {
|
|
113
158
|
if (error instanceof NSFWContentError) {
|
|
114
159
|
throw error;
|
|
115
160
|
}
|
|
116
161
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
throw new Error("An unknown error occurred. Please try again.");
|
|
120
|
-
}
|
|
121
|
-
throw new Error(userMessage);
|
|
162
|
+
const message = formatFalError(error);
|
|
163
|
+
throw new Error(message);
|
|
122
164
|
} finally {
|
|
123
165
|
if (timeoutId) {
|
|
124
166
|
clearTimeout(timeoutId);
|
|
@@ -137,26 +179,22 @@ export async function handleFalRun<T = unknown>(
|
|
|
137
179
|
input: Record<string, unknown>,
|
|
138
180
|
options?: RunOptions
|
|
139
181
|
): Promise<T> {
|
|
140
|
-
// Indeterminate progress - no fake percentages
|
|
141
182
|
options?.onProgress?.({ progress: -1, status: "IN_PROGRESS" as const });
|
|
142
183
|
|
|
143
184
|
try {
|
|
144
185
|
const rawResult = await fal.run(model, { input });
|
|
186
|
+
const { data } = unwrapFalResult<T>(rawResult);
|
|
145
187
|
|
|
146
|
-
|
|
147
|
-
const falResult = rawResult as { data?: unknown; requestId?: string };
|
|
148
|
-
const actualData = (falResult?.data ?? rawResult) as T;
|
|
149
|
-
|
|
150
|
-
validateNSFWContent(actualData as Record<string, unknown>);
|
|
188
|
+
validateNSFWContent(data as Record<string, unknown>);
|
|
151
189
|
|
|
152
190
|
options?.onProgress?.({ progress: 100, status: "COMPLETED" as const });
|
|
153
|
-
return
|
|
191
|
+
return data;
|
|
154
192
|
} catch (error) {
|
|
155
193
|
if (error instanceof NSFWContentError) {
|
|
156
194
|
throw error;
|
|
157
195
|
}
|
|
158
196
|
|
|
159
|
-
const
|
|
160
|
-
throw new Error(
|
|
197
|
+
const message = formatFalError(error);
|
|
198
|
+
throw new Error(message);
|
|
161
199
|
}
|
|
162
200
|
}
|
|
@@ -1,51 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FAL Queue Operations - Direct FAL API queue interactions
|
|
3
|
+
* No silent fallbacks - throws descriptive errors on unexpected responses
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
import { fal } from "@fal-ai/client";
|
|
6
7
|
import type { JobSubmission, JobStatus } from "../../domain/types";
|
|
7
|
-
import
|
|
8
|
-
import { mapFalStatusToJobStatus, FAL_QUEUE_STATUSES } from "./fal-status-mapper";
|
|
9
|
-
|
|
10
|
-
const VALID_STATUSES = Object.values(FAL_QUEUE_STATUSES) as string[];
|
|
8
|
+
import { mapFalStatusToJobStatus } from "./fal-status-mapper";
|
|
11
9
|
|
|
12
10
|
/**
|
|
13
|
-
*
|
|
11
|
+
* Submit job to FAL queue
|
|
12
|
+
* @throws {Error} if response is missing required fields
|
|
14
13
|
*/
|
|
15
|
-
function normalizeFalQueueStatus(value: unknown): FalQueueStatus | null {
|
|
16
|
-
if (!value || typeof value !== "object") {
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const raw = value as Record<string, unknown>;
|
|
21
|
-
|
|
22
|
-
if (typeof raw.status !== "string" || !VALID_STATUSES.includes(raw.status)) {
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// FAL SDK returns snake_case (request_id, queue_position)
|
|
27
|
-
const requestId = (raw.request_id ?? raw.requestId) as string | undefined;
|
|
28
|
-
if (typeof requestId !== "string") {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return {
|
|
33
|
-
status: raw.status as FalQueueStatus["status"],
|
|
34
|
-
requestId,
|
|
35
|
-
queuePosition: (raw.queue_position ?? raw.queuePosition) as number | undefined,
|
|
36
|
-
logs: Array.isArray(raw.logs) ? raw.logs : undefined,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
14
|
export async function submitJob(model: string, input: Record<string, unknown>): Promise<JobSubmission> {
|
|
41
15
|
const result = await fal.queue.submit(model, { input });
|
|
42
16
|
|
|
43
17
|
if (!result?.request_id) {
|
|
44
|
-
throw new Error(
|
|
18
|
+
throw new Error(
|
|
19
|
+
`FAL queue.submit response missing request_id for model ${model}. Response keys: ${Object.keys(result ?? {}).join(", ")}`
|
|
20
|
+
);
|
|
45
21
|
}
|
|
46
22
|
|
|
47
23
|
if (!result?.status_url) {
|
|
48
|
-
throw new Error(
|
|
24
|
+
throw new Error(
|
|
25
|
+
`FAL queue.submit response missing status_url for model ${model}. Response keys: ${Object.keys(result).join(", ")}`
|
|
26
|
+
);
|
|
49
27
|
}
|
|
50
28
|
|
|
51
29
|
return {
|
|
@@ -55,31 +33,60 @@ export async function submitJob(model: string, input: Record<string, unknown>):
|
|
|
55
33
|
};
|
|
56
34
|
}
|
|
57
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Get job status from FAL queue
|
|
38
|
+
* @throws {Error} if response format is invalid or status is unrecognized
|
|
39
|
+
*/
|
|
58
40
|
export async function getJobStatus(model: string, requestId: string): Promise<JobStatus> {
|
|
59
41
|
const raw = await fal.queue.status(model, { requestId, logs: true });
|
|
60
42
|
|
|
61
|
-
|
|
62
|
-
|
|
43
|
+
if (!raw || typeof raw !== "object") {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`FAL queue.status returned non-object for model ${model}, requestId ${requestId}: ${typeof raw}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const response = raw as Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
if (typeof response.status !== "string") {
|
|
63
52
|
throw new Error(
|
|
64
|
-
`
|
|
53
|
+
`FAL queue.status response missing 'status' field for model ${model}, requestId ${requestId}. Keys: ${Object.keys(response).join(", ")}`
|
|
65
54
|
);
|
|
66
55
|
}
|
|
67
56
|
|
|
68
|
-
|
|
57
|
+
// FAL SDK returns snake_case (request_id, queue_position)
|
|
58
|
+
const resolvedRequestId = (response.request_id ?? response.requestId) as string | undefined;
|
|
59
|
+
if (typeof resolvedRequestId !== "string") {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`FAL queue.status response missing request_id for model ${model}, requestId ${requestId}. Keys: ${Object.keys(response).join(", ")}`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return mapFalStatusToJobStatus(
|
|
66
|
+
response.status,
|
|
67
|
+
resolvedRequestId,
|
|
68
|
+
(response.queue_position ?? response.queuePosition) as number | undefined,
|
|
69
|
+
Array.isArray(response.logs) ? response.logs : undefined,
|
|
70
|
+
);
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Get job result from FAL queue
|
|
75
|
+
* fal.queue.result returns Result<T> = { data: T, requestId: string }
|
|
76
|
+
* @throws {Error} if response format is unexpected
|
|
77
|
+
*/
|
|
71
78
|
export async function getJobResult<T = unknown>(model: string, requestId: string): Promise<T> {
|
|
72
79
|
const result = await fal.queue.result(model, { requestId });
|
|
73
80
|
|
|
74
81
|
if (!result || typeof result !== 'object') {
|
|
75
82
|
throw new Error(
|
|
76
|
-
`
|
|
83
|
+
`FAL queue.result returned non-object for model ${model}, requestId ${requestId}: ${typeof result}`
|
|
77
84
|
);
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
if (!('data' in result)) {
|
|
81
88
|
throw new Error(
|
|
82
|
-
`
|
|
89
|
+
`FAL queue.result response missing 'data' property for model ${model}, requestId ${requestId}. Keys: ${Object.keys(result).join(", ")}`
|
|
83
90
|
);
|
|
84
91
|
}
|
|
85
92
|
|
|
@@ -1,41 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FAL Status Mapper
|
|
3
3
|
* Maps FAL queue status to standardized job status
|
|
4
|
+
* Validates status values - throws on unknown status instead of silent fallback
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import type { JobStatus, AIJobStatusType } from "../../domain/types";
|
|
7
|
-
import type {
|
|
8
|
+
import type { FalLogEntry } from "../../domain/entities/fal.types";
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
const VALID_STATUSES: Record<string, AIJobStatusType> = {
|
|
10
11
|
IN_QUEUE: "IN_QUEUE",
|
|
11
12
|
IN_PROGRESS: "IN_PROGRESS",
|
|
12
13
|
COMPLETED: "COMPLETED",
|
|
13
14
|
FAILED: "FAILED",
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export type FalQueueStatusKey = keyof typeof FAL_QUEUE_STATUSES;
|
|
17
|
-
|
|
18
|
-
const STATUS_MAP = FAL_QUEUE_STATUSES satisfies Record<string, AIJobStatusType>;
|
|
19
|
-
|
|
20
|
-
const DEFAULT_STATUS: AIJobStatusType = "IN_PROGRESS";
|
|
15
|
+
};
|
|
21
16
|
|
|
22
17
|
/**
|
|
23
|
-
* Map FAL
|
|
24
|
-
*
|
|
18
|
+
* Map raw FAL status values to standardized JobStatus
|
|
19
|
+
* Validates all inputs - no unsafe casts
|
|
20
|
+
*
|
|
21
|
+
* @throws {Error} if status is not a recognized FAL queue status
|
|
25
22
|
*/
|
|
26
|
-
export function mapFalStatusToJobStatus(
|
|
27
|
-
|
|
23
|
+
export function mapFalStatusToJobStatus(
|
|
24
|
+
rawStatus: string,
|
|
25
|
+
requestId?: string,
|
|
26
|
+
queuePosition?: number,
|
|
27
|
+
logs?: unknown[],
|
|
28
|
+
): JobStatus {
|
|
29
|
+
const mappedStatus = VALID_STATUSES[rawStatus];
|
|
30
|
+
if (!mappedStatus) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Unknown FAL queue status: "${rawStatus}". Expected one of: ${Object.keys(VALID_STATUSES).join(", ")}`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
28
35
|
|
|
29
36
|
return {
|
|
30
37
|
status: mappedStatus,
|
|
31
|
-
logs: Array.isArray(
|
|
32
|
-
?
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
logs: Array.isArray(logs)
|
|
39
|
+
? logs.map((log) => {
|
|
40
|
+
const entry = log as FalLogEntry;
|
|
41
|
+
return {
|
|
42
|
+
message: typeof entry?.message === "string" ? entry.message : String(log),
|
|
43
|
+
level: typeof entry?.level === "string" ? entry.level : "info",
|
|
44
|
+
timestamp: typeof entry?.timestamp === "string" ? entry.timestamp : new Date().toISOString(),
|
|
45
|
+
};
|
|
46
|
+
})
|
|
37
47
|
: [],
|
|
38
|
-
queuePosition:
|
|
39
|
-
requestId:
|
|
48
|
+
queuePosition: typeof queuePosition === "number" ? queuePosition : undefined,
|
|
49
|
+
requestId: typeof requestId === "string" ? requestId : "",
|
|
40
50
|
};
|
|
41
51
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Request Store - Promise Deduplication with globalThis
|
|
3
3
|
* Survives hot reloads for React Native development
|
|
4
|
+
*
|
|
5
|
+
* React Native is single-threaded - no lock mechanism needed.
|
|
6
|
+
* Direct Map operations are atomic in JS event loop.
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
9
|
export interface ActiveRequest<T = unknown> {
|
|
@@ -10,47 +13,22 @@ export interface ActiveRequest<T = unknown> {
|
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
const STORE_KEY = "__FAL_PROVIDER_REQUESTS__";
|
|
13
|
-
const LOCK_KEY = "__FAL_PROVIDER_REQUESTS_LOCK__";
|
|
14
16
|
const TIMER_KEY = "__FAL_PROVIDER_CLEANUP_TIMER__";
|
|
15
17
|
type RequestStore = Map<string, ActiveRequest>;
|
|
16
18
|
|
|
17
19
|
const CLEANUP_INTERVAL = 60000; // 1 minute
|
|
18
20
|
const MAX_REQUEST_AGE = 300000; // 5 minutes
|
|
19
21
|
|
|
20
|
-
/**
|
|
21
|
-
* Get cleanup timer from globalThis to survive hot reloads
|
|
22
|
-
*/
|
|
23
22
|
function getCleanupTimer(): ReturnType<typeof setInterval> | null {
|
|
24
23
|
const globalObj = globalThis as Record<string, unknown>;
|
|
25
24
|
return (globalObj[TIMER_KEY] as ReturnType<typeof setInterval>) ?? null;
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
/**
|
|
29
|
-
* Set cleanup timer in globalThis to survive hot reloads
|
|
30
|
-
*/
|
|
31
27
|
function setCleanupTimer(timer: ReturnType<typeof setInterval> | null): void {
|
|
32
28
|
const globalObj = globalThis as Record<string, unknown>;
|
|
33
29
|
globalObj[TIMER_KEY] = timer;
|
|
34
30
|
}
|
|
35
31
|
|
|
36
|
-
/**
|
|
37
|
-
* Simple lock mechanism to prevent concurrent access issues
|
|
38
|
-
* NOTE: This is not a true mutex but provides basic protection for React Native
|
|
39
|
-
*/
|
|
40
|
-
function acquireLock(): boolean {
|
|
41
|
-
const globalObj = globalThis as Record<string, unknown>;
|
|
42
|
-
if (globalObj[LOCK_KEY]) {
|
|
43
|
-
return false; // Lock already held
|
|
44
|
-
}
|
|
45
|
-
globalObj[LOCK_KEY] = true;
|
|
46
|
-
return true;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function releaseLock(): void {
|
|
50
|
-
const globalObj = globalThis as Record<string, unknown>;
|
|
51
|
-
globalObj[LOCK_KEY] = false;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
32
|
export function getRequestStore(): RequestStore {
|
|
55
33
|
const globalObj = globalThis as Record<string, unknown>;
|
|
56
34
|
if (!globalObj[STORE_KEY]) {
|
|
@@ -61,18 +39,14 @@ export function getRequestStore(): RequestStore {
|
|
|
61
39
|
|
|
62
40
|
/**
|
|
63
41
|
* Create a deterministic request key using model and input hash
|
|
64
|
-
* Same model + input will always produce the same key for deduplication
|
|
65
42
|
*/
|
|
66
43
|
export function createRequestKey(model: string, input: Record<string, unknown>): string {
|
|
67
44
|
const inputStr = JSON.stringify(input, Object.keys(input).sort());
|
|
68
|
-
// Use DJB2 hash for input fingerprinting
|
|
69
45
|
let hash = 0;
|
|
70
46
|
for (let i = 0; i < inputStr.length; i++) {
|
|
71
47
|
const char = inputStr.charCodeAt(i);
|
|
72
48
|
hash = ((hash << 5) - hash + char) | 0;
|
|
73
49
|
}
|
|
74
|
-
// Return deterministic key without unique ID
|
|
75
|
-
// This allows proper deduplication: same model + input = same key
|
|
76
50
|
return `${model}:${hash.toString(36)}`;
|
|
77
51
|
}
|
|
78
52
|
|
|
@@ -80,56 +54,23 @@ export function getExistingRequest<T>(key: string): ActiveRequest<T> | undefined
|
|
|
80
54
|
return getRequestStore().get(key) as ActiveRequest<T> | undefined;
|
|
81
55
|
}
|
|
82
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Store a request for deduplication
|
|
59
|
+
* RN is single-threaded - no lock needed, Map.set is synchronous
|
|
60
|
+
*/
|
|
83
61
|
export function storeRequest<T>(key: string, request: ActiveRequest<T>): void {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// Spin-wait loop (synchronous)
|
|
90
|
-
// Note: Does NOT yield to event loop - tight loop
|
|
91
|
-
// In practice, this rarely loops due to single-threaded nature of React Native
|
|
92
|
-
while (!acquireLock() && retries < maxRetries) {
|
|
93
|
-
retries++;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (retries >= maxRetries) {
|
|
97
|
-
// Lock acquisition failed - this shouldn't happen in normal operation
|
|
98
|
-
// Log warning but proceed anyway since RN is single-threaded
|
|
99
|
-
console.warn(
|
|
100
|
-
`[request-store] Failed to acquire lock after ${maxRetries} attempts for key: ${key}. ` +
|
|
101
|
-
'Proceeding anyway (safe in single-threaded environment)'
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
const requestWithTimestamp = {
|
|
107
|
-
...request,
|
|
108
|
-
createdAt: request.createdAt ?? Date.now(),
|
|
109
|
-
};
|
|
110
|
-
getRequestStore().set(key, requestWithTimestamp);
|
|
111
|
-
|
|
112
|
-
// Start automatic cleanup if not already running
|
|
113
|
-
startAutomaticCleanup();
|
|
114
|
-
} finally {
|
|
115
|
-
// Always release lock, even if we didn't successfully acquire it
|
|
116
|
-
// to prevent deadlocks
|
|
117
|
-
releaseLock();
|
|
118
|
-
}
|
|
62
|
+
getRequestStore().set(key, {
|
|
63
|
+
...request,
|
|
64
|
+
createdAt: request.createdAt ?? Date.now(),
|
|
65
|
+
});
|
|
66
|
+
ensureCleanupRunning();
|
|
119
67
|
}
|
|
120
68
|
|
|
121
69
|
export function removeRequest(key: string): void {
|
|
122
70
|
const store = getRequestStore();
|
|
123
71
|
store.delete(key);
|
|
124
|
-
|
|
125
|
-
//
|
|
126
|
-
if (store.size === 0) {
|
|
127
|
-
const timer = getCleanupTimer();
|
|
128
|
-
if (timer) {
|
|
129
|
-
clearInterval(timer);
|
|
130
|
-
setCleanupTimer(null);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
72
|
+
// Don't stop timer here - let the cleanup interval handle it
|
|
73
|
+
// This prevents the race where a new request arrives right after we stop
|
|
133
74
|
}
|
|
134
75
|
|
|
135
76
|
export function cancelAllRequests(): void {
|
|
@@ -138,13 +79,7 @@ export function cancelAllRequests(): void {
|
|
|
138
79
|
req.abortController.abort();
|
|
139
80
|
});
|
|
140
81
|
store.clear();
|
|
141
|
-
|
|
142
|
-
// Stop cleanup timer
|
|
143
|
-
const timer = getCleanupTimer();
|
|
144
|
-
if (timer) {
|
|
145
|
-
clearInterval(timer);
|
|
146
|
-
setCleanupTimer(null);
|
|
147
|
-
}
|
|
82
|
+
stopCleanupTimer();
|
|
148
83
|
}
|
|
149
84
|
|
|
150
85
|
export function hasActiveRequests(): boolean {
|
|
@@ -152,10 +87,7 @@ export function hasActiveRequests(): boolean {
|
|
|
152
87
|
}
|
|
153
88
|
|
|
154
89
|
/**
|
|
155
|
-
* Clean up
|
|
156
|
-
* Should be called periodically to prevent memory leaks
|
|
157
|
-
*
|
|
158
|
-
* @param maxAge - Maximum age in milliseconds (default: 5 minutes)
|
|
90
|
+
* Clean up stale requests from the store
|
|
159
91
|
* @returns Number of requests cleaned up
|
|
160
92
|
*/
|
|
161
93
|
export function cleanupRequestStore(maxAge: number = MAX_REQUEST_AGE): number {
|
|
@@ -163,77 +95,36 @@ export function cleanupRequestStore(maxAge: number = MAX_REQUEST_AGE): number {
|
|
|
163
95
|
const now = Date.now();
|
|
164
96
|
let cleanedCount = 0;
|
|
165
97
|
|
|
166
|
-
// Track stale requests
|
|
167
|
-
const staleKeys: string[] = [];
|
|
168
|
-
|
|
169
98
|
for (const [key, request] of store.entries()) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
// Clean up stale requests that exceed max age
|
|
173
|
-
if (requestAge > maxAge) {
|
|
174
|
-
staleKeys.push(key);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Remove stale requests
|
|
179
|
-
for (const key of staleKeys) {
|
|
180
|
-
const request = store.get(key);
|
|
181
|
-
if (request) {
|
|
99
|
+
if (now - request.createdAt > maxAge) {
|
|
182
100
|
request.abortController.abort();
|
|
183
101
|
store.delete(key);
|
|
184
102
|
cleanedCount++;
|
|
185
103
|
}
|
|
186
104
|
}
|
|
187
105
|
|
|
188
|
-
// Stop
|
|
106
|
+
// Stop timer only inside the interval callback when store is truly empty
|
|
189
107
|
if (store.size === 0) {
|
|
190
|
-
|
|
191
|
-
if (timer) {
|
|
192
|
-
clearInterval(timer);
|
|
193
|
-
setCleanupTimer(null);
|
|
194
|
-
}
|
|
108
|
+
stopCleanupTimer();
|
|
195
109
|
}
|
|
196
110
|
|
|
197
111
|
return cleanedCount;
|
|
198
112
|
}
|
|
199
113
|
|
|
200
114
|
/**
|
|
201
|
-
*
|
|
202
|
-
* Runs periodically to prevent memory leaks
|
|
203
|
-
* Uses globalThis to survive hot reloads in React Native
|
|
115
|
+
* Ensure cleanup timer is running (idempotent)
|
|
204
116
|
*/
|
|
205
|
-
function
|
|
206
|
-
|
|
207
|
-
if (existingTimer) {
|
|
208
|
-
return; // Already running
|
|
209
|
-
}
|
|
117
|
+
function ensureCleanupRunning(): void {
|
|
118
|
+
if (getCleanupTimer()) return;
|
|
210
119
|
|
|
211
120
|
const timer = setInterval(() => {
|
|
212
|
-
|
|
213
|
-
const store = getRequestStore();
|
|
214
|
-
|
|
215
|
-
// Stop timer if no more requests in store (prevents indefinite timer)
|
|
216
|
-
if (store.size === 0) {
|
|
217
|
-
const currentTimer = getCleanupTimer();
|
|
218
|
-
if (currentTimer) {
|
|
219
|
-
clearInterval(currentTimer);
|
|
220
|
-
setCleanupTimer(null);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (cleanedCount > 0) {
|
|
225
|
-
console.log(`[request-store] Cleaned up ${cleanedCount} stale request(s)`);
|
|
226
|
-
}
|
|
121
|
+
cleanupRequestStore(MAX_REQUEST_AGE);
|
|
227
122
|
}, CLEANUP_INTERVAL);
|
|
228
123
|
|
|
229
124
|
setCleanupTimer(timer);
|
|
230
125
|
}
|
|
231
126
|
|
|
232
|
-
|
|
233
|
-
* Stop automatic cleanup (typically on app shutdown or hot reload)
|
|
234
|
-
* Clears the global timer to prevent memory leaks
|
|
235
|
-
*/
|
|
236
|
-
export function stopAutomaticCleanup(): void {
|
|
127
|
+
function stopCleanupTimer(): void {
|
|
237
128
|
const timer = getCleanupTimer();
|
|
238
129
|
if (timer) {
|
|
239
130
|
clearInterval(timer);
|
|
@@ -241,8 +132,14 @@ export function stopAutomaticCleanup(): void {
|
|
|
241
132
|
}
|
|
242
133
|
}
|
|
243
134
|
|
|
244
|
-
|
|
245
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Stop automatic cleanup (for app shutdown)
|
|
137
|
+
*/
|
|
138
|
+
export function stopAutomaticCleanup(): void {
|
|
139
|
+
stopCleanupTimer();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Clear any leftover timer on module load (hot reload safety)
|
|
246
143
|
if (typeof globalThis !== "undefined") {
|
|
247
144
|
const existingTimer = getCleanupTimer();
|
|
248
145
|
if (existingTimer) {
|
|
@@ -1,140 +1,134 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FAL Error Handler
|
|
3
|
-
*
|
|
3
|
+
* Uses @fal-ai/client error types (ApiError, ValidationError) for proper error handling
|
|
4
|
+
* No silent fallbacks - errors are categorized explicitly
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { isNonEmptyString } from './validators/string-validator.util';
|
|
7
|
+
import { ApiError, ValidationError } from "@fal-ai/client";
|
|
8
|
+
import type { FalErrorInfo, FalErrorCategory } from "../../domain/entities/error.types";
|
|
9
|
+
import { FalErrorType } from "../../domain/entities/error.types";
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
[ErrorTypeEnum.TIMEOUT]: ["timeout", "timed out"],
|
|
27
|
-
[ErrorTypeEnum.IMAGE_TOO_SMALL]: ["image_too_small", "image dimensions are too small", "minimum dimensions"],
|
|
28
|
-
[ErrorTypeEnum.VALIDATION]: ["validation", "invalid", "unprocessable", "422", "bad request", "400"],
|
|
29
|
-
[ErrorTypeEnum.CONTENT_POLICY]: ["content_policy", "content policy", "policy violation", "nsfw", "inappropriate"],
|
|
30
|
-
[ErrorTypeEnum.RATE_LIMIT]: ["rate limit", "too many requests", "429"],
|
|
31
|
-
[ErrorTypeEnum.AUTHENTICATION]: ["unauthorized", "401", "forbidden", "403", "api key", "authentication"],
|
|
32
|
-
[ErrorTypeEnum.QUOTA_EXCEEDED]: ["quota exceeded", "insufficient credits", "billing", "payment required", "402"],
|
|
33
|
-
[ErrorTypeEnum.MODEL_NOT_FOUND]: ["model not found", "endpoint not found", "404", "not found"],
|
|
34
|
-
[ErrorTypeEnum.API_ERROR]: ["api error", "502", "503", "504", "500", "internal server error"],
|
|
35
|
-
[ErrorTypeEnum.UNKNOWN]: [],
|
|
11
|
+
/**
|
|
12
|
+
* HTTP status code to error type mapping
|
|
13
|
+
*/
|
|
14
|
+
const STATUS_TO_ERROR_TYPE: Record<number, FalErrorType> = {
|
|
15
|
+
400: FalErrorType.VALIDATION,
|
|
16
|
+
401: FalErrorType.AUTHENTICATION,
|
|
17
|
+
402: FalErrorType.QUOTA_EXCEEDED,
|
|
18
|
+
403: FalErrorType.AUTHENTICATION,
|
|
19
|
+
404: FalErrorType.MODEL_NOT_FOUND,
|
|
20
|
+
422: FalErrorType.VALIDATION,
|
|
21
|
+
429: FalErrorType.RATE_LIMIT,
|
|
22
|
+
500: FalErrorType.API_ERROR,
|
|
23
|
+
502: FalErrorType.API_ERROR,
|
|
24
|
+
503: FalErrorType.API_ERROR,
|
|
25
|
+
504: FalErrorType.API_ERROR,
|
|
36
26
|
};
|
|
37
27
|
|
|
38
|
-
const RETRYABLE_TYPES = new Set([
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
28
|
+
const RETRYABLE_TYPES = new Set<FalErrorType>([
|
|
29
|
+
FalErrorType.NETWORK,
|
|
30
|
+
FalErrorType.TIMEOUT,
|
|
31
|
+
FalErrorType.RATE_LIMIT,
|
|
42
32
|
]);
|
|
43
33
|
|
|
44
34
|
/**
|
|
45
|
-
*
|
|
35
|
+
* Message-based error type detection (for non-ApiError errors)
|
|
46
36
|
*/
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
37
|
+
const MESSAGE_PATTERNS: Array<{ type: FalErrorType; patterns: string[] }> = [
|
|
38
|
+
{ type: FalErrorType.NETWORK, patterns: ["network", "fetch", "econnrefused", "enotfound", "etimedout"] },
|
|
39
|
+
{ type: FalErrorType.TIMEOUT, patterns: ["timeout", "timed out"] },
|
|
40
|
+
{ type: FalErrorType.CONTENT_POLICY, patterns: ["nsfw", "content_policy", "content policy", "policy violation"] },
|
|
41
|
+
{ type: FalErrorType.IMAGE_TOO_SMALL, patterns: ["image_too_small", "image dimensions are too small", "minimum dimensions"] },
|
|
42
|
+
];
|
|
51
43
|
|
|
52
44
|
/**
|
|
53
|
-
*
|
|
45
|
+
* Categorize error using @fal-ai/client error types
|
|
46
|
+
* Priority: ApiError status code > message pattern matching
|
|
54
47
|
*/
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
48
|
+
function categorizeError(error: unknown): FalErrorCategory {
|
|
49
|
+
// 1. ApiError (includes ValidationError) - use HTTP status code
|
|
50
|
+
if (error instanceof ApiError) {
|
|
51
|
+
const typeFromStatus = STATUS_TO_ERROR_TYPE[error.status];
|
|
52
|
+
if (typeFromStatus) {
|
|
53
|
+
return {
|
|
54
|
+
type: typeFromStatus,
|
|
55
|
+
messageKey: typeFromStatus,
|
|
56
|
+
retryable: RETRYABLE_TYPES.has(typeFromStatus),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// Unknown status code - still an API error
|
|
60
|
+
return { type: FalErrorType.API_ERROR, messageKey: "api_error", retryable: false };
|
|
61
|
+
}
|
|
60
62
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
: falError.body;
|
|
63
|
+
// 2. Standard Error - match message patterns
|
|
64
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
64
65
|
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
}
|
|
66
|
+
for (const { type, patterns } of MESSAGE_PATTERNS) {
|
|
67
|
+
if (patterns.some((p) => message.includes(p))) {
|
|
68
|
+
return { type, messageKey: type, retryable: RETRYABLE_TYPES.has(type) };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
*/
|
|
72
|
-
function matchesPatterns(errorString: string, patterns: string[]): boolean {
|
|
73
|
-
return patterns.some((pattern) => errorString.includes(pattern));
|
|
72
|
+
// 3. No match - UNKNOWN, not retryable
|
|
73
|
+
return { type: FalErrorType.UNKNOWN, messageKey: "unknown", retryable: false };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
*
|
|
77
|
+
* Extract user-readable message from error
|
|
78
|
+
* Uses @fal-ai/client types for structured extraction
|
|
78
79
|
*/
|
|
79
|
-
function
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
function extractMessage(error: unknown): string {
|
|
81
|
+
// ValidationError - extract field-level messages
|
|
82
|
+
if (error instanceof ValidationError) {
|
|
83
|
+
const fieldErrors = error.fieldErrors;
|
|
84
|
+
if (fieldErrors.length > 0) {
|
|
85
|
+
const messages = fieldErrors.map((e) => e.msg).filter(Boolean);
|
|
86
|
+
if (messages.length > 0) return messages.join("; ");
|
|
87
|
+
}
|
|
88
|
+
return error.message;
|
|
89
|
+
}
|
|
82
90
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
retryable: RETRYABLE_TYPES.has(errorType),
|
|
90
|
-
};
|
|
91
|
+
// ApiError - extract from body or message
|
|
92
|
+
if (error instanceof ApiError) {
|
|
93
|
+
// body may contain detail array
|
|
94
|
+
const body = error.body as { detail?: Array<{ msg?: string }> } | undefined;
|
|
95
|
+
if (body?.detail?.[0]?.msg) {
|
|
96
|
+
return body.detail[0].msg;
|
|
91
97
|
}
|
|
98
|
+
return error.message;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Standard Error
|
|
102
|
+
if (error instanceof Error) {
|
|
103
|
+
return error.message;
|
|
92
104
|
}
|
|
93
105
|
|
|
94
|
-
return
|
|
106
|
+
return String(error);
|
|
95
107
|
}
|
|
96
108
|
|
|
97
109
|
/**
|
|
98
|
-
*
|
|
110
|
+
* Map error to FalErrorInfo with full categorization
|
|
99
111
|
*/
|
|
100
|
-
function
|
|
101
|
-
category
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
): FalErrorInfo {
|
|
112
|
+
export function mapFalError(error: unknown): FalErrorInfo {
|
|
113
|
+
const category = categorizeError(error);
|
|
114
|
+
const message = extractMessage(error);
|
|
115
|
+
|
|
105
116
|
return {
|
|
106
117
|
type: category.type,
|
|
107
118
|
messageKey: `fal.errors.${category.messageKey}`,
|
|
108
119
|
retryable: category.retryable,
|
|
109
|
-
originalError:
|
|
110
|
-
originalErrorName:
|
|
111
|
-
stack:
|
|
112
|
-
statusCode:
|
|
120
|
+
originalError: message,
|
|
121
|
+
originalErrorName: error instanceof Error ? error.name : undefined,
|
|
122
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
123
|
+
statusCode: error instanceof ApiError ? error.status : undefined,
|
|
113
124
|
};
|
|
114
125
|
}
|
|
115
126
|
|
|
116
|
-
/**
|
|
117
|
-
* Map error to FalErrorInfo with full error details
|
|
118
|
-
*/
|
|
119
|
-
export function mapFalError(error: unknown): FalErrorInfo {
|
|
120
|
-
const category = categorizeError(error);
|
|
121
|
-
|
|
122
|
-
if (error instanceof Error) {
|
|
123
|
-
return buildErrorInfo(category, error.message, error);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return buildErrorInfo(category, String(error));
|
|
127
|
-
}
|
|
128
|
-
|
|
129
127
|
/**
|
|
130
128
|
* Parse FAL error and return user-friendly message
|
|
131
129
|
*/
|
|
132
130
|
export function parseFalError(error: unknown): string {
|
|
133
|
-
|
|
134
|
-
if (!isNonEmptyString(userMessage)) {
|
|
135
|
-
return "An unknown error occurred. Please try again.";
|
|
136
|
-
}
|
|
137
|
-
return userMessage;
|
|
131
|
+
return extractMessage(error);
|
|
138
132
|
}
|
|
139
133
|
|
|
140
134
|
/**
|
|
@@ -148,10 +142,5 @@ export function categorizeFalError(error: unknown): FalErrorCategory {
|
|
|
148
142
|
* Check if FAL error is retryable
|
|
149
143
|
*/
|
|
150
144
|
export function isFalErrorRetryable(error: unknown): boolean {
|
|
151
|
-
return
|
|
145
|
+
return categorizeError(error).retryable;
|
|
152
146
|
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Extract status code from error
|
|
156
|
-
*/
|
|
157
|
-
export { extractStatusCode };
|
|
@@ -3,30 +3,14 @@
|
|
|
3
3
|
* Clone, merge, pick, and omit operations
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { getErrorMessage } from '../helpers/error-helpers.util';
|
|
7
|
-
|
|
8
6
|
/**
|
|
9
7
|
* Deep clone object using JSON serialization
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
* - Dates become strings
|
|
13
|
-
* - Circular references will cause errors
|
|
14
|
-
* For complex objects, consider a dedicated cloning library
|
|
8
|
+
* Throws on failure (circular references, non-serializable values)
|
|
9
|
+
* No silent fallback - caller must handle errors explicitly
|
|
15
10
|
*/
|
|
16
11
|
export function deepClone<T>(data: T): T {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const serialized = JSON.stringify(data);
|
|
20
|
-
return JSON.parse(serialized) as T;
|
|
21
|
-
} catch (error) {
|
|
22
|
-
// Fallback for circular references or other JSON errors
|
|
23
|
-
console.warn(
|
|
24
|
-
'[object-transformers] deepClone failed, returning original:',
|
|
25
|
-
getErrorMessage(error)
|
|
26
|
-
);
|
|
27
|
-
// Return original data if cloning fails
|
|
28
|
-
return data;
|
|
29
|
-
}
|
|
12
|
+
const serialized = JSON.stringify(data);
|
|
13
|
+
return JSON.parse(serialized) as T;
|
|
30
14
|
}
|
|
31
15
|
|
|
32
16
|
/**
|