-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
2f54c46
commit 8c3f226
Showing
3 changed files
with
243 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,224 @@ | ||
import fetch, { FetchError } from 'node-fetch' | ||
import * as https from 'node:https' | ||
import * as k8s from '@kubernetes/client-node' | ||
|
||
function removeNullableProperties<T extends Record<string, unknown>>( | ||
obj: T | ||
): T { | ||
Object.keys(obj).forEach( | ||
(key) => (obj[key] === undefined || obj[key] === null) && delete obj[key] | ||
) | ||
return obj | ||
} | ||
|
||
/** | ||
* Exponential backoff based on the attempt number. | ||
* | ||
* @remarks | ||
* 1. 600ms * random(0.4, 1.4) | ||
* 2. 1200ms * random(0.4, 1.4) | ||
* 3. 2400ms * random(0.4, 1.4) | ||
* 4. 4800ms * random(0.4, 1.4) | ||
* 5. 9600ms * random(0.4, 1.4) | ||
* | ||
* @param attempt - Current attempt | ||
* @param maxRetries - Maximum number of retries | ||
*/ | ||
async function defaultBackoff(attempt: number, maxRetries: number) { | ||
const attempts = Math.min(attempt, maxRetries) | ||
|
||
const timeout = ~~((Math.random() + 0.4) * (300 << attempts)) | ||
await new Promise((resolve) => | ||
setTimeout((res: any) => resolve(res), timeout) | ||
) | ||
} | ||
|
||
const isPlainObject = (value: any) => value?.constructor === Object | ||
|
||
type QueryArgsSpec = { | ||
path: string | ||
method?: 'GET' | 'DELETE' | 'PATCH' | 'POST' | 'PUT' | 'OPTIONS' | 'HEAD' | ||
body?: any | ||
contentType?: string | ||
params?: any | ||
headers?: Record<string, string | number | undefined> | undefined | ||
} | ||
|
||
type MaybePromise<T> = T | Promise<T> | ||
|
||
type InterceptorArgs = { | ||
args: QueryArgsSpec | ||
opts: https.RequestOptions | ||
} | ||
type Interceptor = ( | ||
args: InterceptorArgs, | ||
options: Options | ||
) => MaybePromise<https.RequestOptions> | ||
|
||
const interceptors: Interceptor[] = [] | ||
|
||
type RetryConditionFunction = (extraArgs: { | ||
res?: Response | ||
error: unknown | ||
args: QueryArgsSpec | ||
attempt: number | ||
options: RetryOptions | ||
}) => boolean | Promise<boolean> | ||
|
||
type RetryOptions = { | ||
retryCondition?: RetryConditionFunction | undefined | ||
maxRetries?: number | undefined | ||
} | ||
|
||
type HttpHeaderOptions = { | ||
headers?: Record<string, string | number | undefined> | undefined | ||
} | ||
|
||
export type Options = RetryOptions & HttpHeaderOptions | ||
|
||
export async function apiClient<Response>( | ||
args: QueryArgsSpec, | ||
extraOptions?: Options | ||
): Promise<Response> { | ||
const maxRetries = extraOptions?.maxRetries ?? 3 | ||
|
||
const defaultRetryCondition: RetryConditionFunction = ({ ...obj }) => { | ||
const { res, attempt, error } = obj | ||
if (attempt > maxRetries) { | ||
return false | ||
} | ||
|
||
if (error instanceof FetchError) { | ||
return true | ||
} | ||
if (res && res.status >= 500) { | ||
return true | ||
} | ||
return false | ||
} | ||
|
||
const options = { | ||
maxRetries, | ||
backoff: defaultBackoff, | ||
retryCondition: defaultRetryCondition, | ||
...extraOptions, | ||
} | ||
|
||
let { path, method, params, body, contentType } = { ...args } | ||
|
||
let httpsOptions: https.RequestOptions = { | ||
path, | ||
headers: removeNullableProperties({ | ||
...args.headers, | ||
...options.headers, | ||
}), | ||
} | ||
if (method) { | ||
httpsOptions.method = method | ||
} | ||
|
||
for (const interceptor of interceptors) { | ||
httpsOptions = await interceptor( | ||
{ | ||
args, | ||
opts: httpsOptions, | ||
}, | ||
options | ||
) | ||
} | ||
|
||
if ( | ||
!httpsOptions.agent && | ||
(httpsOptions.ca || httpsOptions.cert || httpsOptions.key) | ||
) { | ||
const agent = new https.Agent( | ||
removeNullableProperties({ | ||
ca: httpsOptions.ca, | ||
cert: httpsOptions.cert, | ||
key: httpsOptions.key, | ||
port: httpsOptions.port ? Number(httpsOptions.port) : undefined, | ||
}) | ||
) | ||
httpsOptions.agent = agent | ||
} | ||
|
||
if (!httpsOptions.protocol) { | ||
httpsOptions.protocol = 'http:' | ||
} | ||
const host = httpsOptions.host || httpsOptions.hostname | ||
let baseUrl = `${httpsOptions.protocol}//${host}` | ||
const searchParams = toSearchParams(params) | ||
if (searchParams.size) { | ||
baseUrl += (baseUrl.includes('?') ? '&' : '?') + toSearchParams(params) | ||
} | ||
const url = new URL(baseUrl) | ||
if (httpsOptions.port) { | ||
url.port = httpsOptions.port.toString() | ||
} | ||
if (httpsOptions.path) { | ||
url.pathname = httpsOptions.path | ||
} | ||
let isJson = false | ||
if (isPlainObject(body) || Array.isArray(body)) { | ||
isJson = true | ||
body = JSON.stringify(body) | ||
} | ||
const headers: Record<string, string> = { | ||
...(httpsOptions.headers as any), | ||
} | ||
if (contentType) { | ||
headers['Content-Type'] = contentType | ||
} else if (!httpsOptions.headers?.['Content-Type'] && isJson) { | ||
headers['Content-Type'] = 'application/json' | ||
} | ||
|
||
let retry = 0 | ||
while (true) { | ||
try { | ||
const res = await fetch(url, { | ||
headers, | ||
protocol: httpsOptions.protocol || undefined, | ||
method, | ||
agent: httpsOptions.agent, | ||
body, | ||
}) | ||
|
||
const isSuccess = res.status >= 200 && res.status < 300 | ||
const contentType = res.headers.get('content-type') | ||
const isJsonResponse = contentType?.includes('application/json') ?? false | ||
|
||
if (isSuccess && isJsonResponse) { | ||
return (await res.json()) as Response | ||
} | ||
|
||
// helpful message for debugging | ||
const text = await res.text() | ||
if (res.status === 404 && text.includes('404 page not found')) { | ||
console.info( | ||
`Did you forget to install your Custom Resources Definitions? path: ${httpsOptions.path}` | ||
) | ||
} | ||
throw new Error(text) | ||
} catch (e: any) { | ||
retry++ | ||
|
||
if ( | ||
!(await options.retryCondition({ | ||
res: e?.value?.res, | ||
error: e, | ||
args, | ||
attempt: retry, | ||
options: options, | ||
})) | ||
) { | ||
throw e | ||
} | ||
|
||
await options.backoff(retry, options.maxRetries) | ||
} | ||
} | ||
} | ||
|
||
const toSearchParams = (params: Record<string, string>) => { | ||
return new URLSearchParams(removeNullableProperties(params)) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
update-swagger: | ||
#!/bin/sh | ||
# Swaggerが壊れています. 一時的に手動でパッチしています. | ||
# https://github.com/orgs/vercel/discussions/4813#discussioncomment-7539757 | ||
wget -O swagger.json https://openapi.vercel.sh/ | ||
|
||
generate-code: | ||
#!/bin/sh | ||
npx kubernetes-typescript-client-codegen-openapi ./vercel.config.ts |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import type { ConfigFile } from 'kubernetes-typescript-client-codegen-openapi' | ||
|
||
const config: ConfigFile = { | ||
schemaFile: './swagger.json', | ||
apiFile: './api-client/client.ts', | ||
outputFile: './generated/vercel.ts', | ||
strict: false, | ||
} | ||
|
||
export default config |