@umituz/react-native-ai-fal-provider 3.1.5 → 3.1.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-ai-fal-provider",
3
- "version": "3.1.5",
3
+ "version": "3.1.6",
4
4
  "description": "FAL AI provider for React Native - implements IAIProvider interface for unified AI generation",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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
- import { parseFalError } from "../utils/fal-error-handler.util";
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 | null }> {
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
- currentRequestId = update.request_id ?? currentRequestId;
53
- const jobStatus = mapFalStatusToJobStatus({
54
- status: update.status as FalQueueStatus["status"],
55
- requestId: currentRequestId ?? "",
56
- logs: update.logs as FalQueueStatus["logs"],
57
- queuePosition: update.queue_position,
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("FAL subscription timeout"));
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
- // fal.subscribe returns { data: T, requestId: string } - unwrap the data
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?.(actualData);
111
- return { result: actualData, requestId: falRequestId };
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 userMessage = parseFalError(error);
118
- if (!userMessage || userMessage.trim().length === 0) {
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
- // fal.run returns { data: T, requestId: string } - unwrap the data
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 actualData;
191
+ return data;
154
192
  } catch (error) {
155
193
  if (error instanceof NSFWContentError) {
156
194
  throw error;
157
195
  }
158
196
 
159
- const userMessage = parseFalError(error);
160
- throw new Error(userMessage);
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 type { FalQueueStatus } from "../../domain/entities/fal.types";
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
- * Normalize FAL queue status response from snake_case (SDK) to camelCase (internal)
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(`FAL API response missing request_id for model ${model}`);
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(`FAL API response missing status_url for model ${model}`);
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
- const status = normalizeFalQueueStatus(raw);
62
- if (!status) {
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
- `Invalid FAL queue status response for model ${model}, requestId ${requestId}`
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
- return mapFalStatusToJobStatus(status);
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
- `Invalid FAL queue result for model ${model}, requestId ${requestId}: Result is not an object`
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
- `Invalid FAL queue result for model ${model}, requestId ${requestId}: Missing 'data' property`
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 { FalQueueStatus, FalLogEntry } from "../../domain/entities/fal.types";
8
+ import type { FalLogEntry } from "../../domain/entities/fal.types";
8
9
 
9
- export const FAL_QUEUE_STATUSES = {
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
- } as const;
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 queue status to standardized job status
24
- * Provides safe defaults for missing or invalid values
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(status: FalQueueStatus): JobStatus {
27
- const mappedStatus = STATUS_MAP[status.status] ?? DEFAULT_STATUS;
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(status.logs)
32
- ? status.logs.map((log: FalLogEntry) => ({
33
- message: log.message,
34
- level: log.level ?? "info",
35
- timestamp: log.timestamp ?? new Date().toISOString(),
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: status.queuePosition ?? undefined,
39
- requestId: status.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
- // Acquire lock for consistent operation
85
- // React Native is single-threaded, but this prevents re-entrancy issues
86
- const maxRetries = 10;
87
- let retries = 0;
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
- // Stop cleanup timer if store is empty
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 completed/stale requests from the store
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
- const requestAge = now - request.createdAt;
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 cleanup timer if store is empty
106
+ // Stop timer only inside the interval callback when store is truly empty
189
107
  if (store.size === 0) {
190
- const timer = getCleanupTimer();
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
- * Start automatic cleanup of stale requests
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 startAutomaticCleanup(): void {
206
- const existingTimer = getCleanupTimer();
207
- if (existingTimer) {
208
- return; // Already running
209
- }
117
+ function ensureCleanupRunning(): void {
118
+ if (getCleanupTimer()) return;
210
119
 
211
120
  const timer = setInterval(() => {
212
- const cleanedCount = cleanupRequestStore(MAX_REQUEST_AGE);
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
- // Clean up any existing timer on module load to prevent leaks during hot reload
245
- // This ensures old timers are cleared when the module is reloaded in development
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
- * Unified error handling for FAL AI operations
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 type { FalErrorInfo, FalErrorCategory, FalErrorType } from "../../domain/entities/error.types";
7
- import { FalErrorType as ErrorTypeEnum } from "../../domain/entities/error.types";
8
- import { safeJsonParseOrNull } from "./parsers";
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
- const STATUS_CODES = ["400", "401", "402", "403", "404", "422", "429", "500", "502", "503", "504"];
12
-
13
- interface FalApiErrorDetail {
14
- msg?: string;
15
- type?: string;
16
- loc?: string[];
17
- }
18
-
19
- interface FalApiError {
20
- body?: { detail?: FalApiErrorDetail[] } | string;
21
- message?: string;
22
- }
23
-
24
- const ERROR_PATTERNS: Record<FalErrorType, string[]> = {
25
- [ErrorTypeEnum.NETWORK]: ["network", "fetch", "connection", "econnrefused", "enotfound", "etimedout"],
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
- ErrorTypeEnum.NETWORK,
40
- ErrorTypeEnum.TIMEOUT,
41
- ErrorTypeEnum.RATE_LIMIT,
28
+ const RETRYABLE_TYPES = new Set<FalErrorType>([
29
+ FalErrorType.NETWORK,
30
+ FalErrorType.TIMEOUT,
31
+ FalErrorType.RATE_LIMIT,
42
32
  ]);
43
33
 
44
34
  /**
45
- * Extract HTTP status code from error message
35
+ * Message-based error type detection (for non-ApiError errors)
46
36
  */
47
- function extractStatusCode(errorString: string): number | undefined {
48
- const code = STATUS_CODES.find((c) => errorString.includes(c));
49
- return code ? parseInt(code, 10) : undefined;
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
- * Parse FAL API error and extract user-friendly message
45
+ * Categorize error using @fal-ai/client error types
46
+ * Priority: ApiError status code > message pattern matching
54
47
  */
55
- function parseFalApiError(error: unknown): string {
56
- const fallback = error instanceof Error ? error.message : String(error);
57
-
58
- const falError = error as FalApiError;
59
- if (!falError?.body) return fallback;
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
- const body = typeof falError.body === "string"
62
- ? safeJsonParseOrNull<{ detail?: FalApiErrorDetail[] }>(falError.body)
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 detail = body?.detail?.[0];
66
- return detail?.msg ?? falError.message ?? fallback;
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
- * Check if error string matches any of the provided patterns
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
- * Categorize FAL error based on error message patterns
77
+ * Extract user-readable message from error
78
+ * Uses @fal-ai/client types for structured extraction
78
79
  */
79
- function categorizeError(error: unknown): FalErrorCategory {
80
- const message = error instanceof Error ? error.message : String(error);
81
- const errorString = message.toLowerCase();
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
- for (const [type, patterns] of Object.entries(ERROR_PATTERNS)) {
84
- if (patterns.length > 0 && matchesPatterns(errorString, patterns)) {
85
- const errorType = type as FalErrorType;
86
- return {
87
- type: errorType,
88
- messageKey: errorType,
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 { type: ErrorTypeEnum.UNKNOWN, messageKey: "unknown", retryable: false };
106
+ return String(error);
95
107
  }
96
108
 
97
109
  /**
98
- * Build FalErrorInfo from error string and category
110
+ * Map error to FalErrorInfo with full categorization
99
111
  */
100
- function buildErrorInfo(
101
- category: FalErrorCategory,
102
- errorString: string,
103
- errorInstance?: Error
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: errorString,
110
- originalErrorName: errorInstance?.name,
111
- stack: errorInstance?.stack,
112
- statusCode: extractStatusCode(errorString),
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
- const userMessage = parseFalApiError(error);
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 categorizeFalError(error).retryable;
145
+ return categorizeError(error).retryable;
152
146
  }
153
-
154
- /**
155
- * Extract status code from error
156
- */
157
- export { extractStatusCode };
@@ -26,7 +26,6 @@ export {
26
26
  mapFalError,
27
27
  parseFalError,
28
28
  isFalErrorRetryable,
29
- extractStatusCode,
30
29
  } from "./fal-error-handler.util";
31
30
 
32
31
  export { formatDate } from "./date-format.util";
@@ -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
- * NOTE: This has limitations:
11
- * - Functions are not cloned
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
- try {
18
- // Try JSON clone first (fast path)
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
  /**