diff --git a/docs/components/endpoints.md b/docs/components/endpoints.md index a342c100..357c7b99 100644 --- a/docs/components/endpoints.md +++ b/docs/components/endpoints.md @@ -62,6 +62,29 @@ export type BaseEndpointTypes = { Transport files extend BaseEndpointTypes and additionally provide DP specific information like DP request/response type or websocket message types are defined in corresponding transport files. +## Multiple transports and fallback + +For endpoints registered with `transportRoutes`, routing still picks a **primary** transport using `customRouter`, the request `transport` field, or `defaultTransport`. + +Optional **fallback** transports are configured per primary route name with `fallbackTransport`, a map from primary transport name to fallback transport name (both must exist on `transportRoutes`). This is **off by default** until the adapter setting `TRANSPORT_FALLBACK_ENABLED` is set to `true` (see [EA settings](../reference-tables/ea-settings.md)). + +When enabled, the framework may start work on the fallback route in parallel with the primary, but the **response still prefers primary data**: the client waits for the primary cache / foreground / polling path to finish or time out before using a successful fallback result. Separate cache keys are used per transport so cached values do not collide. + +Fallback is not supported together with a custom `cacheKeyGenerator` on the same endpoint; use the default cache key behavior if you need fallback. + +```typescript +new AdapterEndpoint({ + name: 'price', + inputParameters, + transportRoutes: new TransportRoutes() + .register('rest', httpTransport) + .register('ws', wsTransport), + defaultTransport: 'rest', + // When primary is `rest`, allow falling back to `ws` + fallbackTransport: { rest: 'ws' }, +}) +``` + ## Cache Key Generator **Only use if absolutely necessary** diff --git a/docs/reference-tables/ea-settings.md b/docs/reference-tables/ea-settings.md index 93ae62bc..f2203f83 100644 --- a/docs/reference-tables/ea-settings.md +++ b/docs/reference-tables/ea-settings.md @@ -61,6 +61,7 @@ | TLS_PASSPHRASE | string | | Password to be used to generate an encryption key | | | | TLS_PRIVATE_KEY | string | undefined | Base64 Private Key of TSL/SSL certificate | - Value must be a valid base64 string | | | TLS_PUBLIC_KEY | string | undefined | Base64 Public Key of TSL/SSL certificate | - Value must be a valid base64 string | | +| TRANSPORT_FALLBACK_ENABLED | boolean | false | Flag to enable endpoint fallback transports when configured | | | | WARMUP_SUBSCRIPTION_TTL | number | 300000 | TTL for batch warmer subscriptions | - Value must be an integer
- Value must be above the minimum
- Value must be below the maximum | 0 | 3600000 | | WS_CONNECTION_OPEN_TIMEOUT | number | 10000 | The maximum amount of time in milliseconds to wait for the websocket connection to open (including custom open handler) | - Value must be an integer
- Value must be above the minimum
- Value must be below the maximum | 500 | 30000 | | WS_HEARTBEAT_INTERVAL_MS | number | 10000 | The number of ms between each hearbeat message that EA sends to server, only works if heartbeat handler is provided | - Value must be an integer
- Value must be above the minimum
- Value must be below the maximum | 5000 | 300000 | diff --git a/src/adapter/basic.ts b/src/adapter/basic.ts index 2cfe01bd..b1d9c363 100644 --- a/src/adapter/basic.ts +++ b/src/adapter/basic.ts @@ -15,6 +15,7 @@ import { highestRateLimitTiers, } from '../rate-limiting' import { RateLimiterFactory, RateLimitingStrategy } from '../rate-limiting/factory' +import { Transport } from '../transports' import { AdapterRequest, AdapterResponse, @@ -449,6 +450,44 @@ export class Adapter< const endpoint = this.endpointsMap[req.requestContext.endpointName] const transport = endpoint.transportRoutes.get(req.requestContext.transportName) + const { fallback } = req.requestContext + if (!fallback) { + return this.handleSingleTransportRequest(req, replySent, transport) + } + + const fallbackReq = { + ...req, + requestContext: { + ...req.requestContext, + transportName: fallback.transportName, + cacheKey: fallback.cacheKey, + }, + } as AdapterRequest + + const fallbackResponsePromise = this.handleSingleTransportRequest( + fallbackReq, + replySent, + endpoint.transportRoutes.get(fallback.transportName), + ) + .then((response) => ({ response })) + .catch((error) => ({ error })) + + try { + return await this.handleSingleTransportRequest(req, replySent, transport) + } catch (primaryError) { + const fallbackResponse = await fallbackResponsePromise + if ('response' in fallbackResponse) { + return fallbackResponse.response + } + throw primaryError + } + } + + private async handleSingleTransportRequest( + req: AdapterRequest, + replySent: Promise, + transport: Transport, + ): Promise> { // First try to find the response in our cache, keep it ready const cachedResponse = await this.findResponseInCache(req) diff --git a/src/adapter/endpoint.ts b/src/adapter/endpoint.ts index e64ca34a..85a623be 100644 --- a/src/adapter/endpoint.ts +++ b/src/adapter/endpoint.ts @@ -46,6 +46,7 @@ export class AdapterEndpoint implements AdapterEndpo settings: T['Settings'], ) => string defaultTransport?: string + fallbackTransport?: Record constructor(params: AdapterEndpointParams) { this.name = params.name @@ -55,6 +56,14 @@ export class AdapterEndpoint implements AdapterEndpo this.transportRoutes = params.transportRoutes this.customRouter = params.customRouter this.defaultTransport = params.defaultTransport + this.fallbackTransport = params.fallbackTransport + ? Object.fromEntries( + Object.entries(params.fallbackTransport).map(([k, v]) => [ + k.toLowerCase(), + v.toLowerCase(), + ]), + ) + : undefined } else { this.transportRoutes = new TransportRoutes().register( DEFAULT_TRANSPORT_NAME, @@ -69,6 +78,8 @@ export class AdapterEndpoint implements AdapterEndpo this.customOutputValidation = params.customOutputValidation this.overrides = params.overrides this.requestTransforms = [this.symbolOverrider.bind(this), ...(params.requestTransforms || [])] + + this.validateFallbackTransport() } /** @@ -186,6 +197,22 @@ export class AdapterEndpoint implements AdapterEndpo return transportName } + getFallbackTransportNameForRequest(primaryTransportName: string, settings: T['Settings']) { + if (!settings.TRANSPORT_FALLBACK_ENABLED || !this.fallbackTransport) { + logger.trace('TRANSPORT_FALLBACK_ENABLED is false or fallbackTransport is not set') + return + } + + const fallbackTransportName = this.fallbackTransport[primaryTransportName.toLowerCase()] + + if (fallbackTransportName) { + logger.debug(`Request can fall back to transport "${fallbackTransportName}"`) + return fallbackTransportName + } else { + logger.trace(`No fallback transport defined for "${primaryTransportName}"`) + } + } + /** * Default routing strategy. Will try and use the transport override if present * or transport input parameter in the request body. @@ -205,4 +232,40 @@ export class AdapterEndpoint implements AdapterEndpo } return rawRequestBody.data?.transport } + + private validateFallbackTransport() { + if (this.cacheKeyGenerator && this.fallbackTransport) { + throw new AdapterError({ + message: 'fallbackTransport not allowed for endpoints with cacheKeyGenerator', + statusCode: 404, + }) + } + + Object.entries(this.fallbackTransport || {}).forEach(([primary, fallback]) => { + if (primary === fallback) { + throw new AdapterError({ + statusCode: 400, + message: `Fallback transport "${fallback}" cannot be the same as primary transport.`, + }) + } + + if (!this.transportRoutes.get(primary)) { + throw new AdapterError({ + statusCode: 400, + message: `No primary transport found for key "${primary}", must be one of ${JSON.stringify( + this.transportRoutes.routeNames(), + )}`, + }) + } + + if (!this.transportRoutes.get(fallback)) { + throw new AdapterError({ + statusCode: 400, + message: `No fallback transport found for key "${fallback}", must be one of ${JSON.stringify( + this.transportRoutes.routeNames(), + )}`, + }) + } + }) + } } diff --git a/src/adapter/types.ts b/src/adapter/types.ts index 8d52246c..ac228a32 100644 --- a/src/adapter/types.ts +++ b/src/adapter/types.ts @@ -183,6 +183,9 @@ type MultiTransportAdapterEndpointParams = { /** If no value is returned from the custom router or the default (transport param), which transport to use */ defaultTransport?: string + + /** Primary transport mapped to backup transport to use when primary is unable to return data */ + fallbackTransport?: Record } /** diff --git a/src/cache/response.ts b/src/cache/response.ts index 2fe961e4..a0c96d8e 100644 --- a/src/cache/response.ts +++ b/src/cache/response.ts @@ -100,6 +100,7 @@ export class ResponseCache< ) { response.meta = { adapterName: calculateAdapterName(this.adapterName, r.params), + transportName, metrics: { feedId: calculateFeedId( { diff --git a/src/config/index.ts b/src/config/index.ts index ad6e6df7..33d0db1b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -276,6 +276,11 @@ export const BaseSettingsDefinition = { default: 200, validate: validator.integer({ min: 10, max: 1000 }), }, + TRANSPORT_FALLBACK_ENABLED: { + description: 'Flag to enable endpoint fallback transports when configured', + type: 'boolean', + default: false, + }, DEFAULT_CACHE_KEY: { description: 'Default key to be used when one cannot be determined from request parameters', type: 'string', diff --git a/src/util/types.ts b/src/util/types.ts index d2d64b63..2cc799de 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -38,6 +38,12 @@ export type AdapterRequestContext = { /** Precalculated cache key used to get and set corresponding values from the cache and subscription sets */ cacheKey: string + /** Fallback transport context to use if the primary transport is unable to return data */ + fallback?: { + transportName: string + cacheKey: string + } + /** Normalized and validated data coming from the request body */ data: T @@ -86,6 +92,8 @@ export interface AdapterRequestMeta { export interface AdapterResponseMeta extends AdapterRequestMeta { /** Name of the adapter */ adapterName: string + /** Name of the transport */ + transportName: string } /** diff --git a/src/validation/index.ts b/src/validation/index.ts index 0ff9ba7e..17ee13f8 100644 --- a/src/validation/index.ts +++ b/src/validation/index.ts @@ -125,14 +125,31 @@ export const validatorMiddleware: AdapterMiddlewareBuilder = req.requestContext.cacheKey = `${cachePrefix}${cacheKey}` } else { - const transportName = req.requestContext.transportName - req.requestContext.cacheKey = calculateCacheKey({ + const commonCacheKeyParams = { data: req.requestContext.data, adapterName: adapter.name, endpointName: endpoint.name, - transportName, adapterSettings: adapter.config.settings, + } + + req.requestContext.cacheKey = calculateCacheKey({ + ...commonCacheKeyParams, + transportName: req.requestContext.transportName, }) + + const fallbackTransportName = endpoint.getFallbackTransportNameForRequest( + req.requestContext.transportName, + adapter.config.settings, + ) + if (fallbackTransportName) { + req.requestContext.fallback = { + transportName: fallbackTransportName, + cacheKey: calculateCacheKey({ + ...commonCacheKeyParams, + transportName: fallbackTransportName, + }), + } + } } done() diff --git a/test/adapter/transport-fallback.test.ts b/test/adapter/transport-fallback.test.ts new file mode 100644 index 00000000..feeadc6e --- /dev/null +++ b/test/adapter/transport-fallback.test.ts @@ -0,0 +1,171 @@ +import untypedTest, { TestFn } from 'ava' +import { Adapter, AdapterEndpoint } from '../../src/adapter' +import { AdapterConfig } from '../../src/config' +import { TransportRoutes } from '../../src/transports' +import { NopTransport, NopTransportTypes, TestAdapter } from '../../src/util/testing-utils' + +const test = untypedTest as TestFn<{ testAdapter: TestAdapter }> + +test.afterEach(async (t) => { + await t.context.testAdapter?.api.close() +}) + +function tsResponse(result: unknown) { + return { + data: null, + statusCode: 200, + result: result as null, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: 0, + }, + } +} + +function buildFallbackAdapter( + primary?: () => Promise>, + fallback?: () => Promise>, +) { + const config = new AdapterConfig( + {}, + { + envDefaultOverrides: { + TRANSPORT_FALLBACK_ENABLED: true, + }, + }, + ) + return new Adapter({ + name: 'TEST', + defaultEndpoint: 'price', + config, + endpoints: [ + new AdapterEndpoint({ + name: 'price', + transportRoutes: new TransportRoutes() + .register( + 'primary', + primary + ? new (class extends NopTransport { + override async foregroundExecute() { + return primary() + } + })() + : new NopTransport(), + ) + .register( + 'fallback', + fallback + ? new (class extends NopTransport { + override async foregroundExecute() { + return fallback() + } + })() + : new NopTransport(), + ), + defaultTransport: 'primary', + fallbackTransport: { primary: 'fallback' }, + }), + ], + }) +} + +test.serial('uses primary when both primary and fallback succeed', async (t) => { + const adapter = buildFallbackAdapter( + () => Promise.resolve(tsResponse('primary')), + () => Promise.resolve(tsResponse('fallback')), + ) + const testAdapter = await TestAdapter.start(adapter, t.context) + + const response = await testAdapter.request({}) + + t.is(response.statusCode, 200) + t.is(response.json().result, 'primary') +}) + +test.serial('uses primary when fallback stalls', async (t) => { + const adapter = buildFallbackAdapter( + () => Promise.resolve(tsResponse('primary')), + async () => { + await new Promise((r) => { + setTimeout(r, 500) + }) + return tsResponse('fallback') + }, + ) + const testAdapter = await TestAdapter.start(adapter, t.context) + const started = Date.now() + + const response = await testAdapter.request({}) + + t.is(response.statusCode, 200) + t.is(response.json().result, 'primary') + t.true(Date.now() - started < 200, 'should not wait for slow fallback path') +}) + +test.serial('uses primary when fallback failed', async (t) => { + const adapter = buildFallbackAdapter( + () => Promise.resolve(tsResponse('primary')), + () => Promise.reject(new Error('Fallback fail')), + ) + const testAdapter = await TestAdapter.start(adapter, t.context) + + const response = await testAdapter.request({}) + + t.is(response.statusCode, 200) + t.is(response.json().result, 'primary') +}) + +test.serial('uses primary when primary stalls', async (t) => { + const adapter = buildFallbackAdapter( + async () => { + await new Promise((r) => { + setTimeout(r, 500) + }) + return tsResponse('primary') + }, + () => Promise.resolve(tsResponse('fallback')), + ) + const testAdapter = await TestAdapter.start(adapter, t.context) + + const response = await testAdapter.request({}) + + t.is(response.statusCode, 200) + t.is(response.json().result, 'primary') +}) + +test.serial('uses fallback when primary throw exception', async (t) => { + const adapter = buildFallbackAdapter( + () => Promise.reject(new Error('Primary fail')), + () => Promise.resolve(tsResponse('fallback')), + ) + const testAdapter = await TestAdapter.start(adapter, t.context) + + const response = await testAdapter.request({}) + + t.is(response.statusCode, 200) + t.is(response.json().result, 'fallback') +}) + +test.serial('uses fallback when primary does not return data', async (t) => { + const adapter = buildFallbackAdapter(undefined, () => Promise.resolve(tsResponse('fallback'))) + const testAdapter = await TestAdapter.start(adapter, t.context) + + const response = await testAdapter.request({}) + + t.is(response.statusCode, 200) + t.is(response.json().result, 'fallback') +}) + +test.serial('returns timeout when primary and fallback both fail', async (t) => { + const adapter = buildFallbackAdapter() + const testAdapter = await TestAdapter.start(adapter, t.context) + + const response = await testAdapter.request({}) + + t.is(response.statusCode, 504) + t.is( + response.json().error.message, + 'The EA has not received any values from the Data Provider for the requested data yet. Retry after a short delay, and if the problem persists raise this issue in the relevant channels.', + ) +}) diff --git a/test/cache/cache-key.test.ts b/test/cache/cache-key.test.ts index 479e7e4d..6852939a 100644 --- a/test/cache/cache-key.test.ts +++ b/test/cache/cache-key.test.ts @@ -2,6 +2,7 @@ import untypedTest, { TestFn } from 'ava' import { Adapter, AdapterEndpoint, EndpointGenerics } from '../../src/adapter' import { Cache, calculateCacheKey } from '../../src/cache' import { AdapterConfig, BaseAdapterSettings, BaseSettingsDefinition } from '../../src/config' +import { TransportRoutes } from '../../src/transports' import { AdapterRequest, AdapterResponse } from '../../src/util' import { NopTransport, NopTransportTypes, TestAdapter } from '../../src/util/testing-utils' import { InputParameters } from '../../src/validation' @@ -172,6 +173,58 @@ test.serial('custom cache key', async (t) => { t.is(response.json().result, 'test:custom_cache_key') }) +test.serial('builds fallback cache key from fallback transport name', async (t) => { + const config = new AdapterConfig( + {}, + { + envDefaultOverrides: { + TRANSPORT_FALLBACK_ENABLED: true, + }, + }, + ) + const adapter = new Adapter({ + name: 'TEST', + defaultEndpoint: 'test-fallback-cache-key', + config, + endpoints: [ + new AdapterEndpoint({ + name: 'test-fallback-cache-key', + transportRoutes: new TransportRoutes() + .register( + 'primary', + new (class extends NopTransport { + override async foregroundExecute( + req: AdapterRequest, + ) { + return { + data: null, + statusCode: 200, + result: req.requestContext.fallback?.cacheKey as unknown as null, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + } + } + })(), + ) + .register('fallback', new NopTransport()), + defaultTransport: 'primary', + fallbackTransport: { primary: 'fallback' }, + }), + ], + }) + const testAdapter = await TestAdapter.start(adapter, t.context) + + const response = await testAdapter.request({}) + + t.is( + response.json().result, + `TEST-test-fallback-cache-key-fallback-${BaseSettingsDefinition.DEFAULT_CACHE_KEY.default}`, + ) +}) + test.serial('custom cache key is truncated if over max size', async (t) => { const response = await t.context.testAdapter.request({ endpoint: 'test-custom-cache-key-long', diff --git a/test/metrics/metrics.test.ts b/test/metrics/metrics.test.ts index ef3d7399..f0f8945a 100644 --- a/test/metrics/metrics.test.ts +++ b/test/metrics/metrics.test.ts @@ -6,7 +6,7 @@ import MockAdapter from 'axios-mock-adapter' import { Adapter, AdapterEndpoint } from '../../src/adapter' import { AdapterConfig, EmptyCustomSettings } from '../../src/config' import { Metrics, retrieveCost } from '../../src/metrics' -import { HttpTransport } from '../../src/transports' +import { HttpTransport, TransportRoutes } from '../../src/transports' import { InputParameters } from '../../src/validation' import { TestAdapter } from '../../src/util/testing-utils' @@ -56,8 +56,8 @@ type RestEndpointTypes = { const endpoint = '/price' -const createAdapterEndpoint = (): AdapterEndpoint => { - const restEndpointTransport = new HttpTransport({ +const createRestTransport = (shouldFail: boolean) => + new HttpTransport({ prepareRequests: (params) => { return params.map((req) => ({ params: [req], @@ -73,28 +73,35 @@ const createAdapterEndpoint = (): AdapterEndpoint => { })) }, parseResponse: (params, res) => { - return [ - { - params: params[0], - response: { - data: res.data, - statusCode: 200, - result: res.data.price, - timestamps: { - providerIndicatedTimeUnixMs: Date.now() - 100, + return shouldFail && params[0].from === 'fail' + ? [] + : [ + { + params: params[0], + response: { + data: res.data, + statusCode: 200, + result: res.data.price, + timestamps: { + providerIndicatedTimeUnixMs: Date.now() - 100, + }, + }, }, - }, - }, - ] + ] }, }) - return new AdapterEndpoint({ +const createAdapterEndpoint = () => + new AdapterEndpoint({ name: 'TEST', inputParameters, - transport: restEndpointTransport, + transportRoutes: new TransportRoutes() + .register('rest', createRestTransport(true)) + .register('restprimary', createRestTransport(true)) + .register('restfallback', createRestTransport(false)), + defaultTransport: 'rest', + fallbackTransport: { restprimary: 'restfallback' }, }) -} const from = 'ETH' const to = 'USD' @@ -103,6 +110,7 @@ const price = 1234 test.before(async (t) => { t.context.clock = installTimers() process.env['METRICS_ENABLED'] = 'true' + process.env['TRANSPORT_FALLBACK_ENABLED'] = 'true' const config = new AdapterConfig( {}, { @@ -192,7 +200,7 @@ test.serial('test basic metrics', async (t) => { name: 'cache_data_set_count', labels: { feed_id, - participant_id: `TEST-test-default_single_transport-${feed_id}`, + participant_id: `TEST-test-rest-${feed_id}`, cache_type: 'local', }, expectedValue: 2, @@ -202,7 +210,7 @@ test.serial('test basic metrics', async (t) => { name: 'cache_data_max_age', labels: { feed_id, - participant_id: `TEST-test-default_single_transport-${feed_id}`, + participant_id: `TEST-test-rest-${feed_id}`, cache_type: 'local', }, expectedValue: 90000, @@ -212,7 +220,7 @@ test.serial('test basic metrics', async (t) => { name: 'cache_data_staleness_seconds', labels: { feed_id, - participant_id: `TEST-test-default_single_transport-${feed_id}`, + participant_id: `TEST-test-rest-${feed_id}`, cache_type: 'local', }, expectedValue: 0, @@ -265,7 +273,7 @@ test.serial('test basic metrics', async (t) => { name: 'cache_data_get_count', labels: { feed_id, - participant_id: `TEST-test-default_single_transport-${feed_id}`, + participant_id: `TEST-test-rest-${feed_id}`, cache_type: 'local', }, expectedValue: 1, @@ -275,7 +283,7 @@ test.serial('test basic metrics', async (t) => { name: 'cache_data_get_values', labels: { feed_id, - participant_id: `TEST-test-default_single_transport-${feed_id}`, + participant_id: `TEST-test-rest-${feed_id}`, cache_type: 'local', }, expectedValue: 1234, @@ -285,7 +293,7 @@ test.serial('test basic metrics', async (t) => { name: 'cache_data_staleness_seconds', labels: { feed_id, - participant_id: `TEST-test-default_single_transport-${feed_id}`, + participant_id: `TEST-test-rest-${feed_id}`, cache_type: 'local', }, }) @@ -332,6 +340,30 @@ test.serial('validate response.meta has the correct properties', async (t) => { t.deepEqual(response.meta, { adapterName: 'TEST', metrics: { feedId: '{"from":"eth","to":"usd"}' }, + transportName: 'rest', + }) +}) + +test.serial('validate response.meta uses fallback transport', async (t) => { + axiosMock + .onGet(`${URL}${endpoint}`, { + params: { + base: 'fail', + quote: to, + }, + }) + .reply(200, { + price, + }) + + const response = ( + await t.context.testAdapter.request({ from: 'fail', to, transport: 'restprimary' }) + ).json() + + t.deepEqual(response.meta, { + adapterName: 'TEST', + metrics: { feedId: '{"from":"fail","to":"usd"}' }, + transportName: 'restfallback', }) }) diff --git a/test/transports/routing.test.ts b/test/transports/routing.test.ts index 99f10478..92ead5c6 100644 --- a/test/transports/routing.test.ts +++ b/test/transports/routing.test.ts @@ -680,6 +680,111 @@ test.serial('transports with same name throws error', async (t) => { ) }) +test.serial('fallback transport is ignored when disabled', (t) => { + const endpoint = new AdapterEndpoint({ + inputParameters, + name: 'price', + transportRoutes: transports, + fallbackTransport: { batch: 'websocket' }, + }) + const config = new AdapterConfig(settings) + config.initialize() + + t.is(endpoint.getFallbackTransportNameForRequest('batch', config.settings), undefined) +}) + +test.serial('fallback transport missing', (t) => { + const endpoint = new AdapterEndpoint({ + inputParameters, + name: 'price', + transportRoutes: transports, + }) + const config = new AdapterConfig(settings) + config.initialize() + + t.is(endpoint.getFallbackTransportNameForRequest('batch', config.settings), undefined) +}) + +test.serial('fallback transport is normalized when enabled', (t) => { + const endpoint = new AdapterEndpoint({ + inputParameters, + name: 'price', + transportRoutes: transports, + fallbackTransport: { BATCH: 'WEBSOCKET' }, + }) + const config = new AdapterConfig(settings, { + envDefaultOverrides: { + TRANSPORT_FALLBACK_ENABLED: true, + }, + }) + config.initialize() + + t.is(endpoint.getFallbackTransportNameForRequest('batch', config.settings), 'websocket') +}) + +test.serial('fallback transport must be registered', async (t) => { + t.throws( + () => + new AdapterEndpoint({ + inputParameters, + name: 'price', + transportRoutes: transports, + fallbackTransport: { batch: 'invalid' }, + }), + { + message: + 'No fallback transport found for key "invalid", must be one of ["websocket","batch","sse"]', + }, + ) +}) + +test.serial("fallback's primary transport must be registered", async (t) => { + t.throws( + () => + new AdapterEndpoint({ + inputParameters, + name: 'price', + transportRoutes: transports, + fallbackTransport: { invalid: 'WEBSOCKET' }, + }), + { + message: + 'No primary transport found for key "invalid", must be one of ["websocket","batch","sse"]', + }, + ) +}) + +test.serial('fallback transport cannot match primary transport', async (t) => { + t.throws( + () => + new AdapterEndpoint({ + inputParameters, + name: 'price', + transportRoutes: transports, + fallbackTransport: { batch: 'batch' }, + }), + { + message: 'Fallback transport "batch" cannot be the same as primary transport.', + }, + ) +}) + +test.serial('fallback transport not allowed with cacheKeyGenerator', async (t) => { + t.throws( + () => + new AdapterEndpoint({ + inputParameters, + name: 'price', + transportRoutes: transports, + fallbackTransport: { batch: 'WEBSOCKET' }, + cacheKeyGenerator: () => 'cache', + }), + { + message: 'fallbackTransport not allowed for endpoints with cacheKeyGenerator', + }, + ) +}) + test.serial('transport override routes to correct Transport', async (t) => { axiosMock .onPost(`${restUrl}/price`, {