diff --git a/packages/euler-v2-sdk/docs/basic-usage.md b/packages/euler-v2-sdk/docs/basic-usage.md index da5e3af..4b42cf4 100644 --- a/packages/euler-v2-sdk/docs/basic-usage.md +++ b/packages/euler-v2-sdk/docs/basic-usage.md @@ -7,7 +7,7 @@ import { buildEulerSDK } from '@eulerxyz/euler-v2-sdk' // Set EULER_SDK_RPC_URL_1=https://... in the environment for on-chain reads. const sdk = await buildEulerSDK({ - queryCacheConfig: { ttlMs: 10_000 }, // Optional: default cache is 5s + queryCacheConfig: { ttlMs: 10_000 }, // Optional: default success/failure cache is 5s }) ``` diff --git a/packages/euler-v2-sdk/docs/caching-external-data-queries.md b/packages/euler-v2-sdk/docs/caching-external-data-queries.md index 2fe698f..50b2496 100644 --- a/packages/euler-v2-sdk/docs/caching-external-data-queries.md +++ b/packages/euler-v2-sdk/docs/caching-external-data-queries.md @@ -35,7 +35,7 @@ Pass `buildQuery` once when building the SDK and it propagates to every service ```typescript const sdk = await buildEulerSDK({ - queryCacheConfig: { ttlMs: 5000 }, // Optional: default is enabled with a 5s TTL + queryCacheConfig: { ttlMs: 5000 }, // Optional: default success/failure cache is 5s buildQuery: myBuildQueryFn, plugins: [createPythPlugin({ buildQuery: myBuildQueryFn })], }) @@ -50,12 +50,16 @@ Configure the built-in cache through `queryCacheConfig`: ```typescript const sdk = await buildEulerSDK({ queryCacheConfig: { - enabled: true, // default - ttlMs: 5000, // default + enabled: true, // default + ttlMs: 5000, // default success TTL + failureTtlMs: 5000, // default failure TTL }, }) ``` +Set `failureTtlMs: 0` when a consumer needs every failed query call to reach +the underlying transport immediately. + Disable it entirely: ```typescript diff --git a/packages/euler-v2-sdk/docs/configuration.md b/packages/euler-v2-sdk/docs/configuration.md index 00a4c45..34173b7 100644 --- a/packages/euler-v2-sdk/docs/configuration.md +++ b/packages/euler-v2-sdk/docs/configuration.md @@ -74,7 +74,7 @@ Use explicit nested service config when the option is a function or a custom obj | `rewardsServiceConfig` | `v3` adapter with direct Brevis/Fuul helper reads | Reward campaign data, per-user rewards, and reward claim planning | | `intrinsicApyServiceConfig` | V3 intrinsic APY API | Underlying yield data for vault assets | | `buildQuery` | 5s in-memory cache | Wrap all external queries for caching, logging, or profiling | -| `queryCacheConfig` | `{ enabled: true, ttlMs: 5000 }` | Built-in query cache settings when `buildQuery` is not supplied | +| `queryCacheConfig` | `{ enabled: true, ttlMs: 5000, failureTtlMs: 5000 }` | Built-in query cache settings when `buildQuery` is not supplied | | `plugins` | `[]` | Extend on-chain reads and transaction plans | | `servicesOverrides` | `{}` | Replace any built-in service with a custom implementation | @@ -84,7 +84,7 @@ When `accountServiceConfig.adapter`, `eVaultServiceConfig.adapter`, `eulerEarnSe Adapter-specific keys override the shared key within the same configuration layer. Layer priority still applies, so `config.pricingApiKey` overrides `pricingServiceConfig.apiKey`, and `pricingServiceConfig.apiKey` overrides `EULER_SDK_PRICING_API_KEY`. -`vaultTypeAdapterConfig` defaults to the V3 `POST /v3/evk/vaults/resolve` endpoint. Pass subgraph config when vault type resolution should use subgraphs: +`vaultTypeAdapterConfig` defaults to the V3 `POST /v3/resolve/vaults` endpoint. Pass subgraph config when vault type resolution should use subgraphs: ```typescript vaultTypeAdapterConfig: { diff --git a/packages/euler-v2-sdk/docs/data-architecture.md b/packages/euler-v2-sdk/docs/data-architecture.md index 9eb2d89..a111b3c 100644 --- a/packages/euler-v2-sdk/docs/data-architecture.md +++ b/packages/euler-v2-sdk/docs/data-architecture.md @@ -180,7 +180,7 @@ See [Cross-Service Data Population](./cross-service-data-population.md) for the The resolved query decorator is selected as: 1. Consumer-provided `buildQuery`, if present -2. Otherwise the built-in in-memory cache from `queryCacheConfig` (enabled by default with `ttlMs: 5000`) +2. Otherwise the built-in in-memory cache from `queryCacheConfig` (enabled by default with `ttlMs: 5000` and `failureTtlMs: 5000`) This means custom query decorators replace the SDK's default cache rather than layering on top of it automatically. diff --git a/packages/euler-v2-sdk/src/sdk/buildSDK.ts b/packages/euler-v2-sdk/src/sdk/buildSDK.ts index 4972975..88d8027 100644 --- a/packages/euler-v2-sdk/src/sdk/buildSDK.ts +++ b/packages/euler-v2-sdk/src/sdk/buildSDK.ts @@ -210,7 +210,7 @@ export interface BuildSDKOptions< intrinsicApyServiceConfig?: IntrinsicApyServiceConfig; oracleAdapterServiceConfig?: OracleAdapterServiceConfig; feeFlowServiceConfig?: FeeFlowServiceConfig; - /** Default in-memory cache applied to all decorated `query*` methods. Enabled by default with a 5s TTL. */ + /** Default in-memory cache applied to all decorated `query*` methods. Enabled by default with 5s success and failure TTLs. */ queryCacheConfig?: QueryCacheConfig; /** Optional query decorator applied to all query* functions across all services. Use for global logging, caching, profiling, etc. */ buildQuery?: BuildQueryFn; @@ -1362,7 +1362,8 @@ export async function buildEulerSDK< rewardsV3Adapter.fetchUserRewards(chainId, address), ]); if (v3Result.status !== "fulfilled") { - if (directResult.status === "fulfilled") return directResult.value; + if (directResult.status === "fulfilled") + return directResult.value; throw directResult.reason; } const directRewards = diff --git a/packages/euler-v2-sdk/src/utils/buildQuery.ts b/packages/euler-v2-sdk/src/utils/buildQuery.ts index 288b5e2..fb6bdcb 100644 --- a/packages/euler-v2-sdk/src/utils/buildQuery.ts +++ b/packages/euler-v2-sdk/src/utils/buildQuery.ts @@ -20,9 +20,11 @@ export type BuildQueryFn = Promise>( export interface QueryCacheConfig { enabled?: boolean; ttlMs?: number; + failureTtlMs?: number; } const DEFAULT_QUERY_CACHE_TTL_MS = 5_000; +const DEFAULT_QUERY_FAILURE_TTL_MS = 5_000; function normalizeAddress(value: string): string { if (/^0x[0-9a-fA-F]{40}$/.test(value)) { @@ -108,6 +110,7 @@ export function createQueryCacheBuildQuery( ): BuildQueryFn { const enabled = config?.enabled ?? true; const ttlMs = config?.ttlMs ?? DEFAULT_QUERY_CACHE_TTL_MS; + const failureTtlMs = config?.failureTtlMs ?? DEFAULT_QUERY_FAILURE_TTL_MS; return Promise>( queryName: string, @@ -123,11 +126,13 @@ export function createQueryCacheBuildQuery( expiresAt: number; value?: Awaited>; promise?: Promise>>; + error?: unknown; } >(); const wrapped = (async (...args: Parameters) => { - const cacheKey = context?.getCacheKey(args) ?? getEulerSdkQueryKey(queryName, args); + const cacheKey = + context?.getCacheKey(args) ?? getEulerSdkQueryKey(queryName, args); if (cacheKey === null) { return fn(...args); } @@ -137,6 +142,7 @@ export function createQueryCacheBuildQuery( if (cached && cached.expiresAt > now) { if (cached.promise) return cached.promise; if ("value" in cached) return cached.value as Awaited>; + if ("error" in cached) throw cached.error; } const promise = fn(...args) @@ -150,7 +156,14 @@ export function createQueryCacheBuildQuery( .catch((error) => { const current = cache.get(cacheKey); if (current?.promise === promise) { - cache.delete(cacheKey); + if (failureTtlMs > 0) { + cache.set(cacheKey, { + error, + expiresAt: Date.now() + failureTtlMs, + }); + } else { + cache.delete(cacheKey); + } } throw error; }); diff --git a/packages/euler-v2-sdk/test/readPathInfra.test.ts b/packages/euler-v2-sdk/test/readPathInfra.test.ts index 26c5edf..fd39002 100644 --- a/packages/euler-v2-sdk/test/readPathInfra.test.ts +++ b/packages/euler-v2-sdk/test/readPathInfra.test.ts @@ -91,7 +91,7 @@ function makeDeployment(chainId = 1) { } as const; } -test("buildQuery cache dedupes, clears rejected promises, and decorate query methods", async () => { +test("buildQuery cache dedupes, short-caches failures, and decorate query methods", async () => { let runs = 0; const cached = createQueryCacheBuildQuery({ ttlMs: 60_000 })( "queryExample", @@ -114,7 +114,48 @@ test("buildQuery cache dedupes, clears rejected promises, and decorate query met await assert.rejects(() => cached("boom"), /boom/); await assert.rejects(() => cached("boom"), /boom/); - assert.equal(runs, 6); + assert.equal(runs, 5); + + const retryableFailures = createQueryCacheBuildQuery({ + failureTtlMs: 0, + ttlMs: 60_000, + })( + "queryRetryableFailures", + async () => { + runs += 1; + throw new Error("retryable"); + }, + {}, + ); + await assert.rejects(() => retryableFailures(), /retryable/); + await assert.rejects(() => retryableFailures(), /retryable/); + assert.equal(runs, 7); + + const originalNow = Date.now; + try { + let now = 1_000; + Date.now = () => now; + let cooledRuns = 0; + const cooledFailures = createQueryCacheBuildQuery({ + failureTtlMs: 10, + ttlMs: 60_000, + })( + "queryCooledFailures", + async () => { + cooledRuns += 1; + throw new Error("cooled"); + }, + {}, + ); + await assert.rejects(() => cooledFailures(), /cooled/); + await assert.rejects(() => cooledFailures(), /cooled/); + assert.equal(cooledRuns, 1); + now += 11; + await assert.rejects(() => cooledFailures(), /cooled/); + assert.equal(cooledRuns, 2); + } finally { + Date.now = originalNow; + } const passthrough = createQueryCacheBuildQuery({ enabled: false })( "queryDisabled",