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
42 changes: 40 additions & 2 deletions apps/sim/app/api/tools/custom/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import {
authMockFns,
createRouteContext,
hybridAuthMockFns,
permissionsMock,
permissionsMockFns,
Expand All @@ -15,8 +16,9 @@ import {
import { NextRequest } from 'next/server'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockUpsertCustomTools } = vi.hoisted(() => ({
const { mockUpsertCustomTools, MockCustomToolTitleConflictError } = vi.hoisted(() => ({
mockUpsertCustomTools: vi.fn(),
MockCustomToolTitleConflictError: class extends Error {},
}))

const mockGetUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
Expand Down Expand Up @@ -82,11 +84,12 @@ vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)

vi.mock('@/lib/workflows/custom-tools/operations', () => ({
upsertCustomTools: (...args: unknown[]) => mockUpsertCustomTools(...args),
CustomToolTitleConflictError: MockCustomToolTitleConflictError,
}))

vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)

import { DELETE } from '@/app/api/tools/custom/route'
import { DELETE, POST } from '@/app/api/tools/custom/route'

describe('Custom Tools API Routes', () => {
const mockSession = { user: { id: 'user-123' } }
Expand All @@ -113,6 +116,41 @@ describe('Custom Tools API Routes', () => {
resetDbChainMock()
})

describe('POST /api/tools/custom', () => {
function saveRequest() {
return new NextRequest('http://localhost:3000/api/tools/custom', {
method: 'POST',
body: JSON.stringify({
workspaceId: 'workspace-123',
tools: [{ title: sampleTools[0].title, schema: sampleTools[0].schema, code: 'return 1' }],
}),
})
}

it('answers a title the workspace already holds with 409, not 500', async () => {
mockUpsertCustomTools.mockRejectedValueOnce(
new MockCustomToolTitleConflictError('A tool with that title already exists')
)

const response = await POST(saveRequest(), createRouteContext({}))

expect(response.status).toBe(409)
expect(await response.json()).toEqual({ error: 'A tool with that title already exists' })
})

it('answers a unique violation from a concurrent save with 409', async () => {
mockUpsertCustomTools.mockRejectedValueOnce(
Object.assign(new Error('duplicate key value violates unique constraint'), {
code: '23505',
})
)

const response = await POST(saveRequest(), createRouteContext({}))

expect(response.status).toBe(409)
})
})

/**
* Test DELETE endpoint
*/
Expand Down
16 changes: 14 additions & 2 deletions apps/sim/app/api/tools/custom/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { db } from '@sim/db'
import { customTools } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { getErrorMessage } from '@sim/utils/errors'
import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors'
import { and, desc, eq, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
Expand All @@ -16,7 +16,10 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
import {
CustomToolTitleConflictError,
upsertCustomTools,
} from '@/lib/workflows/custom-tools/operations'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

const logger = createLogger('CustomToolsAPI')
Expand Down Expand Up @@ -176,6 +179,15 @@ export const POST = withRouteHandler(async (req: NextRequest) => {

return NextResponse.json({ success: true, data: resultTools })
} catch (error) {
if (error instanceof CustomToolTitleConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}
if (getPostgresErrorCode(error) === '23505') {
return NextResponse.json(
{ error: 'A custom tool with that title already exists in this workspace' },
{ status: 409 }
)
}
logger.error('Error updating custom tools', error)
const errorMessage = getErrorMessage(error, 'Failed to update custom tools')
return NextResponse.json({ error: errorMessage }, { status: 500 })
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/lib/oauth/refresh-error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ const APP_CONFIGURATION_ERRORS = new Set<string>([
* Credential revocation codes only for the providers listed. Atlassian rejects a revoked or
* rotated-out refresh token with `unauthorized_client`; elsewhere that code usually describes the
* app registration, and treating it as terminal would send every credential of the provider to
* reauthorization over one configuration fault.
* reauthorization over one configuration fault. Instagram's Graph API rejects a refresh of a token
* its user logged out of, revoked, or let expire with `OAuthException` code `190`.
*/
const PROVIDER_CREDENTIAL_REVOCATION_ERRORS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
['confluence', new Set(['unauthorized_client'])],
['instagram', new Set(['190'])],
['jira', new Set(['unauthorized_client'])],
])

Expand Down
8 changes: 8 additions & 0 deletions apps/sim/lib/oauth/terminal-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ describe('isCredentialRevocationError', () => {
expect(isCredentialRevocationError('unauthorized_client', providerId)).toBe(false)
}
)

it.each([undefined, 'facebook', 'microsoft'])(
"treats Meta's code 190 as a revocation only for Instagram, not %s",
(providerId) => {
expect(isCredentialRevocationError('190', 'instagram')).toBe(true)
expect(isCredentialRevocationError('190', providerId)).toBe(false)
}
)
})

describe('markCredentialDead / getRecentTerminalError / clearDeadFlag', () => {
Expand Down
41 changes: 41 additions & 0 deletions apps/sim/lib/selectors/server/providers/http.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { createLogger } from '@sim/logger'
import { parseRetryAfter } from '@sim/utils/retry'
import {
SelectorConnectionUnavailableError,
SelectorOptionsUnavailableError,
} from '@/lib/selectors/server/errors'

const logger = createLogger('SelectorProviderHttp')
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved

const PROVIDER_TIMEOUT_MS = 30_000
const MAX_PROVIDER_RESPONSE_BYTES = 16 * 1024 * 1024

Expand Down Expand Up @@ -44,6 +47,35 @@ async function readBoundedBody(response: Response): Promise<string> {
return new TextDecoder().decode(body)
}

/**
* Host and path of a provider request. The query string is dropped and OData-style path
* arguments (`search(q='...')`) are blanked, since both can carry tokens or a user's search text.
*/
function providerEndpoint(input: RequestInfo | URL): string | undefined {
try {
const url = new URL(input instanceof Request ? input.url : input)
return `${url.host}${url.pathname.replace(/\([^/]*\)/g, '()')}`
} catch {
return undefined
}
}

/**
* Records why a provider call failed before the error is reduced to a bare status, the only
* place the upstream status and endpoint are still known.
*/
function logProviderFailure(
input: RequestInfo | URL,
reason: 'status' | 'network' | 'invalid_response',
status?: number
): void {
logger.warn('Selector provider request failed', {
endpoint: providerEndpoint(input),
reason,
...(status !== undefined ? { status } : {}),
})
}

export type ProviderJsonStatusResult<T> =
| { ok: true; status: number; data: T }
| { ok: false; status: number; retryAfterMs?: number }
Expand Down Expand Up @@ -86,6 +118,7 @@ export async function fetchProviderJsonWithStatus<T>(
response = await fetch(input, { ...init, signal, redirect: init?.redirect ?? 'error' })
} catch (error) {
if (init?.signal?.aborted) throw error
logProviderFailure(input, 'network')
if (options.passthroughNetworkErrors) throw new RetryableProviderNetworkError()
throw new SelectorOptionsUnavailableError()
}
Expand All @@ -103,6 +136,7 @@ export async function fetchProviderJsonWithStatus<T>(
...(retryAfterMs !== null && retryAfterMs > 0 ? { retryAfterMs } : {}),
}
}
logProviderFailure(input, 'status', response.status)
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
throw selectorProviderStatusError(response.status)
}
try {
Expand All @@ -113,6 +147,13 @@ export async function fetchProviderJsonWithStatus<T>(
}
} catch (error) {
if (init?.signal?.aborted) throw error
logProviderFailure(
input,
error instanceof SyntaxError || error instanceof SelectorOptionsUnavailableError
? 'invalid_response'
: 'network',
response.status
)
if (error instanceof SelectorOptionsUnavailableError) throw error
throw new SelectorOptionsUnavailableError()
}
Expand Down
31 changes: 31 additions & 0 deletions apps/sim/lib/table/application/rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,37 @@ describe('row query and upsert application semantics', () => {
expect(mockQueryRows).not.toHaveBeenCalled()
})

it('turns a read cancelled by its statement timeout into an actionable 400', async () => {
mockQueryRows.mockRejectedValueOnce(
Object.assign(new Error('canceling statement due to statement timeout'), { code: '57014' })
)

await expect(
queryTableRows.execute({
principal: PRINCIPAL,
input: { tableId: TABLE.id, limit: 100 },
})
).rejects.toMatchObject({
code: 'validation',
details: { code: 'TABLE_QUERY_TIMEOUT' },
message: expect.stringContaining('Sort by createdAt'),
})
})

it('keeps a caller-cancelled read as the raw cancellation, not a timeout', async () => {
const cancelled = Object.assign(new Error('canceling statement due to user request'), {
code: '57014',
})
mockQueryRows.mockRejectedValueOnce(cancelled)

await expect(
queryTableRows.execute({
principal: PRINCIPAL,
input: { tableId: TABLE.id, limit: 100 },
})
).rejects.toBe(cancelled)
})

it('resumes the same offset page under the identical predicate', async () => {
const cursor = encodeCursor({
lastRow: { id: 'row-100', orderKey: null },
Expand Down
27 changes: 25 additions & 2 deletions apps/sim/lib/table/application/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { isDeepStrictEqual } from 'node:util'
import { AuditAction, AuditResourceType } from '@sim/audit'
import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal'
import { db } from '@sim/db'
import { getRequestContext } from '@sim/logger'
import { createLogger, getRequestContext } from '@sim/logger'
import { getPostgresCancellationReason } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { isPlainRecord, toRecord } from '@sim/utils/object'
import {
Expand Down Expand Up @@ -77,6 +78,7 @@ import { columnTypeOf } from '@/lib/table/column-types'
import { TableQueryValidationError } from '@/lib/table/errors'
import { signalTableRowsChanged, signalTableRowsChangedByActor } from '@/lib/table/events'
import { CSV_MAX_BATCH_SIZE } from '@/lib/table/import'
import { READ_STATEMENT_TIMEOUT_MS } from '@/lib/table/planner'
import { isTablePredicate, predicateToFilter } from '@/lib/table/query-builder/converters'
import {
validatePredicate,
Expand All @@ -98,6 +100,8 @@ import { predicateToStorage, resolveFilterSelectValues } from '@/lib/table/selec
import { coerceRowValues } from '@/lib/table/validation'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'

const logger = createLogger('TableRowsApplication')

const ROW_CHANGE_COUNTERS = ['rowsInserted', 'rowsUpdated', 'rowsDeleted'] as const

/**
Expand Down Expand Up @@ -501,6 +505,25 @@ async function throwValidationResponse(
throw new TableRowsValidationError(body.error ?? 'Invalid row data', body.details)
}

/**
* A structured row query cancelled by its own `statement_timeout`: the query shape costs more than
* the table's size allows. Sorting or range-filtering on a stored column reads every row's data, so it is the
* caller's to narrow, and retrying the same query only times out again.
*/
function rethrowReadQueryError(error: unknown, tableId: string): never {
if (getPostgresCancellationReason(error) === 'statement_timeout') {
logger.warn('Table read hit its statement timeout', { tableId })
throw new TableRowsValidationError(
`The query took longer than ${READ_STATEMENT_TIMEOUT_MS / 1000} seconds and was stopped. ` +
'Sorting or range-filtering on a column reads every row in the table, which is too slow ' +
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
'at this table size. Sort by createdAt, add an equality filter, or keep ' +
'fewer rows (an Expiration column deletes old rows automatically).',
{ code: 'TABLE_QUERY_TIMEOUT' }
)
}
rethrowQueryValidation(error)
}

function rethrowQueryValidation(error: unknown): never {
if (error instanceof TableQueryValidationError) {
throw new TableRowsValidationError(error.message, error.code ? { code: error.code } : undefined)
Expand Down Expand Up @@ -680,7 +703,7 @@ export const queryTableRows = defineAuthorizedTableUseCase({
: {}),
}
} catch (error) {
rethrowQueryValidation(error)
rethrowReadQueryError(error, context.table.id)
}
},
})
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/table/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type TableQueryErrorCode =
| 'CURSOR_FILTER_CONFLICT'
| 'INVALID_FILTER'
| 'INVALID_ORDER'
| 'TABLE_QUERY_TIMEOUT'

/**
* Error thrown when caller-supplied filter or sort input is malformed.
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/table/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type DbTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0]
* small shared `web` pool — one abusive key can starve every tenant. `SET LOCAL`
* scopes both to the surrounding transaction, so they die when it commits.
*/
const READ_STATEMENT_TIMEOUT_MS = 15_000
export const READ_STATEMENT_TIMEOUT_MS = 15_000
const READ_LOCK_TIMEOUT_MS = 3_000

/**
Expand Down
10 changes: 9 additions & 1 deletion apps/sim/lib/workflows/custom-tools/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ const logger = createLogger('CustomToolsOperations')

export type CustomToolSortBy = 'title' | 'createdAt' | 'updatedAt'

/** A strict batch write found a title the workspace already holds; the caller's to fix. */
export class CustomToolTitleConflictError extends Error {
constructor(title: string) {
super(`A tool with the title "${title}" already exists in this workspace`)
this.name = 'CustomToolTitleConflictError'
}
}

interface CustomToolWriteParams {
tools: Array<{
id?: string
Expand Down Expand Up @@ -144,7 +152,7 @@ async function writeCustomTools(
.limit(1)

if (duplicateTitle.length > 0) {
throw new Error(`A tool with the title "${tool.title}" already exists in this workspace`)
throw new CustomToolTitleConflictError(tool.title)
}

await tx.insert(customTools).values(row)
Expand Down
1 change: 1 addition & 0 deletions packages/testing/src/mocks/table.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type MockTableQueryErrorCode =
| 'CURSOR_FILTER_CONFLICT'
| 'INVALID_FILTER'
| 'INVALID_ORDER'
| 'TABLE_QUERY_TIMEOUT'

interface MockColumnRef {
id?: string
Expand Down
Loading