Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Currency module: better error handling #10679

Merged
merged 4 commits into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 54 additions & 39 deletions modules/currency.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,22 @@ import {getHook} from '../src/hook.js';
import {defer} from '../src/utils/promise.js';
import {registerOrtbProcessor, REQUEST} from '../src/pbjsORTB.js';
import {timedBidResponseHook} from '../src/utils/perfMetrics.js';
import {on as onEvent, off as offEvent} from '../src/events.js';

const DEFAULT_CURRENCY_RATE_URL = 'https://cdn.jsdelivr.net/gh/prebid/currency-file@1/latest.json?date=$$TODAY$$';
const CURRENCY_RATE_PRECISION = 4;

var bidResponseQueue = [];
var conversionCache = {};
var currencyRatesLoaded = false;
var needToCallForCurrencyFile = true;
var adServerCurrency = 'USD';
let ratesURL;
let bidResponseQueue = [];
let conversionCache = {};
let currencyRatesLoaded = false;
let needToCallForCurrencyFile = true;
let adServerCurrency = 'USD';

export var currencySupportEnabled = false;
export var currencyRates = {};
var bidderCurrencyDefault = {};
var defaultRates;
let bidderCurrencyDefault = {};
let defaultRates;

export let responseReady = defer();

Expand Down Expand Up @@ -57,7 +59,7 @@ export let responseReady = defer();
* there is an error loading the config.conversionRateFile.
*/
export function setConfig(config) {
let url = DEFAULT_CURRENCY_RATE_URL;
ratesURL = DEFAULT_CURRENCY_RATE_URL;

if (typeof config.rates === 'object') {
currencyRates.conversions = config.rates;
Expand All @@ -79,14 +81,14 @@ export function setConfig(config) {
adServerCurrency = config.adServerCurrency;
if (config.conversionRateFile) {
logInfo('currency using override conversionRateFile:', config.conversionRateFile);
url = config.conversionRateFile;
ratesURL = config.conversionRateFile;
}

// see if the url contains a date macro
// this is a workaround to the fact that jsdelivr doesn't currently support setting a 24-hour HTTP cache header
// So this is an approach to let the browser cache a copy of the file each day
// We should remove the macro once the CDN support a day-level HTTP cache setting
const macroLocation = url.indexOf('$$TODAY$$');
const macroLocation = ratesURL.indexOf('$$TODAY$$');
if (macroLocation !== -1) {
// get the date to resolve the macro
const d = new Date();
Expand All @@ -97,10 +99,10 @@ export function setConfig(config) {
const todaysDate = `${d.getFullYear()}${month}${day}`;

// replace $$TODAY$$ with todaysDate
url = `${url.substring(0, macroLocation)}${todaysDate}${url.substring(macroLocation + 9, url.length)}`;
ratesURL = `${ratesURL.substring(0, macroLocation)}${todaysDate}${ratesURL.substring(macroLocation + 9, ratesURL.length)}`;
}

initCurrency(url);
initCurrency();
} else {
// currency support is disabled, setting defaults
logInfo('disabling currency support');
Expand All @@ -121,21 +123,11 @@ function errorSettingsRates(msg) {
}
}

function initCurrency(url) {
conversionCache = {};
currencySupportEnabled = true;

logInfo('Installing addBidResponse decorator for currency module', arguments);

// Adding conversion function to prebid global for external module and on page use
getGlobal().convertCurrency = (cpm, fromCurrency, toCurrency) => parseFloat(cpm) * getCurrencyConversion(fromCurrency, toCurrency);
getHook('addBidResponse').before(addBidResponseHook, 100);
getHook('responsesReady').before(responsesReadyHook);

// call for the file if we haven't already
function loadRates() {
if (needToCallForCurrencyFile) {
needToCallForCurrencyFile = false;
ajax(url,
currencyRatesLoaded = false;
ajax(ratesURL,
{
success: function (response) {
try {
Expand All @@ -150,17 +142,37 @@ function initCurrency(url) {
},
error: function (...args) {
errorSettingsRates(...args);
currencyRatesLoaded = true;
processBidResponseQueue();
needToCallForCurrencyFile = true;
}
}
);
}
}

function initCurrency() {
conversionCache = {};
currencySupportEnabled = true;

logInfo('Installing addBidResponse decorator for currency module', arguments);

// Adding conversion function to prebid global for external module and on page use
getGlobal().convertCurrency = (cpm, fromCurrency, toCurrency) => parseFloat(cpm) * getCurrencyConversion(fromCurrency, toCurrency);
getHook('addBidResponse').before(addBidResponseHook, 100);
getHook('responsesReady').before(responsesReadyHook);
onEvent(CONSTANTS.EVENTS.AUCTION_TIMEOUT, rejectOnAuctionTimeout);
onEvent(CONSTANTS.EVENTS.AUCTION_INIT, loadRates);
loadRates();
}

function resetCurrency() {
logInfo('Uninstalling addBidResponse decorator for currency module', arguments);

getHook('addBidResponse').getHooks({hook: addBidResponseHook}).remove();
getHook('responsesReady').getHooks({hook: responsesReadyHook}).remove();
offEvent(CONSTANTS.EVENTS.AUCTION_TIMEOUT, rejectOnAuctionTimeout);
offEvent(CONSTANTS.EVENTS.AUCTION_INIT, loadRates);
delete getGlobal().convertCurrency;

adServerCurrency = 'USD';
Expand Down Expand Up @@ -207,23 +219,25 @@ export const addBidResponseHook = timedBidResponseHook('currency', function addB
if (bid.currency === adServerCurrency) {
return fn.call(this, adUnitCode, bid, reject);
}

bidResponseQueue.push(wrapFunction(fn, this, [adUnitCode, bid, reject]));
bidResponseQueue.push([fn, this, adUnitCode, bid, reject]);
if (!currencySupportEnabled || currencyRatesLoaded) {
processBidResponseQueue();
}
});

function processBidResponseQueue() {
while (bidResponseQueue.length > 0) {
(bidResponseQueue.shift())();
}
responseReady.resolve()
function rejectOnAuctionTimeout({auctionId}) {
bidResponseQueue = bidResponseQueue.filter(([fn, ctx, adUnitCode, bid, reject]) => {
if (bid.auctionId === auctionId) {
reject(CONSTANTS.REJECTION_REASON.CANNOT_CONVERT_CURRENCY)
} else {
return true;
}
});
}

function wrapFunction(fn, context, params) {
return function() {
let bid = params[1];
function processBidResponseQueue() {
while (bidResponseQueue.length > 0) {
const [fn, ctx, adUnitCode, bid, reject] = bidResponseQueue.shift();
if (bid !== undefined && 'currency' in bid && 'cpm' in bid) {
let fromCurrency = bid.currency;
try {
Expand All @@ -234,12 +248,13 @@ function wrapFunction(fn, context, params) {
}
} catch (e) {
logWarn('getCurrencyConversion threw error: ', e);
params[2](CONSTANTS.REJECTION_REASON.CANNOT_CONVERT_CURRENCY);
return;
reject(CONSTANTS.REJECTION_REASON.CANNOT_CONVERT_CURRENCY);
continue;
}
}
return fn.apply(context, params);
};
fn.call(ctx, adUnitCode, bid, reject);
}
responseReady.resolve();
}

function getCurrencyConversion(fromCurrency, toCurrency = adServerCurrency) {
Expand Down
2 changes: 1 addition & 1 deletion modules/sonobiAnalyticsAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {ajaxBuilder} from '../src/ajax.js';

let ajax = ajaxBuilder(0);

const DEFAULT_EVENT_URL = 'apex.go.sonobi.com/keymaker';
export const DEFAULT_EVENT_URL = 'apex.go.sonobi.com/keymaker';
const analyticsType = 'endpoint';
const QUEUE_TIMEOUT_DEFAULT = 200;
const {
Expand Down
2 changes: 2 additions & 0 deletions src/auction.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ export function newAuction({adUnits, adUnitCodes, callback, cbTimeout, labels, a
function executeCallback(timedOut) {
if (!timedOut) {
clearTimeout(_timeoutTimer);
} else {
events.emit(CONSTANTS.EVENTS.AUCTION_TIMEOUT, getProperties());
}
if (_auctionEnd === undefined) {
let timedOutRequests = [];
Expand Down
1 change: 1 addition & 0 deletions src/constants.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
},
"EVENTS": {
"AUCTION_INIT": "auctionInit",
"AUCTION_TIMEOUT": "auctionTimeout",
"AUCTION_END": "auctionEnd",
"BID_ADJUSTMENT": "bidAdjustment",
"BID_TIMEOUT": "bidTimeout",
Expand Down
33 changes: 31 additions & 2 deletions test/spec/auctionmanager_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1156,7 +1156,7 @@ describe('auctionmanager.js', function () {
return auction.end.then(() => {
expect(auction.getBidsReceived().length).to.eql(1);
})
})
});
})

it('sets bidResponse.ttlBuffer from adUnit.ttlBuffer', () => {
Expand Down Expand Up @@ -1200,6 +1200,7 @@ describe('auctionmanager.js', function () {
const spec2 = mockBidder(BIDDER_CODE1, [bids[1]]);
registerBidder(spec2);
auction = auctionModule.newAuction({adUnits, adUnitCodes, callback: () => auctionDone(), cbTimeout: 20});
indexAuctions = [auction];
});

afterEach(function () {
Expand All @@ -1216,7 +1217,35 @@ describe('auctionmanager.js', function () {
});
respondToRequest(0);
return pm;
})
});

describe('AUCTION_TIMEOUT event', () => {
let handler;
beforeEach(() => {
handler = sinon.spy();
events.on(CONSTANTS.EVENTS.AUCTION_TIMEOUT, handler);
})
afterEach(() => {
events.off(CONSTANTS.EVENTS.AUCTION_TIMEOUT, handler);
});

Object.entries({
'is fired on timeout': [true, [0]],
'is NOT fired otherwise': [false, [0, 1]],
}).forEach(([t, [shouldFire, respond]]) => {
it(t, () => {
const pm = runAuction().then(() => {
if (shouldFire) {
sinon.assert.calledWith(handler, sinon.match({auctionId: auction.getAuctionId()}))
} else {
sinon.assert.notCalled(handler);
}
});
respond.forEach(respondToRequest);
return pm;
})
});
});

it('should emit BID_TIMEOUT and AUCTION_END for timed out bids', function () {
const pm = runAuction().then(() => {
Expand Down
86 changes: 64 additions & 22 deletions test/spec/modules/currency_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import {createBid} from '../../../src/bidfactory.js';
import CONSTANTS from '../../../src/constants.json';
import {server} from '../../mocks/xhr.js';
import * as events from 'src/events.js';

var assert = require('chai').assert;
var expect = require('chai').expect;
Expand Down Expand Up @@ -286,32 +287,56 @@ describe('currency', function () {
expect(innerBid.getCpmInNewCurrency('JPY')).to.equal('1000.000');
});

it('uses default rates when currency file fails to load', function () {
setConfig({});

setConfig({
adServerCurrency: 'USD',
defaultRates: {
USD: {
JPY: 100
describe('when rates fail to load', () => {
let bid, addBidResponse, reject;
beforeEach(() => {
bid = makeBid({cpm: 100, currency: 'JPY', bidder: 'rubicoin'});
addBidResponse = sinon.spy();
reject = sinon.spy();
})
it('uses default rates if specified', function () {
setConfig({
adServerCurrency: 'USD',
defaultRates: {
USD: {
JPY: 100
}
}
}
});

// default response is 404
fakeCurrencyFileServer.respond();
});

var bid = { cpm: 100, currency: 'JPY', bidder: 'rubicon' };
var innerBid;
// default response is 404
addBidResponseHook(addBidResponse, 'au', bid);
fakeCurrencyFileServer.respond();
sinon.assert.calledWith(addBidResponse, 'au', sinon.match(innerBid => {
expect(innerBid.cpm).to.equal('1.0000');
expect(typeof innerBid.getCpmInNewCurrency).to.equal('function');
expect(innerBid.getCpmInNewCurrency('JPY')).to.equal('100.000');
return true;
}));
});

addBidResponseHook(function(adCodeId, bid) {
innerBid = bid;
}, 'elementId', bid);
it('rejects bids if no default rates are specified', () => {
setConfig({
adServerCurrency: 'USD',
});
addBidResponseHook(addBidResponse, 'au', bid, reject);
fakeCurrencyFileServer.respond();
sinon.assert.notCalled(addBidResponse);
sinon.assert.calledWith(reject, CONSTANTS.REJECTION_REASON.CANNOT_CONVERT_CURRENCY);
});

expect(innerBid.cpm).to.equal('1.0000');
expect(typeof innerBid.getCpmInNewCurrency).to.equal('function');
expect(innerBid.getCpmInNewCurrency('JPY')).to.equal('100.000');
});
it('attempts to load rates again on the next auction', () => {
setConfig({
adServerCurrency: 'USD',
});
fakeCurrencyFileServer.respond();
fakeCurrencyFileServer.respondWith(JSON.stringify(getCurrencyRates()));
events.emit(CONSTANTS.EVENTS.AUCTION_INIT, {});
addBidResponseHook(addBidResponse, 'au', bid, reject);
fakeCurrencyFileServer.respond();
sinon.assert.calledWith(addBidResponse, 'au', bid, reject);
})
})
});

describe('currency.addBidResponseDecorator bidResponseQueue', function () {
Expand Down Expand Up @@ -415,6 +440,23 @@ describe('currency', function () {
expect(reject.calledOnce).to.be.true;
});

it('should reject bid when rates have not loaded when the auction times out', () => {
fakeCurrencyFileServer.respondWith(JSON.stringify(getCurrencyRates()));
setConfig({'adServerCurrency': 'JPY'});
const bid = makeBid({cpm: 1, currency: 'USD', auctionId: 'aid'});
const noConversionBid = makeBid({cpm: 1, currency: 'JPY', auctionId: 'aid'});
const reject = sinon.spy();
const addBidResponse = sinon.spy();
addBidResponseHook(addBidResponse, 'au', bid, reject);
addBidResponseHook(addBidResponse, 'au', noConversionBid, reject);
events.emit(CONSTANTS.EVENTS.AUCTION_TIMEOUT, {auctionId: 'aid'});
fakeCurrencyFileServer.respond();
sinon.assert.calledOnce(addBidResponse);
sinon.assert.calledWith(addBidResponse, 'au', noConversionBid, reject);
sinon.assert.calledOnce(reject);
sinon.assert.calledWith(reject, CONSTANTS.REJECTION_REASON.CANNOT_CONVERT_CURRENCY);
})

it('should return 1 when currency support is enabled and same currency code is requested as is set to adServerCurrency', function () {
fakeCurrencyFileServer.respondWith(JSON.stringify(getCurrencyRates()));
setConfig({ 'adServerCurrency': 'JPY' });
Expand Down
Loading