Skip to content

Improve Bond home-connector network failure handling#277

Merged
kentcdodds merged 2 commits into
mainfrom
cursor/home-connector-bond-failures-4dc4
Apr 27, 2026
Merged

Improve Bond home-connector network failure handling#277
kentcdodds merged 2 commits into
mainfrom
cursor/home-connector-bond-failures-4dc4

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Summary

  • wrap Bond bridge requests so .local/network fetch failures surface actionable bridge context instead of a generic TypeError: fetch failed
  • retry once against the discovered Bond IPv4 address when the stored host fails with a DNS/network-style fetch error
  • keep non-network Bond API failures unwrapped so HTTP/auth errors are not mislabeled as mDNS reachability problems
  • add focused tests for the fallback path, untried URL reporting, non-network error handling, and merged Sentry capture context

Likely root cause

The repeated production failures most likely come from the home-connector storing a Bond bridge hostname that ends in .local and then trying to fetch Bond endpoints from inside a NAS/container that cannot resolve mDNS on the LAN. In that case Node fetch throws TypeError: fetch failed with an underlying getaddrinfo ENOTFOUND ...local, which matches the production/NAS logs.

Operator guidance

If Bond failures mention a .local host or ENOTFOUND, the immediate operator action is:

  1. confirm whether the container has mDNS/DNS visibility to the LAN
  2. if not, update the stored Bond bridge connection to a stable IP via bond_update_bridge_connection
  3. otherwise restore container/network mDNS reachability

This change makes those failures much more actionable in Sentry/logs, uses the last discovered IPv4 address as a safe fallback when it is available, and avoids pointing operators at mDNS when the real issue is an HTTP-level Bond response.

Testing

  • npm test -- --run src/adapters/bond/index.node.test.ts src/sentry.node.test.ts
  • npm run typecheck

Artifacts

Open in Web Open in Cursor 

Summary by CodeRabbit

  • Bug Fixes

    • Bond bridge connectivity now automatically falls back to discovered IPs when mDNS (.local) resolution fails, and shows clearer, actionable error messages for connection failures.
    • Improved error reporting to preserve HTTP/status details when non-network errors occur.
  • Improvements

    • Enhanced error capture merging so diagnostic context (tags, contexts, extra) is combined and includes service identification.
  • Tests

    • Added tests covering Bond adapter connectivity/fallback and improved error capture behavior.

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
@coderabbitai

coderabbitai Bot commented Apr 27, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a Bond request wrapper that retries between stored .local host and discovered IP addresses on network/DNS failures, surfaces detailed BondRequestError with capture context, refactors Bond adapter calls to use the wrapper, and enhances Sentry error capture to extract and merge error-attached context.

Changes

Cohort / File(s) Summary
Bond Adapter
packages/home-connector/src/adapters/bond/index.ts
Adds withBondBridgeRequest to build prioritized base URLs (stored host then discovered IP), detect network/DNS failures, retry with discovered addresses, construct BondRequestError with homeConnectorCaptureContext, and refactors all Bond API calls to use this wrapper.
Bond Adapter Tests
packages/home-connector/src/adapters/bond/index.node.test.ts
New Vitest suite testing .local resolution failure => IP fallback, failure without fallback yields informative Error, and non-network HTTP failure preserves status/body and does not trigger fallback.
Sentry Error Capture
packages/home-connector/src/sentry.ts
Adds HomeConnectorErrorCaptureContext type, getHomeConnectorErrorCaptureContext(error) extractor, deep-merge behavior for contexts, merging for extra, and ensures tags.service = 'home-connector' when calling captureHomeConnectorException.
Sentry Tests
packages/home-connector/src/sentry.node.test.ts
Expands Sentry mock to track captures, adds tests for cloning of capture contexts and for captureHomeConnectorException calling captureException with merged context when Sentry enabled.

Sequence Diagram

sequenceDiagram
    participant Client
    participant BondAdapter as Bond Adapter
    participant Retry as withBondBridgeRequest
    participant Local as Stored .local Host
    participant IP as Discovered IP
    participant Sentry

    Client->>BondAdapter: request (e.g., getDeviceState)
    BondAdapter->>Retry: execute request (prioritized URLs)
    Retry->>Local: attempt request to stored .local URL
    alt Network/DNS failure
        Local-->>Retry: network/DNS error
        Retry->>IP: retry with discovered IP URL
        alt IP succeeds
            IP-->>Retry: success response
            Retry-->>BondAdapter: return result
            BondAdapter-->>Client: success
        else IP fails
            IP-->>Retry: failure
            Retry-->>BondAdapter: throw BondRequestError (with homeConnectorCaptureContext)
            BondAdapter->>Sentry: captureHomeConnectorException(error, context)
            Sentry-->>BondAdapter: recorded
            BondAdapter-->>Client: error
        end
    else Non-network HTTP error
        Local-->>Retry: HTTP error (e.g., 401)
        Retry-->>BondAdapter: throw BondRequestError (preserve status/body)
        BondAdapter->>Sentry: captureHomeConnectorException(error, context)
        BondAdapter-->>Client: error
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I hopped from .local to IP bright,
retried beneath the moonlit night.
When DNS hides and errors peep,
Sentry notes the secrets I keep.
Bridges mended, logs in sight—hop!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Improve Bond home-connector network failure handling' directly and specifically describes the main change: enhanced error handling for Bond adapter network failures with fallback and context.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/home-connector-bond-failures-4dc4

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@kentcdodds kentcdodds marked this pull request as ready for review April 27, 2026 22:16
@github-actions

github-actions Bot commented Apr 27, 2026

Copy link
Copy Markdown

🔎 Preview deployed: https://kody-pr-277.kentcdodds.workers.dev

Worker: kody-pr-277
D1: kody-pr-277-db
KV: kody-pr-277-oauth-kv

Mocks:

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (7)
packages/home-connector/src/adapters/bond/index.ts (2)

197-235: Minor: compute formatBondFailureReason(input.error) once.

It's evaluated twice — once for the user-facing errorMessage (line 207) and again for extra.bondFailureReason (line 231). They must produce identical output anyway, so caching avoids re-walking the cause chain and removes any risk of drift if the formatter is later changed.

♻️ Tiny tweak
 	const connection = getBondBridgeConnectionContext(input.bridge)
 	const guidance = connection.host.endsWith('.local')
 		? ' The stored Bond host ends in .local, ...'
 		: ' Verify the bridge host/IP is still reachable ...'
-	const errorMessage = `Bond bridge "${input.bridge.bridgeId}" could not be reached while trying to ${input.operation} at ${input.baseUrlsTried.join(', ')}. ${formatBondFailureReason(input.error)}.${guidance}`
+	const failureReason = formatBondFailureReason(input.error)
+	const errorMessage = `Bond bridge "${input.bridge.bridgeId}" could not be reached while trying to ${input.operation} at ${input.baseUrlsTried.join(', ')}. ${failureReason}.${guidance}`
@@
 		extra: {
 			bondOperation: input.operation,
 			bondBaseUrlsTried: input.baseUrlsTried,
-			bondFailureReason: formatBondFailureReason(input.error),
+			bondFailureReason: failureReason,
 		},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/home-connector/src/adapters/bond/index.ts` around lines 197 - 235,
In createBondActionableError, compute formatBondFailureReason(input.error) once
into a local const (e.g. bondFailureReason) and reuse that variable when
building errorMessage and when setting extra.bondFailureReason so the formatter
is only invoked once and both places use the identical string; update references
to formatBondFailureReason(input.error) in the errorMessage construction and in
wrappedError.homeConnectorCaptureContext.extra.bondFailureReason to use the new
bondFailureReason variable.

148-195: Refactor: extract a single getCauseMessage(error) helper.

isBondNetworkFailure (163-171) and formatBondFailureReason (183-191) duplicate the same error.cause ladder (Error | string | object-with-message | other). The duplicated nested ternaries also have indentation that doesn't align with the parent branches (: '' and : null visually float), making the precedence harder to verify at a glance. Pulling the cause-extraction into one helper drops the duplication and clarifies intent.

♻️ Suggested helper
+function getErrorCauseMessage(error: Error): string | null {
+	const cause = error.cause
+	if (cause instanceof Error) return cause.message
+	if (typeof cause === 'string') return cause
+	if (cause && typeof cause === 'object') {
+		const message = (cause as { message?: unknown }).message
+		return message == null ? null : String(message)
+	}
+	return null
+}
+
 function isBondNetworkFailure(error: unknown) {
 	if (!(error instanceof Error)) {
 		return false
 	}
 	const message = error.message.toLowerCase()
 	if (
 		message.includes('fetch failed') ||
 		message.includes('enotfound') ||
 		message.includes('eai_again') ||
 		message.includes('econnrefused') ||
 		message.includes('ehostunreach') ||
 		message.includes('etimedout')
 	) {
 		return true
 	}
-	const causeMessage =
-		error.cause instanceof Error
-			? error.cause.message.toLowerCase()
-			: typeof error.cause === 'string'
-				? error.cause.toLowerCase()
-				: error.cause && typeof error.cause === 'object'
-					? String((error.cause as { message?: unknown }).message ?? '')
-							.toLowerCase()
-				: ''
+	const causeMessage = (getErrorCauseMessage(error) ?? '').toLowerCase()
 	return (
 		causeMessage.includes('enotfound') ||
 		causeMessage.includes('eai_again') ||
 		causeMessage.includes('econnrefused') ||
 		causeMessage.includes('ehostunreach') ||
 		causeMessage.includes('etimedout') ||
 		causeMessage.includes('getaddrinfo')
 	)
 }
 
 function formatBondFailureReason(error: unknown) {
 	if (error instanceof Error) {
-		const causeMessage =
-			error.cause instanceof Error
-				? error.cause.message
-				: typeof error.cause === 'string'
-					? error.cause
-					: error.cause && typeof error.cause === 'object'
-						? String((error.cause as { message?: unknown }).message ?? '')
-					: null
+		const causeMessage = getErrorCauseMessage(error)
 		return causeMessage ? `${error.message}; cause=${causeMessage}` : error.message
 	}
 	return String(error)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/home-connector/src/adapters/bond/index.ts` around lines 148 - 195,
Extract a single helper function getCauseMessage(error: unknown): string | null
that encapsulates the repeated logic for reading error.cause (handling Error,
string, object-with-message, and other cases) and return the cause message
lowercased where appropriate; then replace the duplicated ladders in
isBondNetworkFailure and formatBondFailureReason with calls to
getCauseMessage(error) (use the lowercased result in isBondNetworkFailure when
checking contains like 'enotfound'/'etimedout' and use the raw string/nullable
result in formatBondFailureReason to build the "message; cause=..." string).
Ensure getCauseMessage returns null when no meaningful cause exists and remove
the nested ternaries from isBondNetworkFailure and formatBondFailureReason so
both read the cause via the new helper.
packages/home-connector/src/sentry.node.test.ts (1)

176-178: Test isolation: isEnabled left as true after this test.

sentryMock.isEnabled.mockReturnValue(true) is set here but never restored. Existing tests above this one defensively reset isEnabled per test, so they're fine, but any future test appended below that forgets to reset will inherit true and silently exercise different code paths. Consider scoping with afterEach(() => sentryMock.isEnabled.mockReturnValue(false)) to keep the suite resilient.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/home-connector/src/sentry.node.test.ts` around lines 176 - 178, The
test captureHomeConnectorException sets
sentryMock.isEnabled.mockReturnValue(true) but does not restore it, risking test
leakage; update the suite to reset this mock after each test (e.g., add an
afterEach that calls sentryMock.isEnabled.mockReturnValue(false) or restores the
original mock implementation) so sentryMock.isEnabled is consistently false by
default and tests remain isolated.
packages/home-connector/src/sentry.ts (2)

53-73: Heads up: contexts is shallow-cloned only at the outer key level.

{ ...captureContext.contexts } clones the top-level keys, but each inner record (e.g. contexts.bond) is still shared by reference with the error. The current test only checks reference inequality at the top level, so it passes, but if any downstream code (today or later) mutates an inner context record after capture, the mutation will leak back to the original error. Consider a one-level-deeper clone for full isolation:

Optional defensive clone
-		...(captureContext.contexts
-			? { contexts: { ...captureContext.contexts } }
-			: {}),
+		...(captureContext.contexts
+			? {
+					contexts: Object.fromEntries(
+						Object.entries(captureContext.contexts).map(([key, value]) => [
+							key,
+							{ ...value },
+						]),
+					),
+				}
+			: {}),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/home-connector/src/sentry.ts` around lines 53 - 73,
getHomeConnectorErrorCaptureContext currently shallow-clones
captureContext.contexts only at the top level so inner context objects remain
shared with the original error; update getHomeConnectorErrorCaptureContext (and
the handling of HomeConnectorErrorWithCaptureContext.captureContext) to perform
a one-level-deeper defensive clone for contexts (i.e., for each key in
captureContext.contexts clone its value object) before returning, so mutations
to returned contexts won't mutate the original error; similarly consider doing
the same for captureContext.extra if it can contain nested objects.

144-149: Sentry's Contexts type allows undefined values per key — verify the cast is sound.

captureContext.contexts from Parameters<typeof Sentry.captureException>[1] is typed as Contexts (Record<string, Context | undefined> in @sentry/node). The cast to Record<string, Record<string, unknown>> | undefined drops that | undefined for entries. mergeContextRecords happens to tolerate undefined values via the ?? {} fallback inside the loop, but the type assertion is still slightly loose. Worth tightening the parameter type of mergeContextRecords to mirror Sentry’s shape, or spelling out the cast explicitly.

`@sentry/node` 10.45.0 Contexts type Record<string, Context | undefined>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/home-connector/src/sentry.ts` around lines 144 - 149, The cast of
captureContext.contexts to Record<string, Record<string, unknown>> | undefined
is too loose because Sentry's Contexts is Record<string, Context | undefined>;
update mergeContextRecords' parameter type to accept Record<string, Context |
undefined> | undefined (or a dedicated SentryContexts type) and change the call
site to pass captureContext.contexts without the unsafe cast; ensure
mergeContextRecords treats undefined values per key (it already uses ?? {}
inside the loop) so only the type signature needs tightening to mirror Sentry's
shape and remove the assertion.
packages/home-connector/src/adapters/bond/index.node.test.ts (2)

30-44: Nit: prefer the standard Error(..., { cause }) constructor form.

new TypeError('fetch failed', { cause: {...} }) is the idiomatic shape Node's undici uses when emitting this exact error, so the synthetic error matches production more faithfully (e.g., cause is non-enumerable in real undici failures). Object.defineProperty works, but the constructor option is shorter and slightly more realistic.

♻️ Suggested simplification
-function createDnsFetchError(message = 'getaddrinfo ENOTFOUND zpgi01117.local') {
-	const error = new TypeError('fetch failed')
-	Object.defineProperty(error, 'cause', {
-		value: {
-			code: 'ENOTFOUND',
-			errno: -3008,
-			syscall: 'getaddrinfo',
-			hostname: 'zpgi01117.local',
-			message,
-		},
-		enumerable: true,
-		configurable: true,
-	})
-	return error
-}
+function createDnsFetchError(message = 'getaddrinfo ENOTFOUND zpgi01117.local') {
+	return new TypeError('fetch failed', {
+		cause: {
+			code: 'ENOTFOUND',
+			errno: -3008,
+			syscall: 'getaddrinfo',
+			hostname: 'zpgi01117.local',
+			message,
+		},
+	})
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/home-connector/src/adapters/bond/index.node.test.ts` around lines 30
- 44, The test helper createDnsFetchError constructs a synthetic fetch error by
manually defining a cause property; instead use the standard Error constructor
form to match Node/undici shape by creating the error with new TypeError('fetch
failed', { cause: { code: 'ENOTFOUND', errno: -3008, syscall: 'getaddrinfo',
hostname: 'zpgi01117.local', message } }), so replace the Object.defineProperty
block inside createDnsFetchError with the constructor { cause } form to make the
error non-enumerable and more realistic.

155-160: Optional: assert both substrings against a single rejection.

Calling bond.getDeviceState('BONDTEST2', 'mockdev1') twice runs the full request pipeline (and the failing fetch) twice just to check two substrings of the same error. Capturing the rejection once is cheaper and removes the implicit assumption that a second invocation rejects identically.

♻️ Suggested consolidation
-		await expect(bond.getDeviceState('BONDTEST2', 'mockdev1')).rejects.toThrow(
-			'Bond bridge "BONDTEST2" could not be reached while trying to fetch device mockdev1 state at http://zpgi01117.local',
-		)
-		await expect(bond.getDeviceState('BONDTEST2', 'mockdev1')).rejects.toThrow(
-			'If this connector runs in a NAS/container without mDNS, update the bridge host to a stable IP',
-		)
+		const rejection = await bond
+			.getDeviceState('BONDTEST2', 'mockdev1')
+			.catch((error: unknown) => error)
+		expect(rejection).toBeInstanceOf(Error)
+		const message = (rejection as Error).message
+		expect(message).toContain(
+			'Bond bridge "BONDTEST2" could not be reached while trying to fetch device mockdev1 state at http://zpgi01117.local',
+		)
+		expect(message).toContain(
+			'If this connector runs in a NAS/container without mDNS, update the bridge host to a stable IP',
+		)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/home-connector/src/adapters/bond/index.node.test.ts` around lines
155 - 160, Instead of calling bond.getDeviceState twice, capture the single
promise and assert both substrings against that same rejection; e.g. create
const p = bond.getDeviceState('BONDTEST2', 'mockdev1') and then await
expect(p).rejects.toThrow('Bond bridge "BONDTEST2" could not be reached ...')
and await expect(p).rejects.toThrow('If this connector runs in a NAS/container
without mDNS, update the bridge host to a stable IP'), so the request pipeline
only runs once while still verifying both messages from the error thrown by
bond.getDeviceState.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/home-connector/src/adapters/bond/index.node.test.ts`:
- Around line 30-44: The test helper createDnsFetchError constructs a synthetic
fetch error by manually defining a cause property; instead use the standard
Error constructor form to match Node/undici shape by creating the error with new
TypeError('fetch failed', { cause: { code: 'ENOTFOUND', errno: -3008, syscall:
'getaddrinfo', hostname: 'zpgi01117.local', message } }), so replace the
Object.defineProperty block inside createDnsFetchError with the constructor {
cause } form to make the error non-enumerable and more realistic.
- Around line 155-160: Instead of calling bond.getDeviceState twice, capture the
single promise and assert both substrings against that same rejection; e.g.
create const p = bond.getDeviceState('BONDTEST2', 'mockdev1') and then await
expect(p).rejects.toThrow('Bond bridge "BONDTEST2" could not be reached ...')
and await expect(p).rejects.toThrow('If this connector runs in a NAS/container
without mDNS, update the bridge host to a stable IP'), so the request pipeline
only runs once while still verifying both messages from the error thrown by
bond.getDeviceState.

In `@packages/home-connector/src/adapters/bond/index.ts`:
- Around line 197-235: In createBondActionableError, compute
formatBondFailureReason(input.error) once into a local const (e.g.
bondFailureReason) and reuse that variable when building errorMessage and when
setting extra.bondFailureReason so the formatter is only invoked once and both
places use the identical string; update references to
formatBondFailureReason(input.error) in the errorMessage construction and in
wrappedError.homeConnectorCaptureContext.extra.bondFailureReason to use the new
bondFailureReason variable.
- Around line 148-195: Extract a single helper function getCauseMessage(error:
unknown): string | null that encapsulates the repeated logic for reading
error.cause (handling Error, string, object-with-message, and other cases) and
return the cause message lowercased where appropriate; then replace the
duplicated ladders in isBondNetworkFailure and formatBondFailureReason with
calls to getCauseMessage(error) (use the lowercased result in
isBondNetworkFailure when checking contains like 'enotfound'/'etimedout' and use
the raw string/nullable result in formatBondFailureReason to build the "message;
cause=..." string). Ensure getCauseMessage returns null when no meaningful cause
exists and remove the nested ternaries from isBondNetworkFailure and
formatBondFailureReason so both read the cause via the new helper.

In `@packages/home-connector/src/sentry.node.test.ts`:
- Around line 176-178: The test captureHomeConnectorException sets
sentryMock.isEnabled.mockReturnValue(true) but does not restore it, risking test
leakage; update the suite to reset this mock after each test (e.g., add an
afterEach that calls sentryMock.isEnabled.mockReturnValue(false) or restores the
original mock implementation) so sentryMock.isEnabled is consistently false by
default and tests remain isolated.

In `@packages/home-connector/src/sentry.ts`:
- Around line 53-73: getHomeConnectorErrorCaptureContext currently
shallow-clones captureContext.contexts only at the top level so inner context
objects remain shared with the original error; update
getHomeConnectorErrorCaptureContext (and the handling of
HomeConnectorErrorWithCaptureContext.captureContext) to perform a
one-level-deeper defensive clone for contexts (i.e., for each key in
captureContext.contexts clone its value object) before returning, so mutations
to returned contexts won't mutate the original error; similarly consider doing
the same for captureContext.extra if it can contain nested objects.
- Around line 144-149: The cast of captureContext.contexts to Record<string,
Record<string, unknown>> | undefined is too loose because Sentry's Contexts is
Record<string, Context | undefined>; update mergeContextRecords' parameter type
to accept Record<string, Context | undefined> | undefined (or a dedicated
SentryContexts type) and change the call site to pass captureContext.contexts
without the unsafe cast; ensure mergeContextRecords treats undefined values per
key (it already uses ?? {} inside the loop) so only the type signature needs
tightening to mirror Sentry's shape and remove the assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c690e49c-a8ea-4f8b-a685-320f98500963

📥 Commits

Reviewing files that changed from the base of the PR and between 613a0bd and e21d5d1.

📒 Files selected for processing (4)
  • packages/home-connector/src/adapters/bond/index.node.test.ts
  • packages/home-connector/src/adapters/bond/index.ts
  • packages/home-connector/src/sentry.node.test.ts
  • packages/home-connector/src/sentry.ts

Comment thread packages/home-connector/src/adapters/bond/index.ts
Comment thread packages/home-connector/src/adapters/bond/index.ts
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/home-connector/src/sentry.node.test.ts (1)

17-19: Prefer test-local cleanup over a suite-level afterEach.

This hook only undoes per-test mutation of sentryMock.isEnabled, so moving that reset into the tests that change it will satisfy the current lint rule and keep isolation explicit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/home-connector/src/sentry.node.test.ts` around lines 17 - 19, The
suite-level afterEach that resets sentryMock.isEnabled should be removed;
instead, in any test that mutates sentryMock.isEnabled (e.g., tests that call
sentryMock.isEnabled.mockReturnValue(true)), restore it to the desired default
within that test (use a local reset at the end of the test or a try/finally to
call sentryMock.isEnabled.mockReturnValue(false)). Remove the afterEach block
and add per-test cleanup inside each test that changes sentryMock.isEnabled to
keep isolation explicit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/home-connector/src/adapters/bond/index.ts`:
- Around line 241-271: withBondBridgeRequest currently retries all requests
(including non-idempotent writes like bondInvokeDeviceAction and
bondInvokeGroupAction) across multiple base URLs, which can double-actuate
devices; change the logic so it does not automatically retry writes: detect
whether the incoming operation is a write (e.g., by checking input.operation or
adding a new flag on the input such as isIdempotent/readOnly) and if it is not
read-only, do not attempt the fallback retry path—immediately rethrow
non-network errors and only consider retrying for safe, proven-local failures
(DNS/lookup errors) on read-only calls; update the call sites
(bondInvokeDeviceAction/bondInvokeGroupAction) to either pass a
readOnly/isIdempotent flag when safe to retry or leave it unset for writes so
withBondBridgeRequest will not retry them.
- Around line 97-120: getBridgeDiscoveredAddress currently returns the first
non-empty discovery string but must restrict fallbacks to actual IPv4 addresses
only; update getBridgeDiscoveredAddress to validate candidates (rawAddress,
mdnsAddress, and entries from mdnsAddresses) with an IPv4 check (e.g., a simple
regex or netmask parse) and ignore mdns hostnames or IPv6 strings so only true
IPv4 addresses are returned (otherwise return null); apply the same IPv4-only
validation to the other equivalent discovery branch referenced in the diff (the
mdns/addresses fallback) so both places use the same IPv4 validation logic.

---

Nitpick comments:
In `@packages/home-connector/src/sentry.node.test.ts`:
- Around line 17-19: The suite-level afterEach that resets sentryMock.isEnabled
should be removed; instead, in any test that mutates sentryMock.isEnabled (e.g.,
tests that call sentryMock.isEnabled.mockReturnValue(true)), restore it to the
desired default within that test (use a local reset at the end of the test or a
try/finally to call sentryMock.isEnabled.mockReturnValue(false)). Remove the
afterEach block and add per-test cleanup inside each test that changes
sentryMock.isEnabled to keep isolation explicit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d744e879-69cc-45f2-aae4-0927adf38219

📥 Commits

Reviewing files that changed from the base of the PR and between e21d5d1 and eee77ff.

📒 Files selected for processing (4)
  • packages/home-connector/src/adapters/bond/index.node.test.ts
  • packages/home-connector/src/adapters/bond/index.ts
  • packages/home-connector/src/sentry.node.test.ts
  • packages/home-connector/src/sentry.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/home-connector/src/adapters/bond/index.node.test.ts

Comment on lines +97 to +120
function getBridgeDiscoveredAddress(bridge: BondPersistedBridge) {
const rawAddress = bridge.rawDiscovery?.['address']
if (typeof rawAddress === 'string' && rawAddress.trim()) {
return rawAddress.trim()
}
const mdns = bridge.rawDiscovery?.['mdns']
if (mdns && typeof mdns === 'object' && !Array.isArray(mdns)) {
const mdnsRecord = mdns as Record<string, unknown>
const mdnsAddress = mdnsRecord['address']
if (typeof mdnsAddress === 'string' && mdnsAddress.trim()) {
return mdnsAddress.trim()
}
const mdnsAddresses = mdnsRecord['addresses']
if (Array.isArray(mdnsAddresses)) {
const firstAddress = mdnsAddresses.find(
(entry) => typeof entry === 'string' && entry.trim(),
)
if (typeof firstAddress === 'string') {
return firstAddress.trim()
}
}
}
return null
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restrict the fallback host to an actual IPv4 address.

The new helper returns the first non-empty discovery string, but this PR’s contract is “retry against the discovered IPv4 address.” If discovery surfaces an mDNS hostname or an IPv6 address first, the fallback can either duplicate the failing host or build an unusable base URL, so the .local recovery path still fails.

Also applies to: 136-143

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/home-connector/src/adapters/bond/index.ts` around lines 97 - 120,
getBridgeDiscoveredAddress currently returns the first non-empty discovery
string but must restrict fallbacks to actual IPv4 addresses only; update
getBridgeDiscoveredAddress to validate candidates (rawAddress, mdnsAddress, and
entries from mdnsAddresses) with an IPv4 check (e.g., a simple regex or netmask
parse) and ignore mdns hostnames or IPv6 strings so only true IPv4 addresses are
returned (otherwise return null); apply the same IPv4-only validation to the
other equivalent discovery branch referenced in the diff (the mdns/addresses
fallback) so both places use the same IPv4 validation logic.

Comment on lines +241 to +271
async function withBondBridgeRequest<T>(input: {
bridge: BondPersistedBridge
operation: string
request: (baseUrl: string) => Promise<T>
}) {
const baseUrls = createBondBaseUrlCandidates(input.bridge)
const attemptedBaseUrls: Array<string> = []
let lastError: unknown = null
for (let index = 0; index < baseUrls.length; index += 1) {
const baseUrl = baseUrls[index]!
attemptedBaseUrls.push(baseUrl)
try {
return await input.request(baseUrl)
} catch (error) {
lastError = error
if (!isBondNetworkFailure(error)) {
throw error
}
const canRetryWithFallback =
index === 0 && baseUrls.length > 1
if (!canRetryWithFallback) {
break
}
}
}
throw createBondActionableError({
bridge: input.bridge,
operation: input.operation,
error: lastError,
baseUrlsTried: attemptedBaseUrls,
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Do not automatically retry Bond action writes.

withBondBridgeRequest now wraps bondInvokeDeviceAction and bondInvokeGroupAction, so a timeout / generic transport failure can cause the same non-idempotent command to be sent twice—first via the stored host, then again via the fallback IP. That is a real double-actuation risk for shades and other devices.

Suggested direction
 async function withBondBridgeRequest<T>(input: {
 	bridge: BondPersistedBridge
 	operation: string
+	allowFallbackRetry?: boolean
 	request: (baseUrl: string) => Promise<T>
 }) {
 	const baseUrls = createBondBaseUrlCandidates(input.bridge)
 	const attemptedBaseUrls: Array<string> = []
 	let lastError: unknown = null
 	for (let index = 0; index < baseUrls.length; index += 1) {
 		const baseUrl = baseUrls[index]!
 		attemptedBaseUrls.push(baseUrl)
 		try {
 			return await input.request(baseUrl)
 		} catch (error) {
 			lastError = error
 			if (!isBondNetworkFailure(error)) {
 				throw error
 			}
-			const canRetryWithFallback =
-				index === 0 && baseUrls.length > 1
+			const canRetryWithFallback =
+				input.allowFallbackRetry === true &&
+				index === 0 &&
+				baseUrls.length > 1
 			if (!canRetryWithFallback) {
 				break
 			}
 		}
 	}

Then opt in only from read-only call sites, or narrow retries to failures that prove the request never left the process (for example DNS resolution errors).

Also applies to: 564-575, 700-711

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/home-connector/src/adapters/bond/index.ts` around lines 241 - 271,
withBondBridgeRequest currently retries all requests (including non-idempotent
writes like bondInvokeDeviceAction and bondInvokeGroupAction) across multiple
base URLs, which can double-actuate devices; change the logic so it does not
automatically retry writes: detect whether the incoming operation is a write
(e.g., by checking input.operation or adding a new flag on the input such as
isIdempotent/readOnly) and if it is not read-only, do not attempt the fallback
retry path—immediately rethrow non-network errors and only consider retrying for
safe, proven-local failures (DNS/lookup errors) on read-only calls; update the
call sites (bondInvokeDeviceAction/bondInvokeGroupAction) to either pass a
readOnly/isIdempotent flag when safe to retry or leave it unset for writes so
withBondBridgeRequest will not retry them.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit eee77ff. Configure here.

causeMessage.includes('etimedout') ||
causeMessage.includes('getaddrinfo')
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Network failure check doesn't distinguish TypeError from HTTP errors

Low Severity

isBondNetworkFailure checks error instanceof Error then matches substrings like 'fetch failed' in the message. Node.js fetch throws TypeError specifically for network errors, but bondRequestJson throws plain Error for HTTP-level failures (e.g., Bond HTTP 500 for /path: <body>). If a Bond API response body ever contains the phrase "fetch failed", the plain Error would be misclassified as a network failure, triggering an unwarranted retry against the fallback URL and wrapping the error with incorrect network-failure guidance and Sentry tags. The 'fetch failed' branch needs an error instanceof TypeError guard.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit eee77ff. Configure here.

@kentcdodds kentcdodds merged commit 2266d3c into main Apr 27, 2026
9 checks passed
@kentcdodds kentcdodds deleted the cursor/home-connector-bond-failures-4dc4 branch April 27, 2026 22:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants