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
20 changes: 20 additions & 0 deletions apps/sim/lib/selectors/server/providers/http.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getMockLogger } from '@sim/testing/mocks/logger.mock'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import {
SelectorConnectionUnavailableError,
Expand Down Expand Up @@ -96,4 +97,23 @@ describe('provider HTTP selector boundary', () => {
expect(cancel).toHaveBeenCalledOnce()
}
)

it('logs a failed provider call without its query string, search text, or email path segments', async () => {
mockFetch.mockResolvedValueOnce(new Response('{}', { status: 404 }))

await expect(
fetchProviderJson(
"https://www.googleapis.com/calendar/v3/calendars/jane%40example.com/root/search(q='payroll')?access_token=secret"
)
).rejects.toBeInstanceOf(SelectorOptionsUnavailableError)

expect(getMockLogger('SelectorProviderHttp').warn).toHaveBeenCalledWith(
'Selector provider request failed',
{
endpoint: 'www.googleapis.com/calendar/v3/calendars/{redacted}/root/search()',
reason: 'status',
status: 404,
}
)
})
})
10 changes: 7 additions & 3 deletions apps/sim/lib/selectors/server/providers/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,17 @@ async function readBoundedBody(response: Response): Promise<string> {
}

/**
* 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.
* Host and path of a provider request, with the parts that can carry user data removed: the query
* string (tokens, filters), OData-style path arguments (`search(q='...')` holds search text), and
* any segment holding an email address (a Google calendar id is usually its owner's address).
*/
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, '()')}`
const path = url.pathname
.replace(/\([^/]*\)/g, '()')
.replace(/[^/]*(?:@|%40)[^/]*/gi, '{redacted}')
return `${url.host}${path}`
} catch {
return undefined
}
Expand Down
7 changes: 4 additions & 3 deletions apps/sim/lib/table/application/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,9 +506,10 @@ async function throwValidationResponse(
}

/**
* 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.
* A structured row query cancelled by its own `statement_timeout`. Sorting or range-filtering on a
* stored column reads every candidate row's full data, so on a large table the query only finishes
* when that data happens to be cached. The fix is the caller's (narrow the query), so this is a 400
* with an actionable message rather than an opaque 500.
*/
function rethrowReadQueryError(error: unknown, tableId: string): never {
if (getPostgresCancellationReason(error) === 'statement_timeout') {
Expand Down
Loading