From eadb1a63fa71726b7760bfb92036f561398b84fb Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Thu, 24 Jul 2025 17:49:28 -0300 Subject: [PATCH 1/2] Improve render retries and rate limits --- .../secret-sync/render/render-sync-fns.ts | 124 ++++++++++++------ 1 file changed, 83 insertions(+), 41 deletions(-) diff --git a/backend/src/services/secret-sync/render/render-sync-fns.ts b/backend/src/services/secret-sync/render/render-sync-fns.ts index 9140136a0..dffac1a8e 100644 --- a/backend/src/services/secret-sync/render/render-sync-fns.ts +++ b/backend/src/services/secret-sync/render/render-sync-fns.ts @@ -8,7 +8,22 @@ import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types"; -const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredentials) => { +const MAX_RETRIES = 5; + +const sleep = async () => + new Promise((resolve) => { + setTimeout(resolve, 2000); + }); + +const retrySleep = async () => + new Promise((resolve) => { + setTimeout(resolve, 60000); + }); + +const getRenderEnvironmentSecrets = async ( + secretSync: TRenderSyncWithCredentials, + attempt = 0 +): Promise => { const { destinationConfig, connection: { @@ -22,35 +37,48 @@ const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredential do { const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl; - const { data } = await request.get< - { - envVar: { - key: string; - value: string; - }; - cursor: string; - }[] - >(url, { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" + + try { + const { data } = await request.get< + { + envVar: { + key: string; + value: string; + }; + cursor: string; + }[] + >(url, { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + }); + + const secrets = data.map((item) => ({ + key: item.envVar.key, + value: item.envVar.value + })); + + allSecrets.push(...secrets); + cursor = data[data.length - 1]?.cursor; + } catch (error) { + if (isAxiosError(error) && error.response?.status === 429 && attempt < MAX_RETRIES) { + await retrySleep(); + return await getRenderEnvironmentSecrets(secretSync, attempt + 1); } - }); - - const secrets = data.map((item) => ({ - key: item.envVar.key, - value: item.envVar.value - })); - - allSecrets.push(...secrets); - - cursor = data[data.length - 1]?.cursor; + throw error; + } } while (cursor); return allSecrets; }; -const putEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, secretMap: TSecretMap, key: string) => { +const putEnvironmentSecret = async ( + secretSync: TRenderSyncWithCredentials, + secretMap: TSecretMap, + key: string, + attempt = 0 +): Promise => { const { destinationConfig, connection: { @@ -58,22 +86,34 @@ const putEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, secr } } = secretSync; - await request.put( - `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars/${key}`, - { - key, - value: secretMap[key].value - }, - { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" + try { + await request.put( + `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars/${key}`, + { + key, + value: secretMap[key].value + }, + { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } } + ); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 429 && attempt < MAX_RETRIES) { + await retrySleep(); + return await putEnvironmentSecret(secretSync, secretMap, key, attempt + 1); } - ); + throw error; + } }; -const deleteEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, secret: Pick) => { +const deleteEnvironmentSecret = async ( + secretSync: TRenderSyncWithCredentials, + secret: Pick, + attempt = 0 +): Promise => { const { destinationConfig, connection: { @@ -97,18 +137,19 @@ const deleteEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, s return; } + if (isAxiosError(error) && error.response?.status === 429 && attempt < MAX_RETRIES) { + await retrySleep(); + return await deleteEnvironmentSecret(secretSync, secret, attempt + 1); + } + throw error; } }; -const sleep = async () => - new Promise((resolve) => { - setTimeout(resolve, 500); - }); - export const RenderSyncFns = { syncSecrets: async (secretSync: TRenderSyncWithCredentials, secretMap: TSecretMap) => { const renderSecrets = await getRenderEnvironmentSecrets(secretSync); + for await (const key of Object.keys(secretMap)) { // If value is empty skip it as render does not allow empty variables if (secretMap[key].value === "") { @@ -132,6 +173,7 @@ export const RenderSyncFns = { } } }, + getSecrets: async (secretSync: TRenderSyncWithCredentials): Promise => { const renderSecrets = await getRenderEnvironmentSecrets(secretSync); return Object.fromEntries(renderSecrets.map((secret) => [secret.key, { value: secret.value ?? "" }])); From c0317367017029b20b3cd4baa26df41e4ccf37be Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Thu, 24 Jul 2025 18:51:44 -0300 Subject: [PATCH 2/2] Improve render api usage --- .../secret-sync/render/render-sync-fns.ts | 177 +++++++----------- 1 file changed, 70 insertions(+), 107 deletions(-) diff --git a/backend/src/services/secret-sync/render/render-sync-fns.ts b/backend/src/services/secret-sync/render/render-sync-fns.ts index dffac1a8e..36e97e620 100644 --- a/backend/src/services/secret-sync/render/render-sync-fns.ts +++ b/backend/src/services/secret-sync/render/render-sync-fns.ts @@ -10,20 +10,24 @@ import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types"; const MAX_RETRIES = 5; -const sleep = async () => - new Promise((resolve) => { - setTimeout(resolve, 2000); - }); - const retrySleep = async () => new Promise((resolve) => { setTimeout(resolve, 60000); }); -const getRenderEnvironmentSecrets = async ( - secretSync: TRenderSyncWithCredentials, - attempt = 0 -): Promise => { +const makeRequestWithRetry = async (requestFn: () => Promise, attempt = 0): Promise => { + try { + return await requestFn(); + } catch (error) { + if (isAxiosError(error) && error.response?.status === 429 && attempt < MAX_RETRIES) { + await retrySleep(); + return await makeRequestWithRetry(requestFn, attempt + 1); + } + throw error; + } +}; + +const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredentials): Promise => { const { destinationConfig, connection: { @@ -38,8 +42,8 @@ const getRenderEnvironmentSecrets = async ( do { const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl; - try { - const { data } = await request.get< + const { data } = await makeRequestWithRetry(() => + request.get< { envVar: { key: string; @@ -52,32 +56,29 @@ const getRenderEnvironmentSecrets = async ( Authorization: `Bearer ${apiKey}`, Accept: "application/json" } - }); + }) + ); - const secrets = data.map((item) => ({ - key: item.envVar.key, - value: item.envVar.value - })); + const secrets = data.map((item) => ({ + key: item.envVar.key, + value: item.envVar.value + })); - allSecrets.push(...secrets); - cursor = data[data.length - 1]?.cursor; - } catch (error) { - if (isAxiosError(error) && error.response?.status === 429 && attempt < MAX_RETRIES) { - await retrySleep(); - return await getRenderEnvironmentSecrets(secretSync, attempt + 1); - } - throw error; + allSecrets.push(...secrets); + + if (data.length > 0 && data[data.length - 1]?.cursor) { + cursor = data[data.length - 1].cursor; + } else { + cursor = undefined; } } while (cursor); return allSecrets; }; -const putEnvironmentSecret = async ( +const batchUpdateEnvironmentSecrets = async ( secretSync: TRenderSyncWithCredentials, - secretMap: TSecretMap, - key: string, - attempt = 0 + envVars: Array<{ key: string; value: string }> ): Promise => { const { destinationConfig, @@ -86,92 +87,50 @@ const putEnvironmentSecret = async ( } } = secretSync; - try { - await request.put( - `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars/${key}`, - { - key, - value: secretMap[key].value - }, - { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" - } + await makeRequestWithRetry(() => + request.put(`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`, envVars, { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" } - ); - } catch (error) { - if (isAxiosError(error) && error.response?.status === 429 && attempt < MAX_RETRIES) { - await retrySleep(); - return await putEnvironmentSecret(secretSync, secretMap, key, attempt + 1); - } - throw error; - } -}; - -const deleteEnvironmentSecret = async ( - secretSync: TRenderSyncWithCredentials, - secret: Pick, - attempt = 0 -): Promise => { - const { - destinationConfig, - connection: { - credentials: { apiKey } - } - } = secretSync; - - try { - await request.delete( - `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars/${secret.key}`, - { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" - } - } - ); - } catch (error) { - if (isAxiosError(error) && error.response?.status === 404) { - // If the secret does not exist, we can ignore this error - return; - } - - if (isAxiosError(error) && error.response?.status === 429 && attempt < MAX_RETRIES) { - await retrySleep(); - return await deleteEnvironmentSecret(secretSync, secret, attempt + 1); - } - - throw error; - } + }) + ); }; export const RenderSyncFns = { syncSecrets: async (secretSync: TRenderSyncWithCredentials, secretMap: TSecretMap) => { const renderSecrets = await getRenderEnvironmentSecrets(secretSync); - for await (const key of Object.keys(secretMap)) { - // If value is empty skip it as render does not allow empty variables - if (secretMap[key].value === "") { + const finalEnvVars: Array<{ key: string; value: string }> = []; + + for (const renderSecret of renderSecrets) { + const shouldKeep = + secretMap[renderSecret.key] || + (secretSync.syncOptions.disableSecretDeletion && + !matchesSchema(renderSecret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)); + + if (shouldKeep && !secretMap[renderSecret.key]) { + finalEnvVars.push({ + key: renderSecret.key, + value: renderSecret.value + }); + } + } + + for (const [key, secret] of Object.entries(secretMap)) { + // Skip empty values as render does not allow empty variables + if (secret.value === "") { // eslint-disable-next-line no-continue continue; } - await putEnvironmentSecret(secretSync, secretMap, key); - await sleep(); + + finalEnvVars.push({ + key, + value: secret.value + }); } - if (secretSync.syncOptions.disableSecretDeletion) return; - - for await (const renderSecret of renderSecrets) { - if (!matchesSchema(renderSecret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) - // eslint-disable-next-line no-continue - continue; - - if (!secretMap[renderSecret.key]) { - await deleteEnvironmentSecret(secretSync, renderSecret); - await sleep(); - } - } + await batchUpdateEnvironmentSecrets(secretSync, finalEnvVars); }, getSecrets: async (secretSync: TRenderSyncWithCredentials): Promise => { @@ -180,13 +139,17 @@ export const RenderSyncFns = { }, removeSecrets: async (secretSync: TRenderSyncWithCredentials, secretMap: TSecretMap) => { - const encryptedSecrets = await getRenderEnvironmentSecrets(secretSync); + const renderSecrets = await getRenderEnvironmentSecrets(secretSync); + const finalEnvVars: Array<{ key: string; value: string }> = []; - for await (const encryptedSecret of encryptedSecrets) { - if (encryptedSecret.key in secretMap) { - await deleteEnvironmentSecret(secretSync, encryptedSecret); - await sleep(); + for (const renderSecret of renderSecrets) { + if (!(renderSecret.key in secretMap)) { + finalEnvVars.push({ + key: renderSecret.key, + value: renderSecret.value + }); } } + await batchUpdateEnvironmentSecrets(secretSync, finalEnvVars); } };