Skip to main content
AI/MLjeremylongshore

canva-reliability-patterns

"Implement reliability patterns for Canva Connect API \u2014 circuit\

Stars
2,267
Source
jeremylongshore/claude-code-plugins-plus-skills
Updated
2026-05-31
Slug
jeremylongshore--claude-code-plugins-plus-skills--canva-reliability-patterns
View on GitHubRaw SKILL.md

// install — copy + paste into any project

mkdir -p .claude/skills && curl -fsSL https://raw.githubusercontent.com/jeremylongshore/claude-code-plugins-plus-skills/HEAD/plugins/saas-packs/canva-pack/skills/canva-reliability-patterns/SKILL.md -o .claude/skills/canva-reliability-patterns.md

Drops the SKILL.md into .claude/skills/canva-reliability-patterns.md. Works with Claude Code, Cursor, and any agent that loads SKILL.md files from .claude/skills/.

Canva Reliability Patterns

Overview

Production-grade reliability patterns for the Canva Connect API. The API has async operations (exports, uploads, autofills) that can fail or timeout, OAuth tokens that expire every 4 hours, and rate limits that require backoff.

Prerequisites

  • Defined SLOs, queue ownership, idempotency storage, and a protected configuration for timeouts, backoff, and circuit limits.
  • A synthetic or approved non-production workload for exercising failure paths without exposing design assets.

Instructions

  1. Bound every asynchronous operation with an idempotency key, timeout, retry budget, and redacted outcome record.
  2. Retry only explicitly transient requests; treat authorization, policy, schema, and asset-rights errors as fail-closed.
  3. Open circuits and pause dependent queues on sustained failure, then reconcile before recovery.
  4. Change reliability thresholds behind a rollback flag and measure aggregate results before rollout.

Circuit Breaker

import CircuitBreaker from 'opossum';

const canvaBreaker = new CircuitBreaker(
  async (fn: () => Promise<any>) => fn(),
  {
    timeout: 30000,              // 30s before failure
    errorThresholdPercentage: 50, // Open after 50% failure rate
    resetTimeout: 60000,          // Try again after 60s
    volumeThreshold: 5,           // Min 5 requests before evaluating
  }
);

canvaBreaker.on('open', () => {
  console.warn('[canva] Circuit OPEN — Canva API unreachable, failing fast');
});

canvaBreaker.on('halfOpen', () => {
  console.info('[canva] Circuit HALF-OPEN — testing Canva recovery');
});

canvaBreaker.on('close', () => {
  console.info('[canva] Circuit CLOSED — Canva API recovered');
});

// Usage
async function createDesignSafe(body: object, token: string) {
  return canvaBreaker.fire(async () => {
    return canvaAPI('/designs', token, {
      method: 'POST',
      body: JSON.stringify(body),
    });
  });
}

Graceful Degradation

// When Canva is down, degrade gracefully instead of breaking the entire app
async function getDesignWithFallback(
  designId: string,
  token: string,
  cache: LRUCache<string, any>
): Promise<{ data: any; source: 'live' | 'cache' | 'placeholder' }> {
  try {
    const data = await canvaBreaker.fire(async () =>
      canvaAPI(`/designs/${designId}`, token)
    );
    cache.set(designId, data);
    return { data, source: 'live' };
  } catch {
    // Try cached version
    const cached = cache.get(designId);
    if (cached) {
      return { data: cached, source: 'cache' };
    }

    // Return placeholder
    return {
      data: {
        design: {
          id: designId,
          title: 'Design temporarily unavailable',
          urls: { edit_url: '#', view_url: '#' },
        },
      },
      source: 'placeholder',
    };
  }
}

Async Job Resilience

// Export, upload, and autofill jobs can fail — wrap with retry
async function resilientExport(
  designId: string,
  format: object,
  token: string,
  maxRetries = 2
): Promise<string[]> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      // Start export
      const { job } = await canvaAPI('/exports', token, {
        method: 'POST',
        body: JSON.stringify({ design_id: designId, format }),
      });

      // Poll with timeout
      const urls = await pollWithTimeout(job.id, token, 60000);
      return urls;
    } catch (error: any) {
      if (attempt === maxRetries) throw error;

      // Don't retry on client errors (400, 403, 404)
      if (error.status && error.status < 500 && error.status !== 429) throw error;

      const delay = 5000 * Math.pow(2, attempt);
      console.warn(`Export attempt ${attempt + 1} failed, retrying in ${delay / 1000}s`);
      await new Promise(r => setTimeout(r, delay));
    }
  }
  throw new Error('Unreachable');
}

async function pollWithTimeout(
  exportId: string,
  token: string,
  timeoutMs: number
): Promise<string[]> {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const { job } = await canvaAPI(`/exports/${exportId}`, token);
    if (job.status === 'success') return job.urls;
    if (job.status === 'failed') throw new Error(`Export failed: ${job.error?.code}`);
    await new Promise(r => setTimeout(r, 2000));
  }

  throw new Error('Export polling timeout');
}

Token Refresh Resilience

// Token refresh is critical — handle every failure mode
async function resilientTokenRefresh(
  refreshToken: string,
  config: { clientId: string; clientSecret: string }
): Promise<{ accessToken: string; refreshToken: string; expiresAt: number } | null> {
  const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString('base64');

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const res = await fetch('https://api.canva.com/rest/v1/oauth/token', {
        method: 'POST',
        headers: {
          'Authorization': `Basic ${basicAuth}`,
          'Content-Type': 'application/x-www-form-urlencoded',
        },
        body: new URLSearchParams({
          grant_type: 'refresh_token',
          refresh_token: refreshToken,
        }),
        signal: AbortSignal.timeout(10000),
      });

      if (res.ok) {
        const data = await res.json();
        return {
          accessToken: data.access_token,
          refreshToken: data.refresh_token,
          expiresAt: Date.now() + data.expires_in * 1000,
        };
      }

      if (res.status === 400 || res.status === 401) {
        // Invalid refresh token — user must re-authorize
        console.error('[canva] Refresh token invalid — user must re-authorize');
        return null; // Signal caller to initiate new OAuth flow
      }

      // 5xx — retry
    } catch {
      // Network error — retry
    }

    await new Promise(r => setTimeout(r, 2000 * Math.pow(2, attempt)));
  }

  console.error('[canva] Token refresh failed after 3 attempts');
  return null;
}

Dead Letter Queue for Failed Operations

interface FailedOperation {
  id: string;
  operation: 'export' | 'autofill' | 'upload';
  payload: any;
  userId: string;
  error: string;
  attempts: number;
  lastAttempt: Date;
}

class CanvaDeadLetterQueue {
  constructor(private db: Database) {}

  async add(op: Omit<FailedOperation, 'id' | 'lastAttempt'>): Promise<void> {
    await this.db.dlq.insert({
      ...op,
      id: crypto.randomUUID(),
      lastAttempt: new Date(),
    });
  }

  async processNext(getToken: (userId: string) => Promise<string | null>): Promise<boolean> {
    const entry = await this.db.dlq.findOne({ attempts: { $lt: 5 } });
    if (!entry) return false;

    const token = await getToken(entry.userId);
    if (!token) {
      console.warn(`DLQ: User ${entry.userId} has no valid token — skipping`);
      return false;
    }

    try {
      await this.retryOperation(entry, token);
      await this.db.dlq.delete(entry.id);
      return true;
    } catch {
      await this.db.dlq.update(entry.id, {
        attempts: entry.attempts + 1,
        lastAttempt: new Date(),
      });
      return false;
    }
  }

  private async retryOperation(entry: FailedOperation, token: string) {
    switch (entry.operation) {
      case 'export': return canvaAPI('/exports', token, { method: 'POST', body: JSON.stringify(entry.payload) });
      case 'autofill': return canvaAPI('/autofills', token, { method: 'POST', body: JSON.stringify(entry.payload) });
    }
  }
}

Output

Reliability controls emit redacted operation status, retry/circuit state, aggregate latency, and reconciliation result. They do not record tokens, design contents, signed asset URLs, or raw webhook payloads.

Examples

For a timed-out export, retain the idempotency key, check the existing job status within the approved scope, and retry only if no completed result exists. If the circuit opens, pause new exports and alert the owner; do not fan out retries or recreate designs blindly.

Error Handling

Issue Cause Solution
Circuit stays open Threshold too low Increase volumeThreshold
Token refresh fails Single-use refresh token reused Always store new token
Export retries waste quota Re-starting export Track export job IDs
DLQ growing Persistent issue Investigate root cause

Resources

Next Steps

For policy enforcement, see canva-policy-guardrails.