Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Imports: a GitHub rate limit on the repository lookup waits instead of failing the import (#95)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Canberk Demirci authored · GitHub committed signed1 parent 5702919commit 90b603a1cc7883f742f44cea961cfccca7b182daBrowse files
3 files changed+38−3
modifiedapps/diffinery/src/imports/service.ts+10−2View file
@@ -730,14 +730,21 @@
730730
731731 // ---------------------------------------------------------------- workflow: one repository
732732
733- /** Create the Diffinery repository (or reuse the one this import created on an earlier attempt). */
734- async stepRepository(id: string): Promise<void> {
733+ /**
734+ * Create the Diffinery repository (or reuse the one this import created on an earlier attempt).
735+ * A rate limit comes back as `wait` so the workflow sleeps it off and calls this again.
736+ */
737+ async stepRepository(id: string): Promise<{ wait?: number }> {
735738 const ctx = await this.context(id);
736739 await this.setStep(id, "repository", { status: "running", startedAt: now() });
737740 let gr: GhRepo;
738741 try {
739742 gr = await ctx.gh.get<GhRepo>(`repos/${ctx.row.source}`);
740743 } catch (e) {
744+ if (e instanceof RateLimited) {
745+ await this.setStep(id, "repository", { message: e.message });
746+ return { wait: e.waitSeconds };
747+ }
741748 if (e instanceof GitHubError && e.status < 500) throw fatal(e.status === 401 ? "GitHub rejected the token (expired or revoked?)" : `GitHub repository ${ctx.row.source} was not found or the token cannot read it`);
742749 throw e;
743750 }
@@ -782,6 +789,7 @@
782789 await this.dir.setTopics(repo.id, ctx.summary.source.topics);
783790 await this.saveSummary(id, ctx.summary);
784791 await this.setStep(id, "repository", { status: "done", message: `${repo.owner_login}/${repo.name}`, finishedAt: now() });
792+ return {};
785793 }
786794
787795 /** Start (or restart) the Git transfer. Returns whether an external agent has to claim it. */
modifiedapps/diffinery/src/imports/workflow.ts+10−1View file
@@ -13,6 +13,8 @@
1313 const GIT_TRIES = 2;
1414 /** An overall cap on how many times one paged step may be called, so a hostile or broken source cannot run the workflow forever. */
1515 const MAX_STEP_CALLS = 2000;
16+/** How many rate-limit sleeps (each at most an hour) the repository lookup may take. */
17+const MAX_RATE_LIMIT_WAITS = 48;
1618
1719 /**
1820 * Durable execution of one GitHub import (instance id `<importId>-<attempt>`).
@@ -39,7 +41,14 @@
3941 }
4042
4143 private async repository(id: string, step: WorkflowStep, svc: () => ImportService): Promise<void> {
42- await step.do("repository", RETRY, () => svc().stepRepository(id));
44+ // GitHub's rate limit is slept off, never a failure (the first call keeps its original step name).
45+ for (let i = 0; ; i++) {
46+ if (i >= MAX_RATE_LIMIT_WAITS) throw new Error("GitHub kept rate limiting the repository lookup");
47+ const r = await step.do(i === 0 ? "repository" : `repository ${i}`, RETRY, () => svc().stepRepository(id));
48+ // `?.`: an instance that ran this step before the change replays it with no result.
49+ if (!r?.wait) break;
50+ await step.sleep(`repository rate limit ${i}`, `${r.wait} seconds`);
51+ }
4352
4453 for (let attempt = 1; ; attempt++) {
4554 await step.do(`git start ${attempt}`, RETRY, () => svc().startGit(id));
modifiedapps/diffinery/test/unit/imports.test.ts+18−0View file
@@ -11,6 +11,7 @@
1111 import type { Env } from "../../src/env.js";
1212 import type { Account } from "../../src/lib/directory.js";
1313 import { ImportService } from "../../src/imports/service.js";
14+import { RateLimited } from "../../src/imports/github.js";
1415
1516 // ---------------------------------------------------------------- request and option validation
1617
@@ -356,6 +357,23 @@
356357 });
357358 });
358359
360+// ---------------------------------------------------------------- rate limits
361+
362+describe("a GitHub rate limit pauses an import, it never fails it", () => {
363+ it("looking up the source repository waits out the limit and leaves the step running", async () => {
364+ const svc = service();
365+ const steps: Array<Record<string, unknown>> = [];
366+ const gh = { get: async () => { throw new RateLimited(42); } };
367+ Object.assign(svc, {
368+ context: async () => ({ row: { id: "imp", source: "octo/app" }, gh, steps: {}, summary: {} }),
369+ setStep: async (_id: string, _name: string, patch: Record<string, unknown>) => void steps.push(patch),
370+ });
371+ expect(await svc.stepRepository("imp")).toEqual({ wait: 42 });
372+ expect(steps.at(-1)).toEqual({ message: "GitHub rate limit reached; continuing in 42 seconds" });
373+ expect(steps.some((p) => p.status === "failed" || p.status === "done")).toBe(false);
374+ });
375+});
376+
359377 // ---------------------------------------------------------------- texts that point at the web UI
360378
361379 describe("import texts name what the web UI shows", () => {