Skip to content
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
32 changes: 23 additions & 9 deletions server/live-loan-router.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import express from 'express';
import { error } from './util/log.js';
import { error, warn } from './util/log.js';
import { getFromCache, setToCache } from './util/memJsUtils.js';
import drawLoanCard from './util/live-loan/live-loan-draw.js';
import fetchLoansByType, { QUERY_TYPE } from './util/live-loan/live-loan-fetch.js';
Expand Down Expand Up @@ -83,6 +83,7 @@ async function redirectToUrl(type, cache, req, res, queryType = QUERY_TYPE.DEFAU
async function serveImg(type, style, cache, req, res, queryType = QUERY_TYPE.DEFAULT) {
let loan;
let loanImg;
let hasBorrowerImage = true;

try {
loan = await trace('getLoanForRequest', async () => getLoanForRequest(type, cache, req, queryType));
Expand All @@ -92,18 +93,31 @@ async function serveImg(type, style, cache, req, res, queryType = QUERY_TYPE.DEF
if (cachedLoanImg) {
loanImg = cachedLoanImg;
} else {
loanImg = await trace('drawLoanCard', { resource: loan.id }, async () => drawLoanCard(loan, style));
const expires = 10 * 60; // 10 minutes
setToCache(imgCachedName, loanImg, expires, cache).catch(err => {
error(`Error setting loan data to cache, ${err}`, {
error: err,
const result = await trace('drawLoanCard', { resource: loan.id }, async () => drawLoanCard(loan, style));
loanImg = result.buffer;
hasBorrowerImage = result.hasBorrowerImage;

// Only cache if we have the actual borrower image
if (hasBorrowerImage) {
const expires = 10 * 60; // 10 minutes
setToCache(imgCachedName, loanImg, expires, cache).catch(err => {
error(`Error setting loan data to cache, ${err}`, {
error: err,
params: req.params,
loan,
style,
type,
});
});
// Continue before setting to the cache completes to speed up response times
} else {
warn('Not caching loan image as borrower image is missing', {
params: req.params,
loan,
loanId: loan.id,
style,
type,
});
});
// Continue before setting to the cache completes to speed up response times
}
}
} catch (err) {
error(`Error getting served image, ${err}`, {
Expand Down
77 changes: 77 additions & 0 deletions server/util/live-loan/canvas-image-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { loadImage } from 'canvas';
import { trace } from '../mockTrace.js';
import { error } from '../log.js';

// Fallback placeholder image - preload at module initialization
const FALLBACK_IMAGE_URL = 'https://www.kiva.org/img/orig/726677.jpg';
let fallbackImage = null;

// Preload fallback image to avoid I/O during request handling
trace('preloadFallbackImage', async () => {
try {
fallbackImage = await loadImage(FALLBACK_IMAGE_URL);
} catch (err) {
error('Failed to preload fallback image', { error: err, url: FALLBACK_IMAGE_URL });
}
});

/**
* Retry wrapper for loadImage with exponential backoff
* @param {string} url - Image URL to load
* @param {number} loanId - Loan ID for logging
* @param {number} maxRetries - Maximum number of retry attempts
* @returns {Promise<Image>}
*/
export async function loadImageWithRetry(url, loanId, maxRetries = 2) {
let lastError;
/* eslint-disable no-await-in-loop */
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
try {
return await loadImage(url);
} catch (err) {
lastError = err;
if (attempt < maxRetries) {
const delay = 100 * (2 ** attempt); // 100ms, 200ms
error('Image load failed, retrying', {
error: err,
loanId,
imageUrl: url,
attempt: attempt + 1,
maxRetries,
retryDelay: delay
});
await new Promise(resolve => {
setTimeout(resolve, delay);
});
}
}
}
/* eslint-enable no-await-in-loop */
error('Image load failed after all retries', {
error: lastError,
loanId,
imageUrl: url,
attempts: maxRetries + 1
});
throw lastError;
}

/**
* Load borrower image with retry and fallback
* @param {Object} loanData - Loan data object
* @returns {Promise<{image: Image|null, hasBorrowerImage: boolean}>}
*/
export async function loadBorrowerImage(loanData) {
// Use jpeg version of image as webp is not supported by node-canvas
const jpgUrl = loanData?.image?.retina?.replace('webp', 'jpg') ?? loanData?.image?.retina;
try {
const image = await trace(
'loadImageWithRetry',
async () => loadImageWithRetry(jpgUrl, loanData.id)
);
return { image, hasBorrowerImage: true };
} catch (err) {
// Failed to load actual borrower image - use fallback if available
return { image: fallbackImage, hasBorrowerImage: false };
}
}
43 changes: 17 additions & 26 deletions server/util/live-loan/live-loan-draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { mdiMapMarker } from '@mdi/js';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import {
createCanvas, CanvasRenderingContext2D, registerFont, loadImage
createCanvas, CanvasRenderingContext2D, registerFont
} from 'canvas';
import deePool from 'deepool';
import numeral from 'numeral';
import { polyfillPath2D } from 'path2d-polyfill';
import {
ellipsisLine, drawPill, wrapText, roundRect
} from './canvas-utils.js';
import { loadBorrowerImage } from './canvas-image-utils.js';
import getLoanCallouts from '../../../src/util/loanCallouts.js';
import getLoanUse from '../../../src/util/loanUse.js';
import { trace } from '../mockTrace.js';
Expand Down Expand Up @@ -160,15 +161,10 @@ async function drawLegacy(loanData) {
});

// Borrower Image
await trace('borrower-image', async () => {
// Use jpeg version of image as webp is not supported by node-canvas
const jpgUrl = loanData?.image?.retina?.replace('webp', 'jpg') ?? loanData?.image?.retina;
try {
const borrowerImg = await trace('loadImage', async () => loadImage(jpgUrl));
ctx.drawImage(borrowerImg, 0, 0, cardWidth, cardWidth * borrowerImgAspectRatio);
} catch (error) {
console.error('Error loading image:', error);
}
const hasBorrowerImage = await trace('borrower-image', async () => {
const result = await loadBorrowerImage(loanData);
ctx.drawImage(result.image, 0, 0, cardWidth, cardWidth * borrowerImgAspectRatio);
return result.hasBorrowerImage;
});

// Add a border around everything
Expand All @@ -189,7 +185,7 @@ async function drawLegacy(loanData) {
// Recycle canvas for use in other requests
trace('legacyCanvasPool.recycle', () => legacyCanvasPool.recycle(canvas));

return buffer;
return { buffer, hasBorrowerImage };
} catch (e) {
// Recycle canvas for use in other requests
if (canvas) {
Expand Down Expand Up @@ -227,20 +223,15 @@ async function drawClassic(loanData) {
});

// Borrower Image
await trace('borrower-image', async () => {
// Use jpeg version of image as webp is not supported by node-canvas
const jpgUrl = loanData?.image?.retina?.replace('webp', 'jpg') ?? loanData?.image?.retina;
try {
const borrowerImg = await trace('loadImage', async () => loadImage(jpgUrl));
ctx.save();
// eslint-disable-next-line max-len
roundRect(ctx, borrowerImgMargin, borrowerImgMargin, borrowerImgWidth, borrowerImgHeight, 16 * classicResizeFactor);
ctx.clip();
ctx.drawImage(borrowerImg, borrowerImgMargin, borrowerImgMargin, borrowerImgWidth, borrowerImgHeight);
ctx.restore();
} catch (error) {
console.error('Error loading image:', error);
}
const hasBorrowerImage = await trace('borrower-image', async () => {
const result = await loadBorrowerImage(loanData);
ctx.save();
// eslint-disable-next-line max-len
roundRect(ctx, borrowerImgMargin, borrowerImgMargin, borrowerImgWidth, borrowerImgHeight, 16 * classicResizeFactor);
ctx.clip();
ctx.drawImage(result.image, borrowerImgMargin, borrowerImgMargin, borrowerImgWidth, borrowerImgHeight);
ctx.restore();
return result.hasBorrowerImage;
});

// Borrower country
Expand Down Expand Up @@ -350,7 +341,7 @@ async function drawClassic(loanData) {
// Recycle canvas for use in other requests
trace('classicCanvasPool.recycle', () => classicCanvasPool.recycle(canvas));

return buffer;
return { buffer, hasBorrowerImage };
} catch (e) {
// Recycle canvas for use in other requests
if (canvas) {
Expand Down
156 changes: 156 additions & 0 deletions test/unit/specs/server/util/live-loan/canvas-image-utils.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// @vitest-environment node
import { loadImage } from 'canvas';

// Mock canvas module
vi.mock('canvas', async () => {
return {
loadImage: vi.fn().mockResolvedValue({ width: 100, height: 100 }),
};
});

describe('canvas-image-utils', () => {
let loadImageWithRetry;
let loadBorrowerImage;

beforeAll(async () => {
// Now import the module - fallback preload will happen
const module = await import('#server/util/live-loan/canvas-image-utils');
loadImageWithRetry = module.loadImageWithRetry;
loadBorrowerImage = module.loadBorrowerImage;

// Wait a bit for the async preload to complete
// eslint-disable-next-line no-promise-executor-return
await new Promise(resolve => setTimeout(resolve, 50));
});
const mockImage = {
width: 100,
height: 100,
complete: true,
};

beforeEach(() => {
loadImage.mockClear();
});

describe('loadImageWithRetry', () => {
it('should return image on first successful attempt', async () => {
loadImage.mockResolvedValue(mockImage);

const result = await loadImageWithRetry('https://example.com/image.jpg', 12345);

expect(result).toBe(mockImage);
expect(loadImage).toHaveBeenCalledTimes(1);
expect(loadImage).toHaveBeenCalledWith('https://example.com/image.jpg');
});

it('should retry on failure and succeed on second attempt', async () => {
loadImage
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce(mockImage);

const result = await loadImageWithRetry('https://example.com/image.jpg', 12345);

expect(result).toBe(mockImage);
expect(loadImage).toHaveBeenCalledTimes(2);
});

it('should retry with exponential backoff', async () => {
vi.useFakeTimers();
loadImage
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce(mockImage);

const promise = loadImageWithRetry('https://example.com/image.jpg', 12345);

// First attempt fails immediately
await vi.advanceTimersByTimeAsync(0);
expect(loadImage).toHaveBeenCalledTimes(1);

// Wait for first retry delay (100ms)
await vi.advanceTimersByTimeAsync(100);
expect(loadImage).toHaveBeenCalledTimes(2);

// Wait for second retry delay (200ms)
await vi.advanceTimersByTimeAsync(200);
expect(loadImage).toHaveBeenCalledTimes(3);

const result = await promise;
expect(result).toBe(mockImage);

vi.useRealTimers();
});

it('should throw error after all retries fail', async () => {
const err = new Error('Permanent network error');
loadImage.mockRejectedValue(err);

await expect(
loadImageWithRetry('https://example.com/image.jpg', 12345, 2)
).rejects.toThrow('Permanent network error');

expect(loadImage).toHaveBeenCalledTimes(3); // Initial + 2 retries
});

it('should respect custom maxRetries parameter', async () => {
loadImage.mockRejectedValue(new Error('Network error'));

await expect(
loadImageWithRetry('https://example.com/image.jpg', 12345, 0)
).rejects.toThrow('Network error');

expect(loadImage).toHaveBeenCalledTimes(1); // No retries
});
});

describe('loadBorrowerImage', () => {
const mockLoanData = {
id: 12345,
image: {
retina: 'https://example.com/image.webp'
}
};

it('should convert webp to jpg', async () => {
loadImage.mockResolvedValue(mockImage);

await loadBorrowerImage(mockLoanData);

expect(loadImage).toHaveBeenCalledWith('https://example.com/image.jpg');
});

it('should return image and hasBorrowerImage true on success', async () => {
loadImage.mockResolvedValue(mockImage);

const result = await loadBorrowerImage(mockLoanData);

expect(result).toEqual({
image: mockImage,
hasBorrowerImage: true
});
});

it('should return fallback image and hasBorrowerImage false on failure', async () => {
loadImage.mockRejectedValue(new Error('Image load failed'));

const result = await loadBorrowerImage(mockLoanData);

expect(result.hasBorrowerImage).toBe(false);
// Fallback image is null in test environment since preload happens at module init
expect(result.image).toBeDefined();
});

it('should handle missing image URL gracefully', async () => {
loadImage.mockRejectedValue(new Error('Invalid URL'));
const loanDataNoImage = {
id: 12345,
image: {}
};

const result = await loadBorrowerImage(loanDataNoImage);

expect(result.hasBorrowerImage).toBe(false);
expect(result.image).toBeDefined();
});
});
});
Loading