Worker System Architecture

Crane Ledger uses a hybrid approach to API operations, combining immediate responses for simple operations with background processing for complex tasks. This guide explains when operations are processed synchronously vs asynchronously, and how to handle each type effectively.

Operation Types

Synchronous Operations (Direct Response)

Most read operations and simple writes return results immediately. These operations complete within the HTTP request/response cycle.

Characteristics:

  • Immediate response with data
  • Standard HTTP status codes
  • No background processing required
  • Predictable response times (< 100ms)

Examples:

// GET operations - always synchronous
const accounts = await craneLedger.accounts.list('org_xxx');
const invoice = await craneLedger.invoices.get('org_xxx', 'inv_xxx');

// Simple POST operations - synchronous
const currency = await craneLedger.currencies.create('org_xxx', {
  code: 'EUR',
  name: 'Euro'
});

Asynchronous Operations (Worker-Based)

Complex operations that require validation, cross-system coordination, or significant processing are queued for background execution.

Characteristics:

  • Immediate acknowledgment with job ID
  • Background processing (seconds to minutes)
  • Status polling or webhooks for completion
  • Higher reliability for complex operations

Examples:

// Complex operations - asynchronous
const result = await craneLedger.contacts.create('org_xxx', {
  name: 'New Customer',
  contact_type: 'customer'
});
// Returns: { request_id: "req_xxx", status: "queued" }

Worker-Based Operations

Which Operations Use Workers?

ResourceCreateUpdateDeleteNotes
Contacts✅ Worker✅ Worker✅ WorkerBusiness logic validation
Invoices❌ Direct❌ Direct❌ DirectSimple document operations
Bills✅ Worker❌ Direct❌ DirectFinancial impact validation
Transfers✅ Worker❌ Direct❌ DirectDouble-entry accounting
Categories✅ Worker❌ Direct❌ DirectRelationship validation
Taxes✅ Worker❌ Direct❌ DirectCompliance checking
Items✅ Worker❌ Direct❌ DirectInventory impact

Worker Response Format

All worker-based operations return a standardized response:

{
  "request_id": "req_xxxxxxxxxxxxxxxx",
  "success": true,
  "data": {
    "status": "queued",
    "estimated_completion": "2024-01-15T10:30:05Z"
  },
  "error": null,
  "duration_ms": 45,
  "credit_cost": 5
}

Response Fields:

  • request_id: Unique identifier for tracking the operation
  • status: Current status (queued, processing, completed, failed)
  • estimated_completion: Expected completion time
  • credit_cost: Credits consumed (charged immediately)

Handling Asynchronous Operations

Option 1: Polling for Status

Poll the operation status using the request_id:

async function createContactAndWait(orgId, contactData) {
  // Start the operation
  const result = await craneLedger.contacts.create(orgId, contactData);
  const requestId = result.request_id;

  // Poll for completion
  while (true) {
    try {
      // Check operation status (this is a conceptual endpoint)
      const status = await craneLedger.operations.getStatus(requestId);

      if (status.status === 'completed') {
        return status.result; // The created contact
      } else if (status.status === 'failed') {
        throw new Error(status.error);
      }

      // Wait before next poll
      await new Promise(resolve => setTimeout(resolve, 1000));
    } catch (error) {
      // Handle polling errors
      console.error('Status check failed:', error);
      await new Promise(resolve => setTimeout(resolve, 2000));
    }
  }
}

Option 2: Webhook Notifications

Configure webhooks to receive completion notifications:

// Webhook endpoint receives operation completion
app.post('/webhooks/craneledger', (req, res) => {
  const { request_id, status, result, error } = req.body;

  if (status === 'completed') {
    console.log(`Operation ${request_id} completed:`, result);
    // Process the result
    updateLocalDatabase(result);
  } else if (status === 'failed') {
    console.error(`Operation ${request_id} failed:`, error);
    // Handle the error
    notifyUserOfFailure(error);
  }

  res.sendStatus(200);
});

Option 3: Fire-and-Forget

For operations where you don't need immediate confirmation:

async function bulkImportContacts(orgId, contacts) {
  const results = [];

  for (const contact of contacts) {
    try {
      const result = await craneLedger.contacts.create(orgId, contact);
      results.push({
        request_id: result.request_id,
        status: 'queued'
      });
    } catch (error) {
      results.push({
        contact: contact.name,
        error: error.message
      });
    }

    // Small delay to avoid overwhelming the API
    await new Promise(resolve => setTimeout(resolve, 100));
  }

  return results;
}

Best Practices

Error Handling

async function robustOperation(operation, ...args) {
  try {
    const result = await operation(...args);

    if (result.request_id) {
      // Asynchronous operation
      return {
        type: 'async',
        request_id: result.request_id,
        status: 'queued'
      };
    } else {
      // Synchronous operation
      return {
        type: 'sync',
        data: result
      };
    }
  } catch (error) {
    // Handle API errors
    if (error.response?.status === 429) {
      // Rate limited - retry with backoff
      await delay(getBackoffDelay());
      return robustOperation(operation, ...args);
    }

    throw error;
  }
}

Rate Limiting Considerations

class ApiClient {
  constructor() {
    this.queue = [];
    this.processing = false;
  }

  async enqueue(operation, ...args) {
    return new Promise((resolve, reject) => {
      this.queue.push({ operation, args, resolve, reject });
      this.processQueue();
    });
  }

  async processQueue() {
    if (this.processing || this.queue.length === 0) return;

    this.processing = true;

    while (this.queue.length > 0) {
      const { operation, args, resolve, reject } = this.queue.shift();

      try {
        const result = await operation(...args);
        resolve(result);
      } catch (error) {
        if (error.response?.status === 429) {
          // Rate limited - put back in queue with delay
          setTimeout(() => {
            this.queue.unshift({ operation, args, resolve, reject });
            this.processing = false;
          }, 60000); // Wait 1 minute
          return;
        }
        reject(error);
      }

      // Small delay between operations
      await new Promise(resolve => setTimeout(resolve, 100));
    }

    this.processing = false;
  }
}

Idempotency

For critical operations, implement idempotency:

async function createContactIdempotent(orgId, contactData, idempotencyKey) {
  // Use idempotency key to prevent duplicates
  const headers = {
    'X-Idempotency-Key': idempotencyKey
  };

  try {
    const result = await craneLedger.contacts.create(orgId, contactData, { headers });

    if (result.request_id) {
      // Store mapping for status checking
      await storeIdempotencyMapping(idempotencyKey, result.request_id);
    }

    return result;
  } catch (error) {
    if (error.response?.status === 409) {
      // Idempotency key already used
      const existingRequestId = await getIdempotencyMapping(idempotencyKey);
      return await craneLedger.operations.getStatus(existingRequestId);
    }
    throw error;
  }
}

Operation Status Checking

While there's no direct status checking endpoint yet, you can implement status checking by:

  1. Database Polling: If you have access to the same database
  2. Event Monitoring: Watch for completion events
  3. Webhook Tracking: Rely on webhook notifications
  4. Time-based Estimation: Assume completion after estimated time

Performance Considerations

Synchronous Operations

  • Fast: < 100ms response time
  • Predictable: Consistent performance
  • Resource Light: Minimal server resources
  • Cacheable: Results can be cached

Asynchronous Operations

  • Scalable: Handle complex operations without blocking
  • Reliable: Retry failed operations automatically
  • Resource Intensive: Background processing required
  • Monitoring Required: Need to track completion status

Migration Strategies

From Synchronous to Asynchronous

// Before: Assuming all operations are synchronous
async function createInvoice(orgId, invoiceData) {
  const invoice = await api.invoices.create(orgId, invoiceData);
  return sendEmail(invoice);
}

// After: Handling both sync and async operations
async function createInvoice(orgId, invoiceData) {
  const result = await api.invoices.create(orgId, invoiceData);

  if (result.request_id) {
    // Async operation - wait for completion or use webhooks
    const invoice = await waitForCompletion(result.request_id);
    return sendEmail(invoice);
  } else {
    // Sync operation - use immediately
    return sendEmail(result);
  }
}

Building Resilient Applications

class ResilientApiClient {
  constructor(apiClient, options = {}) {
    this.apiClient = apiClient;
    this.retryAttempts = options.retryAttempts || 3;
    this.backoffMs = options.backoffMs || 1000;
  }

  async execute(operation, ...args) {
    let lastError;

    for (let attempt = 1; attempt <= this.retryAttempts; attempt++) {
      try {
        const result = await operation.call(this.apiClient, ...args);

        // Handle async operations
        if (result.request_id) {
          return await this.handleAsyncOperation(result);
        }

        return result;
      } catch (error) {
        lastError = error;

        if (this.isRetryable(error) && attempt < this.retryAttempts) {
          await this.delay(this.backoffMs * Math.pow(2, attempt - 1));
          continue;
        }

        break;
      }
    }

    throw lastError;
  }

  async handleAsyncOperation(result) {
    // Implementation for handling async operations
    // Could poll, use webhooks, or return request_id
    return result;
  }

  isRetryable(error) {
    const retryableStatuses = [429, 500, 502, 503, 504];
    return retryableStatuses.includes(error.response?.status);
  }

  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

Monitoring and Debugging

Track Operation Performance

class OperationTracker {
  constructor() {
    this.operations = new Map();
  }

  start(operationId, operation, args) {
    this.operations.set(operationId, {
      id: operationId,
      operation,
      args,
      startTime: Date.now(),
      status: 'started'
    });
  }

  complete(operationId, result) {
    const op = this.operations.get(operationId);
    if (op) {
      op.endTime = Date.now();
      op.duration = op.endTime - op.startTime;
      op.status = 'completed';
      op.result = result;

      this.logOperation(op);
    }
  }

  fail(operationId, error) {
    const op = this.operations.get(operationId);
    if (op) {
      op.endTime = Date.now();
      op.duration = op.endTime - op.startTime;
      op.status = 'failed';
      op.error = error;

      this.logOperation(op);
    }
  }

  logOperation(op) {
    console.log(`Operation ${op.operation} (${op.id}): ${op.status} in ${op.duration}ms`);
    if (op.error) {
      console.error('Error:', op.error);
    }
  }
}

Summary

Understanding Crane Ledger's hybrid operation model is crucial for building reliable applications:

  • Synchronous operations provide immediate results for simple tasks
  • Asynchronous operations handle complex business logic reliably
  • Proper error handling ensures resilient application behavior
  • Monitoring and tracking help maintain system health

Choose the appropriate pattern based on your application's needs, and implement robust error handling and status tracking for the best user experience.


Need help?

Create a free account to access our support portal. Once signed in, use the Support tab in your dashboard to submit a support ticket — our team typically responds within 24 hours.