mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
server: fetchers: redo all with good error detection
This commit is contained in:
@@ -16,81 +16,22 @@ const SERVER_LOG_FETCHERS_ERRORS = true; // log all fetcher errors to the consol
|
||||
//
|
||||
|
||||
// JSON fetcher
|
||||
export async function fetchJsonOrTRPCThrow<TOut extends object = object, TBody extends object | undefined | FormData = undefined>(config: RequestConfig<TBody>): Promise<TOut> {
|
||||
return _fetchFromTRPC<TBody, TOut>(config, _jsonRequestParserOrThrow, 'json');
|
||||
export async function fetchJsonOrTRPCThrow<TOut extends object = object, TBody extends object | undefined | FormData = undefined>(config: _RequestConfig<TBody>): Promise<TOut> {
|
||||
return _fetchFromTRPC<TBody, TOut>(config, _jsonResponseParserOrThrow, 'json');
|
||||
}
|
||||
|
||||
async function _jsonRequestParserOrThrow(response: Response) {
|
||||
let text = '';
|
||||
try {
|
||||
text = await response.text();
|
||||
return JSON.parse(text) as any;
|
||||
} catch (error) {
|
||||
|
||||
// Errors: Cannot Parse
|
||||
if (error instanceof SyntaxError) {
|
||||
|
||||
const contentType = response.headers?.get('content-type')?.toLowerCase() || '';
|
||||
const contentTypeInfo = contentType && !contentType.includes('application/json') ? ` (Content-Type: ${contentType})` : '';
|
||||
|
||||
// Improve messaging of Empty or Incomplete JSON
|
||||
if (error.message === 'Unexpected end of JSON input')
|
||||
throw new TRPCError({
|
||||
code: 'PARSE_ERROR',
|
||||
message: (!text?.length ? 'Empty response while expecting JSON' : 'Incomplete JSON response') + contentTypeInfo,
|
||||
cause: error,
|
||||
});
|
||||
|
||||
// Improve messaging of a real parsing error where we expected JSON and got something else
|
||||
if (error.message.startsWith('Unexpected token')) {
|
||||
const lcText = text.trim().toLowerCase();
|
||||
let inferredType = 'unknown';
|
||||
|
||||
if (['<html', '<!doctype'].some(tag => lcText.startsWith(tag)))
|
||||
inferredType = 'HTML';
|
||||
else if (['<?xml', '<rss', '<feed', '<xml'].some(tag => lcText.startsWith(tag)))
|
||||
inferredType = 'XML';
|
||||
else if (['<div', '<span', '<p', '<script', '<br', '<body', '<head', '<title'].some(tag => lcText.startsWith(tag)))
|
||||
inferredType = 'HTML-like';
|
||||
else if (lcText.startsWith('{') || lcText.startsWith('['))
|
||||
inferredType = 'malformed JSON';
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'PARSE_ERROR',
|
||||
message: `Expected JSON data but received ${inferredType ? inferredType + ', likely an error page' : 'NON-JSON content'}${contentTypeInfo}: \n\n"${text.length > 200 ? text.slice(0, 200) + '...' : text}"`,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'PARSE_ERROR',
|
||||
message: `Error parsing JSON data${contentTypeInfo}: ${safeErrorString(error) || 'unknown error'}`,
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// Other errors: AbortError (request aborted), TypeError (body locked, decoding error for instance due to Content-Encoding mismatch), etc..
|
||||
throw new TRPCError({
|
||||
code: 'PARSE_ERROR',
|
||||
message: `Error reading JSON data: ${safeErrorString(error) || 'unknown error'}`,
|
||||
});
|
||||
}
|
||||
// unreachable
|
||||
}
|
||||
|
||||
|
||||
// Text fetcher
|
||||
export async function fetchTextOrTRPCThrow<TBody extends object | undefined = undefined>(config: RequestConfig<TBody>): Promise<string> {
|
||||
export async function fetchTextOrTRPCThrow<TBody extends object | undefined = undefined>(config: _RequestConfig<TBody>): Promise<string> {
|
||||
return _fetchFromTRPC<TBody, string>(config, async (response) => await response.text(), 'text');
|
||||
}
|
||||
|
||||
// Response fetcher
|
||||
export async function fetchResponseOrTRPCThrow<TBody extends object | undefined = undefined>(config: RequestConfig<TBody>): Promise<Response> {
|
||||
export async function fetchResponseOrTRPCThrow<TBody extends object | undefined = undefined>(config: _RequestConfig<TBody>): Promise<Response> {
|
||||
return _fetchFromTRPC<TBody, Response>(config, async (response) => response, 'response');
|
||||
}
|
||||
|
||||
|
||||
type RequestConfig<TBody extends object | undefined | FormData> = {
|
||||
type _RequestConfig<TBody extends object | undefined | FormData> = {
|
||||
url: string;
|
||||
headers?: HeadersInit;
|
||||
signal?: AbortSignal;
|
||||
@@ -104,13 +45,74 @@ type RequestConfig<TBody extends object | undefined | FormData> = {
|
||||
);
|
||||
|
||||
|
||||
//
|
||||
// TRPCFetcherError - unified error for all fetch failures
|
||||
//
|
||||
|
||||
/**
|
||||
* Error class for all _fetchFromTRPC failures, easy to pattern-match and retry.
|
||||
*
|
||||
* Is-a TRPCError: { code: 'BAD_REQUEST'; message?: string; cause?: unknown; }
|
||||
*
|
||||
* Retry Decision Matrix:
|
||||
* - HTTP 503/429/502 → Retry with exponential backoff (1-30s)
|
||||
* - Connection errors (ECONNREFUSED, ETIMEDOUT) → Retry with network profile
|
||||
* - HTTP 4xx (except 429) → Don't retry (client error)
|
||||
* - Abort → Don't retry (user initiated)
|
||||
* - Parse → Don't retry (upstream bug)
|
||||
*/
|
||||
export class TRPCFetcherError extends TRPCError {
|
||||
public override readonly name = 'TRPCFetcherError';
|
||||
|
||||
readonly category: TRPCFetcherErrorCategory;
|
||||
readonly connErrorName?: string; // [category='connection'] System error code (ECONNREFUSED, ETIMEDOUT, ENOTFOUND, etc.)
|
||||
readonly httpStatus?: number; // [category='http'] HTTP status code (503, 429, 502, etc.)
|
||||
|
||||
constructor(opts: {
|
||||
category: TRPCFetcherErrorCategory,
|
||||
connErrorName?: string,
|
||||
httpStatus?: number,
|
||||
// -> TRPCError fields (code, cause)
|
||||
// code?: TRPCError['code'],
|
||||
cause?: unknown,
|
||||
// -> Error fields (message)
|
||||
message: string,
|
||||
}) {
|
||||
const code = // opts.code ? opts.code
|
||||
opts.category === 'parse' ? 'UNPROCESSABLE_CONTENT'
|
||||
: opts.category === 'abort' ? 'CLIENT_CLOSED_REQUEST'
|
||||
: 'BAD_REQUEST';
|
||||
super({ code, message: opts.message, cause: opts.cause });
|
||||
|
||||
this.category = opts.category;
|
||||
this.connErrorName = opts.connErrorName;
|
||||
this.httpStatus = opts.httpStatus;
|
||||
|
||||
// Maintains proper prototype chain for instanceof checks
|
||||
Object.setPrototypeOf(this, TRPCFetcherError.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @abort: aborted by client/signal
|
||||
* @connection: network/TCP errors before HTTP response
|
||||
* @http: upstream returned HTTP error (4xx, 5xx)
|
||||
* @parse: response parsing failed (malformed JSON, encoding issues)
|
||||
*/
|
||||
type TRPCFetcherErrorCategory =
|
||||
| 'abort'
|
||||
| 'connection'
|
||||
| 'http'
|
||||
| 'parse';
|
||||
|
||||
|
||||
/**
|
||||
* Internal fetcher
|
||||
* - Parses errors on connection, http responses, and parsing
|
||||
* - Throws TRPCErrors (as this is used within tRPC procedures)
|
||||
*/
|
||||
async function _fetchFromTRPC<TBody extends object | undefined | FormData, TOut>(
|
||||
config: RequestConfig<TBody>,
|
||||
config: _RequestConfig<TBody>,
|
||||
responseParser: (response: Response) => Promise<TOut>,
|
||||
parserName: 'json' | 'text' | 'response',
|
||||
): Promise<TOut> {
|
||||
@@ -156,96 +158,111 @@ async function _fetchFromTRPC<TBody extends object | undefined | FormData, TOut>
|
||||
};
|
||||
|
||||
// upstream FETCH
|
||||
// @throws DOMException.name=AbortError when the request is aborted by the user
|
||||
// @throws Error.name=ResponseAborted (Next.js) when the request is aborted (e.g. HMR)
|
||||
// @throws TypeError: network error occurred (URL invalid, invalid RequestInit, network error such as DNS failure or no connectivity or IP, etc.)
|
||||
response = await fetch(url, request);
|
||||
|
||||
} catch (error: any) {
|
||||
|
||||
// NOTE: if signal?.aborted is true, we also come here, likely with a error?.name = ResponseAborted (Next.js) or AbortError (standard from signal)
|
||||
// since we don't handle this case specially, the same TRPCError will be thrown as for other connection errors.
|
||||
if (error?.name === 'AbortError')
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: (!throwWithoutName ? `[${moduleName} cancelled]: ` : '') + (error?.message || 'This operation was aborted.'),
|
||||
cause: error,
|
||||
});
|
||||
|
||||
// [logging - Connection error] candidate for the logging system
|
||||
const errorCause: any | undefined = error ? error?.cause ?? undefined : undefined;
|
||||
const errorName: string = error?.name || 'UnknownError';
|
||||
const errorString = safeErrorString(error) || 'unknown fetch error';
|
||||
|
||||
// Show server-access warning for common connection issues
|
||||
const showAccessWarning = [
|
||||
'ECONNREFUSED',
|
||||
'ENOTFOUND',
|
||||
'ETIMEDOUT',
|
||||
'DNS_ERROR',
|
||||
'ECONNRESET',
|
||||
'ENETUNREACH', // example an IP is unreachable
|
||||
].includes(errorCause?.code) || [
|
||||
'network connection lost.',
|
||||
'connect timeout error',
|
||||
].includes(errorString.toLowerCase());
|
||||
// 1. [shall be handled before] AbortError - user cancelled the request, signal?.aborted shall be true, or ResponseAborted in Next.js (when HMR or similar)
|
||||
if (['AbortError', 'ResponseAborted'].includes(errorName) /*|| (signal && signal.aborted)*/)
|
||||
throw new TRPCFetcherError({
|
||||
category: 'abort',
|
||||
message: (!throwWithoutName ? `[${moduleName} cancelled]: ` : '')
|
||||
+ (errorString || 'This operation was aborted.'),
|
||||
// cause: error,
|
||||
});
|
||||
|
||||
// 2. TypeError - network/connection error
|
||||
|
||||
// Resolve Technical details about the Cause - Heuristic
|
||||
const _cause = !error || !(error instanceof Error) || !('cause' in error) ? null : error.cause ?? null;
|
||||
const causeName = !_cause || !(_cause instanceof Error) ? null : _cause.name ?? null;
|
||||
const causeCode = !_cause || !(_cause instanceof Error || typeof _cause === 'object') ? null : (_cause as any).code ?? null;
|
||||
const causeMessage = safeErrorString(_cause);
|
||||
// const causeMessage = !_cause ? null : causeName === 'AggregateError' ? safeErrorString(_cause) : _cause?.toString() || safeErrorString(_cause) || null;
|
||||
const connErrorName = causeCode || causeName || errorName;
|
||||
|
||||
// decide whether to show the URL
|
||||
const prettyShowUrl = [
|
||||
'ConnectTimeoutError', // timeout connecting to server - cause Name
|
||||
'UND_ERR_CONNECT_TIMEOUT', // timeout connecting to server - cause Code
|
||||
'ENOTFOUND', // DNS failure
|
||||
'ECONNREFUSED', // connection refused (e.g. connecting to localhost from a public server) - often an AggregateError
|
||||
'EHOSTUNREACH', // when I unplug the network cable
|
||||
// not verified, but likely:
|
||||
'ETIMEDOUT', // connection timed out
|
||||
'ECONNRESET', // connection reset by peer
|
||||
].includes(connErrorName);
|
||||
|
||||
// NOTE: This may log too much - for instance a 404 not found, etc.. - so we're putting it under the flag
|
||||
// Consider we're also throwing the same, so there will likely be further logging.
|
||||
if (SERVER_DEBUG_WIRE || SERVER_LOG_FETCHERS_ERRORS)
|
||||
console.log(`[${method}] [${moduleName} network issue]: ${errorString}`, { error, errorCause, debugCleanUrl, urlShown: showAccessWarning });
|
||||
console.log(`[${method}] [${moduleName} network issue]: "${errorString}"`, { error, _cause, debugCleanUrl, urlShown: prettyShowUrl });
|
||||
|
||||
// Handle (NON) CONNECTION errors -> HTTP 400
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
// -> throw Connection error: will be a 400 (BAD_REQUEST), with preserved cause
|
||||
throw new TRPCFetcherError({
|
||||
category: 'connection',
|
||||
connErrorName: connErrorName,
|
||||
message: (!throwWithoutName ? `[${moduleName} network issue]: ` : '')
|
||||
+ `Could not connect: ${errorString}`
|
||||
+ (errorCause ? ` \nTechnical Details: ${safeErrorString(errorCause)}` : '')
|
||||
+ (showAccessWarning ? ` \n\nPlease make sure the Server can access -> ${debugCleanUrl}` : ''),
|
||||
cause: errorCause,
|
||||
+ `Could not connect: ${errorString}.`
|
||||
+ (causeMessage ? ` \nTechnical cause: ${causeMessage}.` : '')
|
||||
+ (prettyShowUrl ? ` \n\nPlease make sure the Server can access -> ${debugCleanUrl}` : ''),
|
||||
// cause: _cause,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 2. Check for non-200s
|
||||
// These are the MOST FREQUENT errors, application level response. Such as:
|
||||
// - 400 when requesting an invalid size to Dall-E-3, etc..
|
||||
// - 403 when requesting a localhost URL from a public server, etc..
|
||||
if (!response.ok) {
|
||||
|
||||
// try to parse a json or text payload, which frequently contains the error, if present
|
||||
let payload: any | null = await response.text().catch(() => null);
|
||||
// parse status and potential payload (frequently contain error details)
|
||||
let notOkayPayload: any | null = await response.text().catch(() => null);
|
||||
try {
|
||||
if (payload)
|
||||
payload = JSON.parse(payload) as string;
|
||||
if (notOkayPayload)
|
||||
notOkayPayload = JSON.parse(notOkayPayload) as string;
|
||||
} catch {
|
||||
// ...ignore
|
||||
}
|
||||
|
||||
// [logging - HTTP error] candidate for the logging system
|
||||
const status = response.status;
|
||||
let payloadString = safeErrorString(payload);
|
||||
const s: number = response.status;
|
||||
let payloadString = safeErrorString(notOkayPayload);
|
||||
if (payloadString) {
|
||||
// truncate
|
||||
if (payloadString.length > 200)
|
||||
payloadString = payloadString.slice(0, 200) + '...';
|
||||
const lcPayload = payloadString.trim().toLowerCase();
|
||||
if (['<!doctype', '<html', '<head', '<body', '<script', '<title'].some(tag => lcPayload.startsWith(tag)))
|
||||
payloadString = 'The data looks like HTML, likely an error page: \n\n"' + payloadString + '"';
|
||||
// frame
|
||||
const inferredType = _inferTextPayloadType(payloadString);
|
||||
if (inferredType)
|
||||
payloadString = `The data looks like ${inferredType}: \n\n"${payloadString}"`;
|
||||
}
|
||||
|
||||
if (SERVER_DEBUG_WIRE || SERVER_LOG_FETCHERS_ERRORS)
|
||||
console.warn(`[${method}] [${moduleName} issue] (http ${status}, ${response.statusText}):`, { parserName, payloadString });
|
||||
console.warn(`[${method}] [${moduleName} issue] (http ${s}, ${response.statusText}):`, { parserName, payloadString });
|
||||
|
||||
// Handle HTTP Response errors -> HTTP 400
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
// -> throw HTTP error: will be a 400 (BAD_REQUEST), with preserved status
|
||||
throw new TRPCFetcherError({
|
||||
category: 'http',
|
||||
httpStatus: s,
|
||||
message: (throwWithoutName ? '' : `[${moduleName} issue]: `)
|
||||
+ `Upstream responded with HTTP ${status} ${response.statusText}`
|
||||
+ `Upstream responded with HTTP ${s} ${response.statusText}`
|
||||
+ (payloadString ? ` - \n${payloadString}` : '')
|
||||
+ (payload?.error?.failed_generation && url.includes('api.groq.com') // [Groq]
|
||||
? ` \n\nGroq: failed_generation: ${payload.error.failed_generation}` : '')
|
||||
+ (status === 403 && moduleName === 'Gemini' && payloadString?.includes('Requests from referer')
|
||||
? ' \n\nGemini: Check API key restrictions in Google Cloud Console' : '')
|
||||
+ ((status === 404 || status === 403 || status === 502) && !url.includes('app.openpipe.ai') // [OpenPipe] 403 when the model is associated to the project, 404 when not found
|
||||
? ` \n\nPlease make sure the Server can access -> ${debugCleanUrl}` : ''),
|
||||
// Custom hints for common issues from select providers
|
||||
+ (s === 403 && moduleName === 'Gemini' && payloadString?.includes('Requests from referer') ? ' \n\nGemini: Check API key restrictions in Google Cloud Console' : '')
|
||||
+ ((s === 404 || s === 403 || s === 502) && !url.includes('app.openpipe.ai') ? ` \n\nPlease make sure the Server can access -> ${debugCleanUrl}` : ''), // [OpenPipe] 403 when the model is associated to the project, 404 when not found
|
||||
// cause: payload, // NOT an Error - do not use even to preserve original error payload as cause
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 3. Safe Parse
|
||||
let value: TOut;
|
||||
try {
|
||||
@@ -254,25 +271,106 @@ async function _fetchFromTRPC<TBody extends object | undefined | FormData, TOut>
|
||||
|
||||
// [logging - Parsing error] candidate for the logging system
|
||||
if (SERVER_LOG_FETCHERS_ERRORS)
|
||||
console.warn(`[${method}] [${moduleName}]: (${parserName} parsing error): ${error?.name}`, { error, url });
|
||||
console.warn(`[${method}] [${moduleName}]: (${parserName} parsing error): ${error?.name}`, { error, url });
|
||||
|
||||
// Forward already processed Parsing error -> 422
|
||||
if (error instanceof TRPCError)
|
||||
throw new TRPCError({
|
||||
code: 'UNPROCESSABLE_CONTENT',
|
||||
message: (!throwWithoutName ? `[${moduleName}]: ` : '')
|
||||
+ error.message
|
||||
// Forward already processed Parsing error, adding the module name if required
|
||||
if (error instanceof TRPCFetcherError)
|
||||
throw throwWithoutName ? error : new TRPCFetcherError({
|
||||
category: error.category,
|
||||
connErrorName: error.connErrorName,
|
||||
httpStatus: error.httpStatus,
|
||||
message: `[${moduleName} parsing issue]: ${error.message}`
|
||||
+ ` \n\nPlease make sure the Server can access -> ${debugCleanUrl}`,
|
||||
cause: error.cause,
|
||||
// cause: error.cause, // REMOVE the cause
|
||||
});
|
||||
|
||||
// Handle PARSING Errors -> HTTP 422
|
||||
throw new TRPCError({
|
||||
code: 'UNPROCESSABLE_CONTENT',
|
||||
message: (throwWithoutName ? `cannot parse ${parserName}: ` : `[${moduleName} parsing issue]: `)
|
||||
+ (safeErrorString(error) || 'unknown error'),
|
||||
// -> wrap other PARSING ERRORS / ABORTS
|
||||
throw new TRPCFetcherError({
|
||||
category: !!error && (error as any)?.name === 'AbortError' ? 'abort' : 'parse',
|
||||
message: (throwWithoutName ? '' : `[${moduleName} parsing issue]: `)
|
||||
+ `Error reading ${parserName} data: ${safeErrorString(error) || 'unknown error'}`,
|
||||
// cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
// --- Utilities ---
|
||||
|
||||
/**
|
||||
* JSON Response parser with improved error messages due to fragile responses
|
||||
*/
|
||||
async function _jsonResponseParserOrThrow(response: Response) {
|
||||
let text = '';
|
||||
try {
|
||||
// @throws: AbortError (a DOMException.name, request aborted)
|
||||
// @throws: TypeError (operation could not be performed: body locked, decoding error for instance due to Content-Encoding mismatch)
|
||||
text = await response.text();
|
||||
|
||||
// @throws: SyntaxError (malformed JSON)
|
||||
return JSON.parse(text) as any;
|
||||
} catch (error) {
|
||||
|
||||
// 2. JSON.parse errors
|
||||
if (error instanceof SyntaxError) {
|
||||
|
||||
// specialize by error message
|
||||
const { message: errorMessage } = error;
|
||||
const contentType = response.headers?.get('content-type')?.toLowerCase() || '';
|
||||
const contentTypeInfo = contentType && !contentType.includes('application/json') ? ` (Content-Type: ${contentType})` : '';
|
||||
|
||||
// 2.A JSON incomplete / empty
|
||||
if (errorMessage === 'Unexpected end of JSON input')
|
||||
throw new TRPCFetcherError({
|
||||
category: 'parse',
|
||||
message: (text?.length ? 'Incomplete JSON response' : 'Empty response while expecting JSON') + contentTypeInfo,
|
||||
// cause: error,
|
||||
});
|
||||
|
||||
// 2.B NOT JSON
|
||||
if (errorMessage.startsWith('Unexpected token')) {
|
||||
const inferredType = _inferTextPayloadType(text);
|
||||
throw new TRPCFetcherError({
|
||||
category: 'parse',
|
||||
message: `Expected JSON data but received ${inferredType ? inferredType + ', likely an error page' : 'NON-JSON content'}${contentTypeInfo}:`
|
||||
+ ` \n\n"${text.length > 200 ? text.slice(0, 200) + '...' : text}"`,
|
||||
// cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
// 2.C Other SyntaxError
|
||||
throw new TRPCFetcherError({
|
||||
category: 'parse',
|
||||
message: `Error parsing JSON data${contentTypeInfo}: ${safeErrorString(error) || 'unknown error'}`,
|
||||
// cause: error,
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
// 1. response.text(): AbortError (shall have been dealt with already) or TypeError
|
||||
throw new TRPCFetcherError({
|
||||
category: !!error && (error as any)?.name === 'AbortError' ? 'abort' : 'parse', // note: the second may as well be 'connection' but let's make it 'parse' for safety, so it's not retried
|
||||
message: `Error reading JSON data: ${safeErrorString(error) || 'unknown error'}`,
|
||||
// cause: error,
|
||||
});
|
||||
|
||||
}
|
||||
// unreachable
|
||||
}
|
||||
|
||||
function _inferTextPayloadType(payload?: string) {
|
||||
if (!payload || typeof (payload as unknown) !== 'string')
|
||||
return;
|
||||
|
||||
const lcText = payload.trim().toLowerCase();
|
||||
if (['<html', '<!doctype'].some(tag => lcText.startsWith(tag)))
|
||||
return 'HTML';
|
||||
else if (['<?xml', '<rss', '<feed', '<xml'].some(tag => lcText.startsWith(tag)))
|
||||
return 'XML';
|
||||
else if (['<div', '<span', '<p', '<script', '<br', '<body', '<head', '<title'].some(tag => lcText.startsWith(tag)))
|
||||
return 'HTML-like';
|
||||
else if (lcText.startsWith('{') || lcText.startsWith('['))
|
||||
return 'malformed JSON';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user