Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

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

ui: move rate limit card

Hugo da4623be 1fc1ab23

+70 -39
+25 -25
app/routes/dashboard/automations/[rkey].tsx
··· 186 186 </details> 187 187 </Card> 188 188 189 - <Card variant="flat"> 190 - <Stack gap={3}> 191 - <h3 class={inlineCluster}> 192 - <Activity size={18} /> Rate limits 193 - </h3> 194 - <ul class={plainList}> 195 - {rateLimitCounts.map((rl) => { 196 - const ratio = rl.limit === 0 ? 0 : rl.count / rl.limit; 197 - const variant = ratio >= 1 ? "error" : ratio >= 0.8 ? "warning" : "neutral"; 198 - return ( 199 - <li key={rl.window} class={inlineCluster}> 200 - <span>per {rl.window}</span> 201 - <Badge variant={variant}> 202 - {rl.count} / {rl.limit} 203 - </Badge> 204 - </li> 205 - ); 206 - })} 207 - </ul> 208 - <p class={textMuted}> 209 - Hitting any window auto-disables the automation. Dry-run fires do not count. 210 - </p> 211 - </Stack> 212 - </Card> 213 - 214 189 {auto.conditions.length > 0 && ( 215 190 <Card variant="flat"> 216 191 <Stack gap={3}> ··· 377 352 ); 378 353 })} 379 354 </Stack> 355 + 356 + <Card variant="flat"> 357 + <Stack gap={3}> 358 + <h3 class={inlineCluster}> 359 + <Activity size={18} /> Rate limits 360 + </h3> 361 + <ul class={plainList}> 362 + {rateLimitCounts.map((rl) => { 363 + const ratio = rl.limit === 0 ? 0 : rl.count / rl.limit; 364 + const variant = ratio >= 1 ? "error" : ratio >= 0.8 ? "warning" : "neutral"; 365 + return ( 366 + <li key={rl.window} class={inlineCluster}> 367 + <span>per {rl.window}</span> 368 + <Badge variant={variant}> 369 + {rl.count} / {rl.limit} 370 + </Badge> 371 + </li> 372 + ); 373 + })} 374 + </ul> 375 + <p class={textMuted}> 376 + Hitting any window auto-disables the automation. Dry-run fires do not count. 377 + </p> 378 + </Stack> 379 + </Card> 380 380 381 381 <DeliveryLog 382 382 rkey={auto.rkey}
+8
lib/actions/delivery.ts
··· 3 3 4 4 export const RETRY_DELAYS = [5_000, 30_000]; 5 5 6 + /** HTTP status used by action handlers to signal a skip: something the action 7 + * pipeline deliberately chose not to do (e.g. the follow action's built-in 8 + * "no profile" / "already following" checks). Counts as success for chain 9 + * continuation and is non-retryable, but `loadRecentTimestamps` in 10 + * `lib/jetstream/rate-limit.ts` excludes it from the per-window count so a 11 + * skipped fire doesn't burn the user's rate budget. */ 12 + export const SKIP_STATUS = 204; 13 + 6 14 export function isSuccess(code: number): boolean { 7 15 return code >= 200 && code < 300; 8 16 }
+1 -7
lib/actions/follow.ts
··· 8 8 import { fetchRecord } from "../pds/resolver.js"; 9 9 import { executeSearch } from "./searcher.js"; 10 10 import { renderTextTemplate, type FetchContext } from "./template.js"; 11 - import { RETRY_DELAYS, isSuccess, isRetryable, logDelivery } from "./delivery.js"; 11 + import { RETRY_DELAYS, SKIP_STATUS, isSuccess, isRetryable, logDelivery } from "./delivery.js"; 12 12 import { DID_RE } from "./validation.js"; 13 13 import { FOLLOW_TARGET_META } from "../automations/follow-targets.js"; 14 14 import type { ActionResult } from "./executor.js"; 15 15 import type { MatchedEvent } from "../jetstream/consumer.js"; 16 - 17 - /** HTTP status used to signal a skipped fire (no profile, already following). 18 - * Counts as success for chain-continuation (no fail-fast) and retry (no 5xx), 19 - * but is filtered out of rate-limit counts — a skipped follow did zero PDS 20 - * work and shouldn't burn the user's budget. */ 21 - const SKIP_STATUS = 204; 22 16 23 17 async function checkProfileExists( 24 18 action: FollowAction,
+22
lib/jetstream/handler.test.ts
··· 240 240 expect(logged.message).toContain("will skip if no Bluesky profile exists or already following"); 241 241 }); 242 242 243 + it("continues the action chain after an action skips with 204", async () => { 244 + // A 204 skip (e.g. a follow action's "already following" pre-flight) 245 + // must not stop the chain: it's success, not failure. Lock it in so 246 + // future refactors of `isActionSuccess` don't accidentally drop 204. 247 + mockDispatch.mockResolvedValueOnce({ statusCode: 204, message: "Skipped: something" }); 248 + 249 + const match = makeMatch({ 250 + automation: { 251 + actions: [makeWebhookAction(), makeWebhookAction()], 252 + fetches: [], 253 + }, 254 + }); 255 + 256 + await handleMatchedEvent(match); 257 + 258 + expect(mockDispatch).toHaveBeenCalledTimes(2); 259 + // Assert the indices explicitly — "called twice" could otherwise pass if 260 + // a future refactor retried action 0 instead of advancing to action 1. 261 + expect(mockDispatch.mock.calls[0]![1]).toBe(0); 262 + expect(mockDispatch.mock.calls[1]![1]).toBe(1); 263 + }); 264 + 243 265 it("skips fetch resolution when no fetch steps", async () => { 244 266 const match = makeMatch({ 245 267 automation: { actions: [makeWebhookAction()], fetches: [] },
+3 -2
lib/jetstream/rate-limit.ts
··· 1 1 import { and, eq, gte, ne, or, isNull } from "drizzle-orm"; 2 2 import { db } from "../db/index.js"; 3 3 import { automations, deliveryLogs } from "../db/schema.js"; 4 + import { SKIP_STATUS } from "../actions/delivery.js"; 4 5 5 6 /** 6 7 * Per-automation rate limits. Any window hitting its limit auto-disables the ··· 50 51 * a manual re-enable after an auto-disable starts the counters from zero 51 52 * instead of immediately re-tripping on the same logs. 52 53 * 53 - * `statusCode = 204` is excluded: those rows are emitted by action-level 54 + * `SKIP_STATUS` (204) is excluded: those rows are emitted by action-level 54 55 * safety checks (e.g. the follow action's "already following" / "no profile" 55 56 * skips) that did zero PDS work and shouldn't burn the user's budget. `IS 56 57 * NULL` is kept to be forward-compatible with any future non-dry-run log ··· 70 71 eq(deliveryLogs.automationUri, automation.uri), 71 72 eq(deliveryLogs.dryRun, false), 72 73 gte(deliveryLogs.createdAt, cutoff), 73 - or(ne(deliveryLogs.statusCode, 204), isNull(deliveryLogs.statusCode)), 74 + or(ne(deliveryLogs.statusCode, SKIP_STATUS), isNull(deliveryLogs.statusCode)), 74 75 ), 75 76 ); 76 77 return rows.map((r) => r.createdAt.getTime());
+5 -1
lib/pds/resolver.test.ts
··· 257 257 json: async () => ({ error: "InvalidRequest", message: "bad rkey format" }), 258 258 }); 259 259 260 - await expect(fetchRecord("at://did:plc:abc/col/rk")).rejects.toThrow("getRecord failed (400)"); 260 + // Error message includes the XRPC `error` code so `InvalidRequest`-style 261 + // problems are distinguishable from bare 400s in logs. 262 + await expect(fetchRecord("at://did:plc:abc/col/rk")).rejects.toThrow( 263 + "getRecord failed (400 InvalidRequest)", 264 + ); 261 265 }); 262 266 263 267 it("still throws on 400 when the body isn't parseable JSON", async () => {
+6 -4
lib/pds/resolver.ts
··· 106 106 // above). Normalize the 400 variant to the same not-found entry so 107 107 // per-fetch `found exists`/`not-exists` conditions can gate on it. Other 108 108 // 4xx/5xx still throw — those are genuine errors the caller should see. 109 + let xrpcError: { error?: string; message?: string } | null = null; 109 110 if (res.status === 400) { 110 111 try { 111 - const body = (await res.json()) as { error?: string }; 112 - if (body.error === "RecordNotFound") { 112 + xrpcError = (await res.json()) as { error?: string; message?: string }; 113 + if (xrpcError.error === "RecordNotFound") { 113 114 return { found: false, uri: atUri, cid: "", did, collection, rkey, record: {} }; 114 115 } 115 116 } catch { 116 - // Body wasn't JSON, or didn't include `error` — fall through to throw. 117 + // Body wasn't JSON — leave xrpcError null and fall through to throw. 117 118 } 118 119 } 119 - throw new Error(`PDS getRecord failed (${res.status}) for ${atUri}`); 120 + const suffix = xrpcError?.error ? ` ${xrpcError.error}` : ""; 121 + throw new Error(`PDS getRecord failed (${res.status}${suffix}) for ${atUri}`); 120 122 } 121 123 122 124 const data = (await res.json()) as {