Skip to main content

Advanced

9 min read

Error Handling

Directive provides robust error handling for resolvers, constraints, effects, and derivations.


How Resilience Works

Directive has a two-layer error handling system. Resolver retry handles transient failures fast. The error boundary handles persistent failures after retries are exhausted.

  Resolver executes
      │ fails

  Resolver retry policy (attempts: 3, backoff: exponential)
      │ all attempts exhausted

  Error boundary receives the error

      ├─ "skip"        → swallow, move on
      ├─ "retry"       → immediate single re-attempt (same cycle)
      ├─ "retry-later" → schedule deferred retry (exponential backoff)
      ├─ "disable"     → disable the constraint that produced this requirement
      └─ "throw"       → re-throw, crash current reconciliation

Configure fast retries on the resolver. Configure what happens after they fail on the error boundary.

When to Use What

LayerPurposeSpeedScope
resolver.retryTransient failures (network blips, rate limits)Fast – immediate retries with backoffPer-resolver
errorBoundary: "retry"One more attempt after all retries failImmediate – same reconciliation cycleSystem-wide
errorBoundary: "retry-later"Service degradation, longer outagesSlow – deferred with exponential backoffSystem-wide
Circuit BreakerCascading failure preventionInstant rejection when openPer-service

Full Example

const system = createSystem({
  module: myModule,

  errorBoundary: {
    onResolverError: (error, resolver) => {
      // At this point, the resolver's own retry policy is already exhausted.
      if (error.message.includes("rate limit")) {
        return "retry-later"; // Back off and try again later
      }
      if (error.message.includes("not found")) {
        return "disable"; // Stop trying – the resource doesn't exist
      }

      return "skip"; // Swallow and continue
    },

    retryLater: {
      delayMs: 2000,
      maxRetries: 5,
      backoffMultiplier: 2,
    },

    onError: (error) => {
      errorReporter.capture(error);
    },
  },
});

Resolver Retry Policy

Configure automatic retries directly on the resolver for transient failures:

resolvers: {
  fetchData: {
    requirement: "FETCH_DATA",

    // Automatically retry on transient failures
    retry: {
      attempts: 3,
      backoff: "exponential",  // "none" | "linear" | "exponential"
      initialDelay: 100,       // ms before first retry
      maxDelay: 5000,          // maximum delay between retries
    },

    // The resolver itself stays simple – retry logic is handled externally
    resolve: async (req, context) => {
      context.facts.data = await api.getData(req.id);
    },
  },
}
OptionTypeDefaultDescription
attemptsnumber1Maximum retry attempts
backoff"none" | "linear" | "exponential""none"Backoff strategy
initialDelaynumber100Delay before first retry (ms)
maxDelaynumber30000Maximum delay between retries (ms)
shouldRetry(error, attempt) => booleanPredicate to control whether to retry

Use shouldRetry to skip retries for non-transient errors:

retry: {
  attempts: 5,
  backoff: "exponential",

  // Only retry errors that are likely transient
  shouldRetry: (error, attempt) => {
    // 404 (not found) and 401 (unauthorized) won't resolve with retries
    if (error.message.includes("404") || error.message.includes("401")) {
      return false;
    }
    return true;
  },
},

Timeout

Set a timeout for resolver execution:

resolvers: {
  fetchData: {
    requirement: "FETCH_DATA",
    timeout: 5000, // Abort the resolver if it takes longer than 5 seconds

    resolve: async (req, context) => {
      context.facts.data = await slowApi.getData(req.id);
    },
  },
}

When a resolver exceeds its timeout, it is aborted via the context.signal (an AbortSignal). Use it to cancel in-flight requests:

resolve: async (req, context) => {
  // Pass the abort signal to fetch so the request cancels on timeout
  const res = await fetch(`/api/data/${encodeURIComponent(req.id)}`, {
    signal: context.signal,
  });

  if (!res.ok) {
    throw new Error(`Request failed: ${res.status}`);
  }

  context.facts.data = await res.json();
},

Error Boundary

The error boundary catches errors after resolver retries are exhausted. Configure system-level error handling with errorBoundary:

const system = createSystem({
  module: myModule,

  // Define a recovery strategy for each error source
  errorBoundary: {
    onConstraintError: "skip",     // Ignore failing constraints
    onResolverError: "retry",      // Retry failed resolvers immediately
    onEffectError: "skip",         // Don't let side-effect errors crash the system
    onDerivationError: "skip",     // Keep the previous derived value on error

    // Global handler – called for every error regardless of source
    onError: (error) => {
      console.error(`[${error.source}] ${error.sourceId}:`, error.message);
      errorReporter.capture(error);
    },
  },
});
                    ┌──────────────┐
                    │ Error Occurs │
                    └──────┬───────┘
               ┌───────────┼───────────┐
               ▼           ▼           ▼
          ┌────────┐  ┌────────┐  ┌──────────┐
          │  retry │  │  skip  │  │ escalate │
          └───┬────┘  └───┬────┘  └────┬─────┘
              ▼           ▼            ▼
         Re-execute   Mark Skipped   System Error

Recovery Strategies

StrategyBehavior
"skip"Ignore the error and continue. The system proceeds as if nothing happened.
"retry"Retry the operation immediately. For effects, forces a re-run on the next reconcile. For derivations, marks them stale so they recompute on the next read.
"retry-later"Schedule a retry with exponential backoff. Requires retryLater configuration. The engine polls for due retries and triggers reconciliation.
"disable"Permanently disable the failing source. Disables the constraint (for constraint/resolver errors) or effect (for effect errors). The source stays disabled until manually re-enabled.
"throw"Re-throw the error. This stops the current reconciliation cycle.

Retry-Later Configuration

When using "retry-later", configure the backoff behavior. This is separate from resolver retry – it fires after all resolver-level retries are exhausted:

errorBoundary: {
  onResolverError: "retry-later",
  retryLater: {
    delayMs: 1000,        // Initial delay before first retry (default: 1000)
    maxRetries: 3,        // Give up after this many attempts (default: 3)
    backoffMultiplier: 2, // Multiply delay by this on each retry (default: 2)
    maxDelayMs: 30000,    // Cap the delay at this value (default: 30000)
  },
},

The engine starts a polling timer when retryLater is configured. When a due retry fires, it triggers a reconciliation cycle so constraints re-evaluate and resolvers re-execute. Retry attempts are cleared automatically when a resolver succeeds.

Disable Strategy

Use "disable" to permanently turn off failing constraints or effects:

errorBoundary: {
  onConstraintError: "disable", // Permanently disable failing constraints
  onEffectError: "disable",     // Permanently disable failing effects
},

For resolver errors, "disable" disables the constraint that produced the requirement. You can re-enable disabled constraints/effects programmatically:

system.constraints.enable("myConstraint");
system.effects.enable("myEffect");

Callback with Strategy Return

Pass a callback that returns a strategy string for dynamic error handling:

errorBoundary: {
  onResolverError: (error, resolver) => {
    if (error.message.includes("rate limit")) {
      return "retry-later";
    }
    if (error.message.includes("not found")) {
      return "disable";
    }

    return "skip";
  },
},

If the callback returns void (no return value), the strategy defaults to "skip".

See Also


Circuit Breaker

For operations that call external services, use a circuit breaker to automatically stop sending requests to a failing service and recover gracefully.

The circuit breaker is a standalone utility – wrap calls inside your resolver's resolve() function. It complements retry policies: retries handle transient failures within a single operation, while the circuit breaker prevents repeated attempts against a service that is consistently failing.

import { createCircuitBreaker } from '@directive-run/core/plugins';

const apiBreaker = createCircuitBreaker({
  name: 'external-api',
  failureThreshold: 5,
  recoveryTimeMs: 30000,
});

resolvers: {
  fetchData: {
    requirement: "FETCH_DATA",
    retry: { attempts: 3, backoff: "exponential" },
    resolve: async (req, context) => {
      // Circuit breaker wraps the call inside the resolver
      const data = await apiBreaker.execute(() => api.getData(req.id));
      context.facts.data = data;
    },
  },
}

See Circuit Breaker for full configuration and the 3-state pattern.


Batch Error Handling

When using batched resolvers, error handling depends on your failure strategy.

All-or-Nothing (default)

With resolveBatch, if the handler throws, all requirements in the batch fail. Retry policies apply to the entire batch:

resolvers: {
  fetchUsers: {
    requirement: "FETCH_USER",
    batch: { enabled: true, windowMs: 50 },
    retry: { attempts: 3, backoff: "exponential" },

    // If this throws, all 3 requirements fail together
    resolveBatch: async (reqs, context) => {
      const ids = reqs.map(r => r.userId);
      const users = await api.getUsersBatch(ids);
      users.forEach(user => { context.facts[`user_${user.id}`] = user; });
    },
  },
}

Per-Item Results

With resolveBatchWithResults, each item reports success or failure independently:

resolvers: {
  fetchUsers: {
    requirement: "FETCH_USER",
    batch: { enabled: true, windowMs: 50 },

    resolveBatchWithResults: async (reqs, context) => {
      return Promise.all(reqs.map(async (req) => {
        try {
          const user = await api.getUser(req.userId);
          context.facts[`user_${user.id}`] = user;

          return { success: true };
        } catch (error) {
          return { success: false, error };
        }
      }));
    },
  },
}

Batch with resolve() Fallback

You can enable batching with just resolve() (no resolveBatch). The system falls back to calling resolve() individually for each batched requirement. This gives you batching benefits (windowing, dedup) without writing a bulk handler:

resolvers: {
  fetchUser: {
    requirement: "FETCH_USER",
    batch: { enabled: true, windowMs: 50 },

    // Individual resolve – called once per batched requirement
    resolve: async (req, context) => {
      context.facts.user = await api.getUser(req.userId);
    },
  },
}

Upgrade to resolveBatch when you need true bulk operations (e.g., a single SQL WHERE id IN (...) query).


Error Recovery Patterns

Rollback Pattern

resolvers: {
  updateUser: {
    requirement: "UPDATE_USER",
    resolve: async (req, context) => {
      // Save original state before making changes
      const original = context.facts.user;

      // Apply the update optimistically so the UI feels instant
      context.facts.user = { ...original, ...req.updates };

      try {
        // Replace with the server-confirmed version
        context.facts.user = await api.updateUser(req.userId, req.updates);
      } catch (error) {
        // Rollback to the original state on failure
        context.facts.user = original;
        context.facts.error = error.message;
      }
    },
  },
}

Fallback Pattern

resolvers: {
  fetchData: {
    requirement: "FETCH_DATA",
    resolve: async (req, context) => {
      try {
        // Try the primary data source first
        context.facts.data = await api.getData(req.id);
      } catch (error) {
        // Primary failed – fall back to cached data
        try {
          context.facts.data = await cache.getData(req.id);
          context.facts.isStale = true; // Let the UI know this data may be outdated
        } catch (cacheError) {
          // Both sources failed – surface the error to the user
          context.facts.error = "Data unavailable";
        }
      }
    },
  },
}

React Error Boundaries

Combine Directive's error handling with React error boundaries. Pass the system directly – no provider needed:

import { ErrorBoundary } from 'react-error-boundary';

// A fallback UI shown when a React render error occurs
function ErrorFallback({ error, resetErrorBoundary, system }) {
  const handleRetry = () => {
    // Clear the Directive error state, then reset the React boundary
    system.facts.error = null;
    resetErrorBoundary();
  };

  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre>{error.message}</pre>
      <button onClick={handleRetry}>Try again</button>
    </div>
  );
}

// Wrap your app so render errors show the fallback instead of a blank screen
function App({ system }) {
  return (
    <ErrorBoundary FallbackComponent={(props) => <ErrorFallback {...props} system={system} />}>
      <MyComponent system={system} />
    </ErrorBoundary>
  );
}

Error States in Derivations

Use derivations to expose error state to the UI:

derive: {
  // True when an error is present – use to toggle error UI
  hasError: (facts) => facts.error !== null,

  // True when the user can still retry (under the retry limit)
  canRetry: (facts) => facts.error !== null && facts.retryCount < 3,
}

Monitoring with Plugins

Use the plugin system to report errors to monitoring services:

// A plugin that forwards all errors to Sentry for monitoring
const errorMonitor: Plugin = {
  name: 'error-monitor',

  // Fired when a specific resolver fails
  onResolverError: (resolver, req, error) => {
    Sentry.captureException(error, {
      extra: { resolver, requirement: req },
    });
  },

  // Fired for any error across the system
  onError: (error) => {
    Sentry.captureException(error, {
      extra: { source: error.source, sourceId: error.sourceId },
    });
  },
};

// Register the plugin to start capturing errors
const system = createSystem({
  module: myModule,
  plugins: [errorMonitor],
});

Next Steps

Previous
SSR & Hydration

Stay in the loop. Sign up for our newsletter.

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works

Directive - Constraint-Driven Runtime for TypeScript | AI Guardrails & State Management