From a041b769694b2d274094d5b99281595aa7ab715c Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 28 Aug 2026 18:00:48 +1000 Subject: [PATCH] fix(registry): retry skills-raw tree outages --- .../server/api/skills-raw/[...slug].get.ts | 20 +++++-- test/unit/skills-raw-tree-outage.test.ts | 60 +++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 test/unit/skills-raw-tree-outage.test.ts diff --git a/layers/registry/server/api/skills-raw/[...slug].get.ts b/layers/registry/server/api/skills-raw/[...slug].get.ts index 0702925c..36b14cc7 100644 --- a/layers/registry/server/api/skills-raw/[...slug].get.ts +++ b/layers/registry/server/api/skills-raw/[...slug].get.ts @@ -64,14 +64,22 @@ export default defineApiHandler({ }) const branch = repoMeta?.defaultBranch || 'main' - const treeRes = await $fetch<{ files?: { path: string }[] }>( + const treeResult = await $fetch<{ files?: { path: string }[] }>( `https://ungh.cc/repos/${source.owner}/${source.repo}/files/${branch}`, - ).catch(() => { - emitOperationalEvent(createWideEvent({ operation: 'skill-raw-tree-fetch', outcome: 'failed' })) - return null - }) + ).then( + response => ({ _tag: 'available' as const, files: response.files ?? [] }), + () => { + emitOperationalEvent(createWideEvent({ operation: 'skill-raw-tree-fetch', outcome: 'failed' })) + return { _tag: 'unavailable' as const } + }, + ) + + if (treeResult._tag === 'unavailable') { + setHeader(event, 'retry-after', RAW_RETRY_AFTER) + throw createError({ statusCode: 503, message: 'Skill source is unavailable upstream' }) + } - const files = treeRes?.files ?? [] + const files = treeResult.files const slugifiedName = skill.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') const skillPath = files.find(f => f.path.toLowerCase().endsWith(`/${slugifiedName}/skill.md`) diff --git a/test/unit/skills-raw-tree-outage.test.ts b/test/unit/skills-raw-tree-outage.test.ts new file mode 100644 index 00000000..0b5c8db3 --- /dev/null +++ b/test/unit/skills-raw-tree-outage.test.ts @@ -0,0 +1,60 @@ +import type { H3Event } from 'h3' +import type { SqliteD1 } from './helpers/d1-sqlite' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { allMigrations, createSqliteD1 } from './helpers/d1-sqlite' + +let fixture: SqliteD1 +let cache: { + getItem: ReturnType + setItem: ReturnType +} +const responseHeaders = new Map() + +beforeEach(() => { + vi.resetModules() + responseHeaders.clear() + fixture = createSqliteD1(allMigrations()) + fixture.raw.prepare(`INSERT INTO repos (owner, repo) VALUES ('owner', 'repo')`).run() + fixture.raw.prepare( + `INSERT INTO skills (owner, repo, name, slug, display_name, source_resolved) + VALUES ('owner', 'repo', 'skill', 'owner/repo/skill', 'Skill', 1)`, + ).run() + cache = { + getItem: vi.fn().mockResolvedValue(null), + setItem: vi.fn().mockResolvedValue(undefined), + } + + vi.stubGlobal('defineEventHandler', (handler: unknown) => handler) + vi.stubGlobal('getRouterParam', () => 'owner/repo/skill') + vi.stubGlobal('getUserSession', () => Promise.resolve({ user: { id: 1, login: 'tester' } })) + vi.stubGlobal('useStorage', () => cache) + vi.stubGlobal('setHeader', (_event: H3Event, name: string, value: string | number) => { + responseHeaders.set(name, value) + }) + vi.stubGlobal('$fetch', vi.fn() + .mockResolvedValueOnce({ defaultBranch: 'main' }) + .mockRejectedValueOnce(new Error('tree unavailable'))) +}) + +afterEach(() => { + fixture.close() + vi.unstubAllGlobals() +}) + +describe('skills-raw tree outage', () => { + it('returns a retryable error without caching the Skill as missing', async () => { + const handler = (await import('../../layers/registry/server/api/skills-raw/[...slug].get')).default + + await expect(handler(event())).rejects.toMatchObject({ statusCode: 503 }) + + expect(cache.setItem).not.toHaveBeenCalled() + expect(responseHeaders.get('retry-after')).toBe(30) + }) +}) + +function event(): H3Event { + return { + method: 'GET', + context: { platform: { db: fixture.db } }, + } as unknown as H3Event +}