diff --git a/server/live-loan-router.js b/server/live-loan-router.js index dc36af3e61d..590d4673585 100644 --- a/server/live-loan-router.js +++ b/server/live-loan-router.js @@ -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'; @@ -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)); @@ -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}`, { diff --git a/server/util/live-loan/canvas-image-utils.js b/server/util/live-loan/canvas-image-utils.js new file mode 100644 index 00000000000..546cc6063c8 --- /dev/null +++ b/server/util/live-loan/canvas-image-utils.js @@ -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} + */ +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 }; + } +} diff --git a/server/util/live-loan/live-loan-draw.js b/server/util/live-loan/live-loan-draw.js index b0a2f532559..7a03694c78d 100644 --- a/server/util/live-loan/live-loan-draw.js +++ b/server/util/live-loan/live-loan-draw.js @@ -2,7 +2,7 @@ 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'; @@ -10,6 +10,7 @@ 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'; @@ -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 @@ -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) { @@ -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 @@ -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) { diff --git a/test/unit/specs/server/util/live-loan/canvas-image-utils.spec.js b/test/unit/specs/server/util/live-loan/canvas-image-utils.spec.js new file mode 100644 index 00000000000..fb080b7359d --- /dev/null +++ b/test/unit/specs/server/util/live-loan/canvas-image-utils.spec.js @@ -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(); + }); + }); +});