diff --git a/src/errors.ts b/src/errors.ts index fdbcd56..2e30b1c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -88,6 +88,12 @@ export interface DevhelmApiErrorOptions { * non-conforming responses without losing the original shape. */ rawBody?: unknown + /** + * Seconds to wait before retrying, parsed from the `Retry-After` + * response header on a 429. `undefined` when the header was absent + * or not a valid integer. + */ + retryAfter?: number } export class DevhelmApiError extends DevhelmError { @@ -96,6 +102,7 @@ export class DevhelmApiError extends DevhelmError { readonly requestId: string | undefined readonly body: ErrorResponse | undefined readonly rawBody: unknown + readonly retryAfter: number | undefined constructor(message: string, status: number, options?: DevhelmApiErrorOptions) { // Server-supplied code wins; fall back to a generic API-error label so @@ -107,6 +114,7 @@ export class DevhelmApiError extends DevhelmError { this.requestId = options?.requestId this.body = options?.body this.rawBody = options?.rawBody + this.retryAfter = options?.retryAfter } } @@ -184,7 +192,7 @@ const FallbackErrorShape = z export function errorFromResponse( status: number, body: string, - options?: {requestId?: string}, + options?: {requestId?: string; retryAfter?: string}, ): DevhelmApiError { let message = `HTTP ${status}` let detail: string | undefined @@ -223,12 +231,14 @@ export function errorFromResponse( } } + const retryAfter = options?.retryAfter ? Number.parseInt(options.retryAfter, 10) : undefined const opts: DevhelmApiErrorOptions = { detail, code, requestId: options?.requestId ?? bodyRequestId, body: parsed, rawBody, + retryAfter: Number.isFinite(retryAfter) ? retryAfter : undefined, } if (status === 401 || status === 403) return new DevhelmAuthError(message, status, opts) if (status === 404) return new DevhelmNotFoundError(message, status, opts) diff --git a/src/http.ts b/src/http.ts index 3726ee8..8c53302 100644 --- a/src/http.ts +++ b/src/http.ts @@ -99,8 +99,10 @@ export async function checkedFetch( body = await response.text().catch(() => '') } const requestIdHeader = response.headers.get('x-request-id') + const retryAfterHeader = response.headers.get('retry-after') throw errorFromResponse(response.status, body, { requestId: requestIdHeader ?? undefined, + retryAfter: retryAfterHeader ?? undefined, }) } return data diff --git a/src/resources/api-keys.ts b/src/resources/api-keys.ts index ce7bda5..ec451b5 100644 --- a/src/resources/api-keys.ts +++ b/src/resources/api-keys.ts @@ -17,6 +17,11 @@ export class ApiKeys { return fetchPage(this.client, '/api/v1/api-keys', ApiKeyDtoSchema, page, size) } + /** Get a single API key by ID. */ + async get(id: string | number): Promise { + return fetchSingle(this.client, 'GET', `/api/v1/api-keys/${id}`, ApiKeyDtoSchema) + } + /** Create a new API key. Returns the full key value (only available at creation time). */ async create(body: CreateApiKeyRequest): Promise { validateRequest(CreateApiKeyRequestSchema, body, 'apiKeys.create') diff --git a/test/client.test.ts b/test/client.test.ts index a27946b..f7d8975 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -60,6 +60,10 @@ describe('Devhelm client', () => { expect(typeof client.apiKeys.revoke).toBe('function') }) + it('api keys have get method', () => { + expect(typeof client.apiKeys.get).toBe('function') + }) + it('dependencies have track method', () => { expect(typeof client.dependencies.track).toBe('function') }) diff --git a/test/errors.test.ts b/test/errors.test.ts index e9c9829..920c9e5 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -60,6 +60,22 @@ describe('errorFromResponse', () => { expect(err.status).toBe(429) }) + it('exposes retryAfter parsed from the Retry-After header on 429', () => { + const err = errorFromResponse(429, '{"message":"Slow down"}', {retryAfter: '30'}) + expect(err).toBeInstanceOf(DevhelmRateLimitError) + expect(err.retryAfter).toBe(30) + }) + + it('leaves retryAfter undefined when the header is absent', () => { + const err = errorFromResponse(429, '{"message":"Slow down"}') + expect(err.retryAfter).toBeUndefined() + }) + + it('leaves retryAfter undefined when the header is not a valid integer', () => { + const err = errorFromResponse(429, '{"message":"Slow down"}', {retryAfter: 'not-a-number'}) + expect(err.retryAfter).toBeUndefined() + }) + it('returns DevhelmServerError for 500', () => { const err = errorFromResponse(500, '{"error":"Internal Server Error"}') expect(err).toBeInstanceOf(DevhelmServerError)