a little carrier pigeon that ferries figma events to discord
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

add observability and harden discord retry path

- /health now returns {status, lastFigmaRequestAt} as JSON. the
timestamp is written on every authenticated figma POST via a
reserved `__`-prefixed key in the existing KV namespace, so
monitoring can detect upstream silence without a figma PAT.
- discord 401/404 short-circuit the retry and drop immediately.
the webhook is dead; retrying wastes the single retry slot.
- discord 429 honors retry-after, clamped to [60s, 10min],
instead of the fixed 5min. still bounded by MAX_FLUSH_ATTEMPTS.
- log discord response body (truncated to 500 chars) on non-2xx
so 4xx vs 5xx vs rate-limit is obvious in `bun run tail`.
- promote no-KV-mapping log to warn and include file_name plus
triggered_by.handle for attribution.
- log zero-item flushes (deletion-only publishes) instead of
silently dropping.
- self-heal past alarms in batcher: treat alarms with `< now` as
absent so a stale alarm can't starve new batches.

typecheck passes. no new deps, no new bindings, no state shape
changes.

Signed-off-by: eti <eti@eti.tf>

eti aef67696 e1c9dabb

+54 -8
+1 -1
README.md
··· 101 101 ## endpoints 102 102 103 103 - `POST /figma` — Figma webhook receiver. validates `passcode`. accepts `PING` and `LIBRARY_PUBLISH`. other event types return 200 and are ignored. 104 - - `GET /health` — returns `healthy` with 200. 104 + - `GET /health` — returns `{"status":"healthy","lastFigmaRequestAt":<iso>|null}` as JSON with 200. `lastFigmaRequestAt` is the ISO timestamp of the most recent authenticated Figma webhook delivery, useful for detecting upstream silence. 105 105 106 106 all other routes return 404. 107 107
+33 -5
src/batcher.ts
··· 88 88 89 89 await this.state.storage.put(STATE_KEY, batch); 90 90 91 - // Schedule a flush if none is already pending. 91 + // Schedule a flush if none is already pending. Self-heal past alarms 92 + // (e.g. if a prior alarm() threw before clearing state) by treating a 93 + // stale alarm as absent. 92 94 const currentAlarm = await this.state.storage.getAlarm(); 93 - if (currentAlarm === null) { 95 + if (currentAlarm === null || currentAlarm < now) { 94 96 await this.state.storage.setAlarm(now + BATCH_WINDOW_MS); 95 97 } 96 98 ··· 110 112 111 113 // Nothing changed (deletion-only publish, or cleared upstream) -> drop. 112 114 if (totalItems === 0) { 115 + console.log( 116 + `no changed items for ${batch.fileKey}; dropping (likely deletion-only publish)`, 117 + ); 113 118 await this.state.storage.delete(STATE_KEY); 114 119 return; 115 120 } ··· 117 122 const embed = buildEmbed(batch); 118 123 119 124 let success = false; 125 + let res: Response | undefined; 120 126 try { 121 - const res = await sendToDiscord(batch.discordWebhookUrl, embed); 127 + res = await sendToDiscord(batch.discordWebhookUrl, embed); 122 128 success = res.ok; 123 129 if (!success) { 130 + const body = await res.text().catch(() => "<unreadable>"); 124 131 console.error( 125 - `Discord POST failed for ${batch.fileKey}: ${res.status} ${res.statusText}`, 132 + `Discord POST failed for ${batch.fileKey}: ${res.status} ${res.statusText} body=${body.slice(0, 500)}`, 126 133 ); 127 134 } 128 135 } catch (err) { ··· 134 141 return; 135 142 } 136 143 144 + // Dead webhook -> retrying won't help. Drop immediately so the operator 145 + // is the bottleneck (they need to update the KV mapping), not our retry. 146 + if (res?.status === 401 || res?.status === 404) { 147 + console.error( 148 + `Discord webhook dead for ${batch.fileKey} (${res.status}); dropping without retry. Update KV with new URL.`, 149 + ); 150 + await this.state.storage.delete(STATE_KEY); 151 + return; 152 + } 153 + 137 154 // Retry budget exhausted -> drop the batch to avoid a loop. 138 155 if (batch.flushAttempts >= MAX_FLUSH_ATTEMPTS) { 139 156 console.error( ··· 143 160 return; 144 161 } 145 162 163 + // On 429, honor Retry-After (clamped to a sane [60s, 10min] range). 164 + // All other failures use the fixed RETRY_DELAY_MS. 165 + let retryDelayMs = RETRY_DELAY_MS; 166 + if (res?.status === 429) { 167 + const retryAfter = res.headers.get("retry-after"); 168 + const secs = retryAfter ? Number.parseFloat(retryAfter) : NaN; 169 + if (Number.isFinite(secs) && secs > 0) { 170 + retryDelayMs = Math.min(Math.max(secs * 1000, 60_000), 10 * 60_000); 171 + } 172 + } 173 + 146 174 // Schedule a single retry. 147 175 await this.state.storage.put(STATE_KEY, batch); 148 - await this.state.storage.setAlarm(Date.now() + RETRY_DELAY_MS); 176 + await this.state.storage.setAlarm(Date.now() + retryDelayMs); 149 177 } 150 178 }
+20 -2
src/index.ts
··· 38 38 return new Response("unauthorized", { status: 401 }); 39 39 } 40 40 41 + // Record liveness so /health can surface upstream silences. Reserved 42 + // __-prefixed key; operators must not use __-prefixed Figma file_keys. 43 + // Fire-and-forget so Figma keeps getting a fast 200. 44 + ctx.waitUntil( 45 + env.FIGMA_DISCORD_WEBHOOK.put( 46 + "__last_figma_request_at", 47 + new Date().toISOString(), 48 + ), 49 + ); 50 + 41 51 // Figma sends PING immediately after webhook creation. 42 52 if (payload.event_type === "PING") { 43 53 console.log("received PING"); ··· 59 69 60 70 const discordWebhookUrl = await env.FIGMA_DISCORD_WEBHOOK.get(event.file_key); 61 71 if (!discordWebhookUrl) { 62 - console.log(`no KV mapping for file_key=${event.file_key}; skipping`); 72 + console.warn( 73 + `no KV mapping for file_key=${event.file_key} (${event.file_name}); triggered_by=${event.triggered_by?.handle ?? "unknown"}; skipping`, 74 + ); 63 75 return ok(); 64 76 } 65 77 ··· 99 111 const url = new URL(request.url); 100 112 101 113 if (request.method === "GET" && url.pathname === "/health") { 102 - return ok("healthy"); 114 + const lastFigmaRequestAt = await env.FIGMA_DISCORD_WEBHOOK.get( 115 + "__last_figma_request_at", 116 + ); 117 + return new Response( 118 + JSON.stringify({ status: "healthy", lastFigmaRequestAt }), 119 + { status: 200, headers: { "Content-Type": "application/json" } }, 120 + ); 103 121 } 104 122 105 123 if (request.method === "POST" && url.pathname === "/figma") {