···3344export const RETRY_DELAYS = [5_000, 30_000];
5566+/** HTTP status used by action handlers to signal a skip: something the action
77+ * pipeline deliberately chose not to do (e.g. the follow action's built-in
88+ * "no profile" / "already following" checks). Counts as success for chain
99+ * continuation and is non-retryable, but `loadRecentTimestamps` in
1010+ * `lib/jetstream/rate-limit.ts` excludes it from the per-window count so a
1111+ * skipped fire doesn't burn the user's rate budget. */
1212+export const SKIP_STATUS = 204;
1313+614export function isSuccess(code: number): boolean {
715 return code >= 200 && code < 300;
816}
+1-7
lib/actions/follow.ts
···88import { fetchRecord } from "../pds/resolver.js";
99import { executeSearch } from "./searcher.js";
1010import { renderTextTemplate, type FetchContext } from "./template.js";
1111-import { RETRY_DELAYS, isSuccess, isRetryable, logDelivery } from "./delivery.js";
1111+import { RETRY_DELAYS, SKIP_STATUS, isSuccess, isRetryable, logDelivery } from "./delivery.js";
1212import { DID_RE } from "./validation.js";
1313import { FOLLOW_TARGET_META } from "../automations/follow-targets.js";
1414import type { ActionResult } from "./executor.js";
1515import type { MatchedEvent } from "../jetstream/consumer.js";
1616-1717-/** HTTP status used to signal a skipped fire (no profile, already following).
1818- * Counts as success for chain-continuation (no fail-fast) and retry (no 5xx),
1919- * but is filtered out of rate-limit counts — a skipped follow did zero PDS
2020- * work and shouldn't burn the user's budget. */
2121-const SKIP_STATUS = 204;
22162317async function checkProfileExists(
2418 action: FollowAction,
+22
lib/jetstream/handler.test.ts
···240240 expect(logged.message).toContain("will skip if no Bluesky profile exists or already following");
241241 });
242242243243+ it("continues the action chain after an action skips with 204", async () => {
244244+ // A 204 skip (e.g. a follow action's "already following" pre-flight)
245245+ // must not stop the chain: it's success, not failure. Lock it in so
246246+ // future refactors of `isActionSuccess` don't accidentally drop 204.
247247+ mockDispatch.mockResolvedValueOnce({ statusCode: 204, message: "Skipped: something" });
248248+249249+ const match = makeMatch({
250250+ automation: {
251251+ actions: [makeWebhookAction(), makeWebhookAction()],
252252+ fetches: [],
253253+ },
254254+ });
255255+256256+ await handleMatchedEvent(match);
257257+258258+ expect(mockDispatch).toHaveBeenCalledTimes(2);
259259+ // Assert the indices explicitly — "called twice" could otherwise pass if
260260+ // a future refactor retried action 0 instead of advancing to action 1.
261261+ expect(mockDispatch.mock.calls[0]![1]).toBe(0);
262262+ expect(mockDispatch.mock.calls[1]![1]).toBe(1);
263263+ });
264264+243265 it("skips fetch resolution when no fetch steps", async () => {
244266 const match = makeMatch({
245267 automation: { actions: [makeWebhookAction()], fetches: [] },
+3-2
lib/jetstream/rate-limit.ts
···11import { and, eq, gte, ne, or, isNull } from "drizzle-orm";
22import { db } from "../db/index.js";
33import { automations, deliveryLogs } from "../db/schema.js";
44+import { SKIP_STATUS } from "../actions/delivery.js";
4556/**
67 * Per-automation rate limits. Any window hitting its limit auto-disables the
···5051 * a manual re-enable after an auto-disable starts the counters from zero
5152 * instead of immediately re-tripping on the same logs.
5253 *
5353- * `statusCode = 204` is excluded: those rows are emitted by action-level
5454+ * `SKIP_STATUS` (204) is excluded: those rows are emitted by action-level
5455 * safety checks (e.g. the follow action's "already following" / "no profile"
5556 * skips) that did zero PDS work and shouldn't burn the user's budget. `IS
5657 * NULL` is kept to be forward-compatible with any future non-dry-run log
···7071 eq(deliveryLogs.automationUri, automation.uri),
7172 eq(deliveryLogs.dryRun, false),
7273 gte(deliveryLogs.createdAt, cutoff),
7373- or(ne(deliveryLogs.statusCode, 204), isNull(deliveryLogs.statusCode)),
7474+ or(ne(deliveryLogs.statusCode, SKIP_STATUS), isNull(deliveryLogs.statusCode)),
7475 ),
7576 );
7677 return rows.map((r) => r.createdAt.getTime());
+5-1
lib/pds/resolver.test.ts
···257257 json: async () => ({ error: "InvalidRequest", message: "bad rkey format" }),
258258 });
259259260260- await expect(fetchRecord("at://did:plc:abc/col/rk")).rejects.toThrow("getRecord failed (400)");
260260+ // Error message includes the XRPC `error` code so `InvalidRequest`-style
261261+ // problems are distinguishable from bare 400s in logs.
262262+ await expect(fetchRecord("at://did:plc:abc/col/rk")).rejects.toThrow(
263263+ "getRecord failed (400 InvalidRequest)",
264264+ );
261265 });
262266263267 it("still throws on 400 when the body isn't parseable JSON", async () => {
+6-4
lib/pds/resolver.ts
···106106 // above). Normalize the 400 variant to the same not-found entry so
107107 // per-fetch `found exists`/`not-exists` conditions can gate on it. Other
108108 // 4xx/5xx still throw — those are genuine errors the caller should see.
109109+ let xrpcError: { error?: string; message?: string } | null = null;
109110 if (res.status === 400) {
110111 try {
111111- const body = (await res.json()) as { error?: string };
112112- if (body.error === "RecordNotFound") {
112112+ xrpcError = (await res.json()) as { error?: string; message?: string };
113113+ if (xrpcError.error === "RecordNotFound") {
113114 return { found: false, uri: atUri, cid: "", did, collection, rkey, record: {} };
114115 }
115116 } catch {
116116- // Body wasn't JSON, or didn't include `error` — fall through to throw.
117117+ // Body wasn't JSON — leave xrpcError null and fall through to throw.
117118 }
118119 }
119119- throw new Error(`PDS getRecord failed (${res.status}) for ${atUri}`);
120120+ const suffix = xrpcError?.error ? ` ${xrpcError.error}` : "";
121121+ throw new Error(`PDS getRecord failed (${res.status}${suffix}) for ${atUri}`);
120122 }
121123122124 const data = (await res.json()) as {