Skip to content

Commit 332223b

Browse files
refactor(errors): move describeError to @sim/utils/errors
`describeError` is a general-purpose error/cause-chain helper — it didn't belong in `lib/core/errors/retryable-infrastructure.ts` (that module is specifically about classifying retryable infra errors, and the name read wrong for a generic diagnostic). Moved it to `@sim/utils/errors` alongside `toError`/ `getErrorMessage`/`getPostgresErrorCode`, with its own cycle-safe cause walk. - Added describeError + DescribedError + tests to packages/utils/src/errors.ts. - Reverted the describeError addition from retryable-infrastructure.ts (it keeps only isRetryableInfrastructureError / describeRetryableInfrastructureError, which are accurately named and still used by the schedule retry path). - Re-pointed all consumers (cell, logging-session, pause-persistence, schedule) to import describeError from @sim/utils/errors. The `retryable` classification flag still sources from isRetryableInfrastructureError where used. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6dfbea8 commit 332223b

8 files changed

Lines changed: 113 additions & 128 deletions

File tree

apps/sim/background/schedule-execution.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
workflowSchedule,
88
} from '@sim/db'
99
import { createLogger, runWithRequestContext } from '@sim/logger'
10-
import { toError } from '@sim/utils/errors'
10+
import { describeError, toError } from '@sim/utils/errors'
1111
import { generateId } from '@sim/utils/id'
1212
import { backoffWithJitter } from '@sim/utils/retry'
1313
import { task } from '@trigger.dev/sdk'
@@ -16,7 +16,6 @@ import { and, eq, isNull, type SQL, sql } from 'drizzle-orm'
1616
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
1717
import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types'
1818
import {
19-
describeError,
2019
describeRetryableInfrastructureError,
2120
isRetryableInfrastructureError,
2221
} from '@/lib/core/errors/retryable-infrastructure'

apps/sim/background/workflow-column-execution.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,13 @@
11
import { db } from '@sim/db'
22
import { workflow as workflowTable } from '@sim/db/schema'
33
import { createLogger, runWithRequestContext } from '@sim/logger'
4-
import { toError } from '@sim/utils/errors'
4+
import { describeError, toError } from '@sim/utils/errors'
55
import { sleep } from '@sim/utils/helpers'
66
import { generateId } from '@sim/utils/id'
77
import { backoffWithJitter } from '@sim/utils/retry'
88
import { task } from '@trigger.dev/sdk'
99
import { eq } from 'drizzle-orm'
10-
import {
11-
describeError,
12-
isRetryableInfrastructureError,
13-
} from '@/lib/core/errors/retryable-infrastructure'
10+
import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
1411
import { createTimeoutAbortController } from '@/lib/core/execution-limits'
1512
import { RateLimiter } from '@/lib/core/rate-limiter/rate-limiter'
1613
import { preprocessExecution } from '@/lib/execution/preprocessing'

apps/sim/lib/core/errors/retryable-infrastructure.test.ts

Lines changed: 0 additions & 64 deletions
This file was deleted.

apps/sim/lib/core/errors/retryable-infrastructure.ts

Lines changed: 0 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { toError } from '@sim/utils/errors'
2-
31
const RETRYABLE_DB_ERROR_CODES = new Set([
42
'08000',
53
'08001',
@@ -78,47 +76,3 @@ export function describeRetryableInfrastructureError(
7876
export function isRetryableInfrastructureError(error: unknown): boolean {
7977
return Boolean(describeRetryableInfrastructureError(error))
8078
}
81-
82-
export interface DescribedError {
83-
name: string
84-
message: string
85-
code?: string
86-
errno?: string
87-
syscall?: string
88-
/** `"Name: message"` per link in the `.cause` chain, outermost first. Present only when the chain has more than one link. */
89-
causeChain?: string[]
90-
}
91-
92-
/**
93-
* Always-on diagnostic view of an error and its `.cause` chain.
94-
*
95-
* Unlike {@link describeRetryableInfrastructureError} — which returns
96-
* `undefined` for errors outside its retryable allowlist — this returns the
97-
* underlying cause for ANY error, including `AbortError` and otherwise
98-
* unclassified causes. Reports the fields of the DEEPEST `.cause` link, because
99-
* a wrapped driver error (e.g. Drizzle's `"Failed query: ..."` wrapping an
100-
* `ECONNRESET`) carries the real reason there, not on the outer wrapper.
101-
*
102-
* `@sim/logger` does not serialize the non-enumerable `Error.prototype.cause`,
103-
* so callers must pass the result as an explicit structured log field rather
104-
* than relying on the logger to expand a raw error.
105-
*/
106-
export function describeError(error: unknown): DescribedError {
107-
const chain = getErrorChain(error)
108-
if (chain.length === 0) {
109-
const normalized = toError(error)
110-
return { name: normalized.name, message: normalized.message }
111-
}
112-
const deepest = chain[chain.length - 1]
113-
const code = typeof deepest.code === 'string' ? deepest.code : undefined
114-
const errno = typeof deepest.errno === 'string' ? deepest.errno : undefined
115-
const syscall = typeof deepest.syscall === 'string' ? deepest.syscall : undefined
116-
return {
117-
name: deepest.name,
118-
message: deepest.message,
119-
...(code ? { code } : {}),
120-
...(errno ? { errno } : {}),
121-
...(syscall ? { syscall } : {}),
122-
...(chain.length > 1 ? { causeChain: chain.map((e) => `${e.name}: ${e.message}`) } : {}),
123-
}
124-
}

apps/sim/lib/logs/execution/logging-session.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import { db } from '@sim/db'
22
import { workflowExecutionLogs } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
4-
import { toError } from '@sim/utils/errors'
4+
import { describeError, toError } from '@sim/utils/errors'
55
import { and, eq, sql } from 'drizzle-orm'
6-
import {
7-
describeError,
8-
isRetryableInfrastructureError,
9-
} from '@/lib/core/errors/retryable-infrastructure'
6+
import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
107
import { executionLogger } from '@/lib/logs/execution/logger'
118
import {
129
calculateCostSummary,

apps/sim/lib/workflows/executor/pause-persistence.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import { createLogger } from '@sim/logger'
2-
import { toError } from '@sim/utils/errors'
3-
import {
4-
describeError,
5-
isRetryableInfrastructureError,
6-
} from '@/lib/core/errors/retryable-infrastructure'
2+
import { describeError, toError } from '@sim/utils/errors'
3+
import { isRetryableInfrastructureError } from '@/lib/core/errors/retryable-infrastructure'
74
import type { LoggingSession } from '@/lib/logs/execution/logging-session'
85
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
96
import type { ExecutionResult } from '@/executor/types'

packages/utils/src/errors.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { getPostgresErrorCode, toError } from './errors.js'
5+
import { describeError, getPostgresErrorCode, toError } from './errors.js'
66

77
describe('toError', () => {
88
it('returns the same Error when given an Error', () => {
@@ -76,3 +76,54 @@ describe('getPostgresErrorCode', () => {
7676
expect(getPostgresErrorCode(err1)).toBeUndefined()
7777
})
7878
})
79+
80+
describe('describeError', () => {
81+
it('reports name and message for a plain error, omitting causeChain', () => {
82+
const described = describeError(new Error('boom'))
83+
expect(described).toEqual({ name: 'Error', message: 'boom' })
84+
expect(described.causeChain).toBeUndefined()
85+
})
86+
87+
it('surfaces the deepest cause for a wrapped driver error', () => {
88+
const driver = Object.assign(new Error('read ECONNRESET'), {
89+
code: 'ECONNRESET',
90+
errno: 'ECONNRESET',
91+
syscall: 'read',
92+
})
93+
const wrapped = new Error('Failed query: select ...', { cause: driver })
94+
const described = describeError(wrapped)
95+
expect(described.message).toBe('read ECONNRESET')
96+
expect(described.code).toBe('ECONNRESET')
97+
expect(described.errno).toBe('ECONNRESET')
98+
expect(described.syscall).toBe('read')
99+
expect(described.causeChain).toEqual([
100+
'Error: Failed query: select ...',
101+
'Error: read ECONNRESET',
102+
])
103+
})
104+
105+
it('always returns the cause for unclassified errors (AbortError)', () => {
106+
const aborted = Object.assign(new Error('The operation was aborted'), { name: 'AbortError' })
107+
expect(describeError(aborted)).toEqual({
108+
name: 'AbortError',
109+
message: 'The operation was aborted',
110+
})
111+
})
112+
113+
it('falls back to a populated description for non-Error input without throwing', () => {
114+
expect(describeError('just a string')).toEqual({ name: 'Error', message: 'just a string' })
115+
expect(() => describeError({ weird: true })).not.toThrow()
116+
})
117+
118+
it('stops at depth 10 and does not loop on a cyclic cause', () => {
119+
const a = new Error('a')
120+
const b = new Error('b')
121+
;(a as { cause?: unknown }).cause = b
122+
;(b as { cause?: unknown }).cause = a
123+
let described: ReturnType<typeof describeError> | undefined
124+
expect(() => {
125+
described = describeError(a)
126+
}).not.toThrow()
127+
expect(described?.causeChain?.length).toBeLessThanOrEqual(10)
128+
})
129+
})

packages/utils/src/errors.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,60 @@ export function getPostgresConstraintName(error: unknown): string | undefined {
3939
return readPgErrorField(error, 'constraint_name') ?? readPgErrorField(error, 'constraint')
4040
}
4141

42+
export interface DescribedError {
43+
name: string
44+
message: string
45+
code?: string
46+
errno?: string
47+
syscall?: string
48+
/** `"Name: message"` per link in the `.cause` chain, outermost first. Present only when the chain has more than one link. */
49+
causeChain?: string[]
50+
}
51+
52+
/**
53+
* Always-on diagnostic view of an error and its `.cause` chain.
54+
*
55+
* Reports the fields of the DEEPEST `.cause` link, because a wrapped driver
56+
* error (e.g. Drizzle's `"Failed query: ..."` wrapping an `ECONNRESET`) carries
57+
* the real reason there, not on the outer wrapper. Always returns a populated
58+
* object — including for non-`Error` throws and unclassified errors like
59+
* `AbortError`. Cycle-safe and depth-bounded.
60+
*
61+
* Loggers do not serialize the non-enumerable `Error.prototype.cause`, so pass
62+
* the result as an explicit structured field rather than the raw error.
63+
*/
64+
export function describeError(error: unknown): DescribedError {
65+
const chain: Error[] = []
66+
const seen = new Set<unknown>()
67+
let current: unknown = error
68+
while (current instanceof Error && !seen.has(current) && chain.length < 10) {
69+
seen.add(current)
70+
chain.push(current)
71+
current = current.cause
72+
}
73+
74+
if (chain.length === 0) {
75+
const normalized = toError(error)
76+
return { name: normalized.name, message: normalized.message }
77+
}
78+
79+
const deepest = chain[chain.length - 1] as Error & Record<string, unknown>
80+
const asString = (value: unknown): string | undefined =>
81+
typeof value === 'string' ? value : undefined
82+
const code = asString(deepest.code)
83+
const errno = asString(deepest.errno)
84+
const syscall = asString(deepest.syscall)
85+
86+
return {
87+
name: deepest.name,
88+
message: deepest.message,
89+
...(code ? { code } : {}),
90+
...(errno ? { errno } : {}),
91+
...(syscall ? { syscall } : {}),
92+
...(chain.length > 1 ? { causeChain: chain.map((e) => `${e.name}: ${e.message}`) } : {}),
93+
}
94+
}
95+
4296
function readPgErrorField(error: unknown, field: string): string | undefined {
4397
const seen = new Set<unknown>()
4498
let current: unknown = error

0 commit comments

Comments
 (0)