"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.Requester = void 0;
const axios_1 = __importDefault(require("axios"));
const _1 = require(".");
const metrics_1 = require("../metrics");
const error_1 = require("../validation/error");
const logger = (0, _1.makeLogger)('Requester');
class UniqueLinkedList {
    constructor(maxLength) {
        this.maxLength = maxLength;
        this.length = 0;
    }
    add(value) {
        let overflow;
        if (this.length === this.maxLength) {
            // If this new item would put us over max length, remove the first one (i.e. oldest one)
            overflow = this.remove();
        }
        const node = {
            value,
            next: undefined,
        };
        if (!this.first) {
            this.first = node;
        }
        if (this.last) {
            this.last.next = node;
        }
        this.last = node;
        this.length++;
        metrics_1.metrics.get('requesterQueueSize').inc();
        return overflow;
    }
    remove() {
        const node = this.first;
        if (!node) {
            return;
        }
        this.first = node.next;
        this.length--;
        metrics_1.metrics.get('requesterQueueSize').dec();
        return node.value;
    }
}
/**
 * Centralized management of outbound http requests.
 * Enforces rate limiting on a single instance (complying with the N Readers - 1 Writer arch for EA scaling)
 * by adding requests into a queue, processing them sequentially and sleeping when it reaches its limit.
 * The queue will throw an error if the Requester attempts to add more items than the max configured.
 * It additionally serves to coalesce requests by utilizing a more complex queue structure:
 *   - ignores duplicate items via a provided key
 *   - doesn't use the request itself because it's common for those to have things like timestamps/nonces
 * This implementation does not:
 *   - Prioritize any request over another
 *   - Contemplate architectures with multiple writer EA instances
 */
class Requester {
    constructor(rateLimiter, adapterSettings) {
        this.rateLimiter = rateLimiter;
        this.processing = false;
        this.map = {};
        this.maxRetries = adapterSettings.RETRY;
        this.timeout = adapterSettings.API_TIMEOUT;
        this.sleepBeforeRequeueingMs = adapterSettings.REQUESTER_SLEEP_BEFORE_REQUEUEING_MS;
        this.queue = new UniqueLinkedList(adapterSettings.MAX_HTTP_REQUEST_QUEUE_LENGTH);
    }
    queueRequest(queuedRequest) {
        // By the time we're here, we know that queuedRequest has both the unresolved promise, and the resolve and reject handlers within
        // It's really, REALLY important for thread safety that from this point until this function returns, there are no async breaks.
        // Node will stay within a "thread" until it gets a chance to switch context, like an "await" statement. This section of code depends
        // on it executing from start to finish without any other actions to avoid race conditions, so if we had an "await" here we could run into problems.
        // For example, two separate queue processing "threads" could be spawned by mistake, or the queue could overflow.
        const overflowedRequest = this.queue.add(queuedRequest);
        if (overflowedRequest) {
            // If we have overflow, it means the oldest request needs to be rejected because the queue is at its limits
            (0, _1.censorLogs)(() => logger.debug(`Request (Key: ${overflowedRequest.key}, Retry #: ${overflowedRequest.retries}) was removed from the queue to make room for a newer one (Size: ${this.queue.length})`));
            metrics_1.metrics.get('requesterQueueOverflow').inc();
            overflowedRequest.reject(new error_1.AdapterRateLimitError({
                message: 'The EA was unable to execute the request to fetch the requested data from the DP because the request queue overflowed. This likely indicates that a higher API tier is needed.',
                statusCode: 429,
                msUntilNextExecution: this.rateLimiter.msUntilNextExecution(),
            }));
            // Remove the overflown request from our map
            delete this.map[overflowedRequest.key];
        }
        // The item was successfully added to the queue, so we can also add it to our map
        // If the request is being re-added because it will be retried, this will have no practical effect
        (0, _1.censorLogs)(() => logger.trace(`Added request (Key: ${queuedRequest.key}, Retry #: ${queuedRequest.retries}) to the queue (Size: ${this.queue.length})`));
        this.map[queuedRequest.key] = queuedRequest;
        // Finally, we start the queue processing
        if (!this.processing) {
            this.processing = true;
            logger.debug(`Starting requester queue processing`);
            // We don't want to wait for the queue to finish processing here; this will just spawn a "thread"
            // and the promise we'll return from this method is the one for the request when it resolves
            this.processNext();
        }
    }
    /**
     * Queues the provided request, and returns a promise that will resolve whenever it's executed.
     *
     * @param key - a key to uniquely identify this request, and coalesce new ones that match
     * @param req - a request to send to a data provider
     * @param cost - Data Provider API credit cost of the request
     * @returns a promise that will resolve whenever the request is popped from the queue, sent, and a response is received
     */
    async request(key, req, cost) {
        // If there's already a queued request, reuse it's existing promise
        const existingQueuedRequest = this.map[key];
        if (existingQueuedRequest) {
            (0, _1.censorLogs)(() => logger.trace(`Request already exists, returning queued promise (Key: ${key})`));
            return existingQueuedRequest.promise;
        }
        const queuedRequest = {
            key,
            config: req,
            retries: 0,
            cost,
        };
        // This dual promise layer is built so the queuedRequest can hold both the resolve and reject handlers,
        // and the promise itself so we can return it for request coalescing without creating new ones
        await new Promise((unblock) => {
            queuedRequest.promise = new Promise((success, failure) => {
                queuedRequest.resolve = success;
                queuedRequest.reject = failure;
                unblock(0);
            });
        });
        // Add the request to our queue
        this.queueRequest(queuedRequest);
        return queuedRequest.promise;
    }
    // Will grab from queue sequentially, and sleep just before hitting rate limits
    async processNext() {
        // This will remove from the list, but not the map; that way coalescing is still functional for in-flight reqs
        const next = this.queue.remove();
        if (!next) {
            logger.debug(`No more requests present in the queue, stopping processing until new one comes in`);
            this.processing = false;
            return;
        }
        (0, _1.censorLogs)(() => logger.trace(`Popped next request (Key: ${next.key}, Retry #: ${next.retries}) from the queue (Size: ${this.queue.length})`));
        // Wait until the rate limiter allows the request to be executed
        await this.rateLimiter.waitForRateLimit(next.cost);
        // Fire off to complete in the background. We don't wait here to be able to fire off multiple requests concurrently
        this.executeRequest.bind(this)(next);
        return this.processNext();
    }
    // Handler for the requests that will be fired off, eventually resolving the promise associated with the queued request
    async executeRequest(req) {
        const { key, config, resolve, reject, retries } = req;
        const providerDataRequested = Date.now();
        const responseTimer = metrics_1.metrics.get('dataProviderRequestDurationSeconds').startTimer();
        // Set configured timeout for all requests unless manually specified
        config.timeout = config.timeout || this.timeout;
        try {
            (0, _1.censorLogs)(() => logger.trace(`Sending request (Key: ${key}) to data provider`));
            (0, _1.censorLogs)(() => logger.trace(config));
            const response = await axios_1.default.request(config);
            (0, _1.censorLogs)(() => logger.trace(`Request (Key: ${key}) was successful `));
            resolve({
                response,
                timestamps: {
                    providerDataRequestedUnixMs: providerDataRequested,
                    providerDataReceivedUnixMs: Date.now(),
                },
            });
            // Remove the request from our map
            delete this.map[key];
            // Record count of successful data provider requests
            metrics_1.metrics
                .get('dataProviderRequests')
                .labels((0, metrics_1.dataProviderMetricsLabel)(response.status, config.method))
                .inc();
        }
        catch (e) {
            const err = e;
            (0, _1.censorLogs)(() => logger.info({
                msg: 'Request failed',
                response: {
                    statusCode: err.response?.status,
                    data: err.response?.data,
                    text: err.response?.statusText,
                },
            }));
            // Record count of failed data provider request
            metrics_1.metrics
                .get('dataProviderRequests')
                .labels((0, metrics_1.dataProviderMetricsLabel)(err.response?.status || 0, config.method))
                .inc();
            if (retries >= this.maxRetries) {
                logger.trace('No more retries remaining, rejecting promise...');
                const ErrorClass = err.response?.status ? error_1.AdapterDataProviderError : error_1.AdapterConnectionError;
                reject(new ErrorClass({
                    statusCode: 502,
                    name: 'Data Provider error',
                    providerStatusCode: err?.response?.status ?? 502,
                    message: err?.message,
                    cause: e,
                    errorResponse: err?.response?.data,
                    url: config.url,
                }, {
                    providerDataRequestedUnixMs: providerDataRequested,
                    providerDataReceivedUnixMs: Date.now(),
                    providerIndicatedTimeUnixMs: undefined,
                }));
                // Remove the request from our map
                delete this.map[key];
            }
            else {
                const timeToSleep = this.sleepBeforeRequeueingMs || (2 ** retries + Math.random()) * 1000;
                logger.info(`${this.maxRetries - retries} retries remaining, sleeping for ${timeToSleep}ms...`);
                await (0, _1.sleep)(timeToSleep);
                req.retries++;
                // Re add the request to our queue
                this.queueRequest(req);
            }
        }
        finally {
            // Record time taken for data provider request for success or failure
            responseTimer();
        }
    }
}
exports.Requester = Requester;
//# sourceMappingURL=requester.js.map