Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/resources/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiKeyDto> {
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<ApiKeyCreateResponse> {
validateRequest(CreateApiKeyRequestSchema, body, 'apiKeys.create')
Expand Down
4 changes: 4 additions & 0 deletions test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
Expand Down
16 changes: 16 additions & 0 deletions test/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading