Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions apps/sim/app/api/v2/lib/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type OrchestrationErrorCode,
} from '@/lib/core/orchestration/types'
import type { HttpError } from '@/lib/core/utils/http-error'
import { describeResponseError } from '@/lib/core/utils/response-error'
import type { RateLimitResult } from '@/app/api/v1/middleware'

/**
Expand Down Expand Up @@ -217,7 +218,7 @@ export function v2Error(
const retryAfterSeconds = options.omitRetryAfter
? undefined
: RETRY_AFTER_SECONDS_BY_STATUS[status]
return NextResponse.json(
const response = NextResponse.json(
{ error },
{
status,
Expand All @@ -229,6 +230,15 @@ export function v2Error(
},
}
)
const reason = detailCode(options.details)
describeResponseError(response, { errorCode: code, ...(reason ? { errorReason: reason } : {}) })
return response
}

/** The cause a refusal names in `details.code`, a closed vocabulary safe to log. */
function detailCode(details: unknown): string | undefined {
if (typeof details !== 'object' || details === null || !('code' in details)) return undefined
return typeof details.code === 'string' ? details.code : undefined
}

/** Renders a trusted typed HTTP error without changing the v2 envelope. */
Expand Down Expand Up @@ -267,9 +277,17 @@ export function v2ValidationError(error: ZodError, slice?: RequestSlice): NextRe
? issueMessage.slice(fieldLabel.length)
: issueMessage
const location = [slice, ...path].filter((part) => part !== undefined && part !== '').join('.')
return v2Error('BAD_REQUEST', location ? `${location}: ${message}` : message, {
const response = v2Error('BAD_REQUEST', location ? `${location}: ${message}` : message, {
details: serializeZodIssues(error),
})
// Deeper segments can be record keys the caller wrote (an import's CSV headers); no slice is itself a record.
const field = [slice, path[0]].filter((part) => part !== undefined && part !== '').join('.')
describeResponseError(response, {
errorCode: 'BAD_REQUEST',
...(field ? { errorField: field } : {}),
...(error.issues[0] ? { errorReason: error.issues[0].code } : {}),
})
return response
}

/**
Expand Down
30 changes: 30 additions & 0 deletions apps/sim/lib/core/utils/response-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* The machine-readable part of an error response, kept beside the response so
* the route handler's closing log line can be grouped by why a request was
* refused without reading the body. Never the message, which can quote user
* input.
*/
export interface ResponseErrorSummary {
/** The stable error code the body carries, e.g. `BAD_REQUEST`. */
errorCode: string
/** For a validation failure, the request slice and its declared top-level field, e.g. `body.mapping`. */
errorField?: string
/** The cause the refusal named (`READ_ONLY_API_KEY`) or the validation issue kind (`invalid_type`). */
errorReason?: string
}

/**
* Keyed by the response object, so only a response a route actually returns is
* ever read back. One built only to extract its message is never logged.
*/
const summaries = new WeakMap<Response, ResponseErrorSummary>()

/** Records what `response` tells its caller; a later call for the same response replaces it. */
export function describeResponseError(response: Response, summary: ResponseErrorSummary): void {
summaries.set(response, summary)
}

/** What `describeResponseError` recorded for `response`, if anything. */
export function readResponseError(response: Response): ResponseErrorSummary | undefined {
return summaries.get(response)
}
5 changes: 3 additions & 2 deletions apps/sim/lib/core/utils/with-route-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ADMISSION_RETRY_AFTER_SECONDS } from '@/lib/core/admission/transient-fa
import { FILE_DELIVERY_CSP_PATH_PATTERN, getMainCSPPolicy } from '@/lib/core/security/csp'
import { HttpError } from '@/lib/core/utils/http-error'
import { generateRequestId } from '@/lib/core/utils/request'
import { readResponseError } from '@/lib/core/utils/response-error'
import { MAX_CALL_CHAIN_DEPTH, parseCallChain, SIM_VIA_HEADER } from '@/lib/execution/call-chain'
import { trackInFlightRequest } from '@/lib/monitoring/in-flight-requests'
import { withPermissionGroupScope } from '@/lib/permission-groups/request-scope.server'
Expand Down Expand Up @@ -274,9 +275,9 @@ export function withRouteHandler<T>(
const duration = Date.now() - startTime

if (status >= 500) {
logger.error('Server error response', { status, duration })
logger.error('Server error response', { status, duration, ...readResponseError(response) })
} else if (status >= 400) {
logger.warn('Client error response', { status, duration })
logger.warn('Client error response', { status, duration, ...readResponseError(response) })
} else if (status > 0) {
logger.info('OK', { status, duration })
}
Expand Down
Loading