diff --git a/src/app/api/lotw/download-contact/route.ts b/src/app/api/lotw/download-contact/route.ts index 848e9ff..4d28932 100644 --- a/src/app/api/lotw/download-contact/route.ts +++ b/src/app/api/lotw/download-contact/route.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { verifyToken } from '@/lib/auth'; import { query } from '@/lib/db'; -import { parseLoTWAdif, matchLoTWConfirmations, buildLoTWDownloadUrl, decryptString } from '@/lib/lotw'; +import { parseLoTWAdif, matchLoTWConfirmations, buildLoTWDownloadUrl, decryptString, fetchLotwWithRetry, LOTW_USER_AGENT } from '@/lib/lotw'; import { ContactWithLoTW } from '@/types/lotw'; export async function POST(request: NextRequest) { @@ -115,12 +115,14 @@ export async function POST(request: NextRequest) { // Download confirmations from LoTW let adifContent: string; try { - const downloadResponse = await fetch(downloadUrl, { - method: 'GET', - headers: { - 'User-Agent': 'Nextlog/1.0.0', - }, - }); + const downloadResponse = await fetchLotwWithRetry( + () => + fetch(downloadUrl, { + method: 'GET', + headers: { 'User-Agent': LOTW_USER_AGENT }, + }), + 'download' + ); if (!downloadResponse.ok) { throw new Error(`LoTW download failed: ${downloadResponse.status} ${downloadResponse.statusText}`); diff --git a/src/app/api/lotw/upload-contact/route.ts b/src/app/api/lotw/upload-contact/route.ts index 82b2d48..82da921 100644 --- a/src/app/api/lotw/upload-contact/route.ts +++ b/src/app/api/lotw/upload-contact/route.ts @@ -9,6 +9,8 @@ import { decryptString, readCertMetadata, isQsoWithinCertDateRange, + fetchLotwWithRetry, + LOTW_USER_AGENT, } from '@/lib/lotw'; import { ContactWithLoTW, LotwQso, LotwStationProfile } from '@/types/lotw'; @@ -197,10 +199,18 @@ export async function POST(request: NextRequest) { let lotwResponse = ''; try { - const fd = new FormData(); - const blob = new Blob([new Uint8Array(tq8)], { type: 'application/octet-stream' }); - fd.append('upfile', blob, `${stationProfile.callsign}.tq8`); - const uploadResponse = await fetch(LOTW_UPLOAD_URL, { method: 'POST', body: fd }); + const uploadResponse = await fetchLotwWithRetry(() => { + const fd = new FormData(); + const blob = new Blob([new Uint8Array(tq8)], { type: 'application/octet-stream' }); + fd.append('upfile', blob, `${stationProfile.callsign}.tq8`); + // Only set User-Agent — fetch sets the multipart Content-Type (with + // boundary) from the FormData body, so don't override it. + return fetch(LOTW_UPLOAD_URL, { + method: 'POST', + body: fd, + headers: { 'User-Agent': LOTW_USER_AGENT }, + }); + }, 'upload'); lotwResponse = await uploadResponse.text(); if (!uploadResponse.ok) { throw new Error(`LoTW upload HTTP ${uploadResponse.status}: ${lotwResponse.slice(0, 500)}`); diff --git a/src/lib/lotw-sync.ts b/src/lib/lotw-sync.ts index f3f8bc2..5256ccb 100644 --- a/src/lib/lotw-sync.ts +++ b/src/lib/lotw-sync.ts @@ -17,6 +17,8 @@ import { parseLoTWAdif, matchLoTWConfirmations, buildLoTWDownloadUrl, + fetchLotwWithRetry, + LOTW_USER_AGENT, } from '@/lib/lotw'; import { LotwUploadResponse, @@ -355,10 +357,18 @@ export async function performLotwUpload( // wavelog Lotw.php:312-315). FormData with a Blob handles the boundary. let lotwResponse = ''; try { - const fd = new FormData(); - const blob = new Blob([new Uint8Array(tq8)], { type: 'application/octet-stream' }); - fd.append('upfile', blob, `${stationProfile.callsign}.tq8`); - const uploadResponse = await fetch(LOTW_UPLOAD_URL, { method: 'POST', body: fd }); + const uploadResponse = await fetchLotwWithRetry(() => { + const fd = new FormData(); + const blob = new Blob([new Uint8Array(tq8)], { type: 'application/octet-stream' }); + fd.append('upfile', blob, `${stationProfile.callsign}.tq8`); + // Only set User-Agent — fetch sets the multipart Content-Type (with + // boundary) from the FormData body, so don't override it. + return fetch(LOTW_UPLOAD_URL, { + method: 'POST', + body: fd, + headers: { 'User-Agent': LOTW_USER_AGENT }, + }); + }, 'upload'); lotwResponse = await uploadResponse.text(); if (!uploadResponse.ok) { throw new Error(`LoTW upload HTTP ${uploadResponse.status}: ${lotwResponse.slice(0, 500)}`); @@ -551,12 +561,14 @@ export async function performLotwDownload( // Download confirmations from LoTW let adifContent: string; try { - const downloadResponse = await fetch(downloadUrl, { - method: 'GET', - headers: { - 'User-Agent': 'Nextlog/1.0.0', - }, - }); + const downloadResponse = await fetchLotwWithRetry( + () => + fetch(downloadUrl, { + method: 'GET', + headers: { 'User-Agent': LOTW_USER_AGENT }, + }), + 'download' + ); if (!downloadResponse.ok) { throw new Error(`LoTW download failed: ${downloadResponse.status} ${downloadResponse.statusText}`); diff --git a/src/lib/lotw.ts b/src/lib/lotw.ts index 716d794..940f34f 100644 --- a/src/lib/lotw.ts +++ b/src/lib/lotw.ts @@ -21,6 +21,69 @@ export function decryptString(encryptedText: string): string { return decrypt(encryptedText); } +// LoTW's front end intermittently answers 5xx (typically 503 Service +// Unavailable) when it's throttling or under load — and cloud/serverless +// egress IPs (e.g. Vercel) get throttled far more readily than a self-hosted +// box on a stable IP. Every LoTW leg here is idempotent, so retry a few +// transient failures with exponential backoff before giving up. Only 5xx and +// network errors are retried; a 2xx is returned as-is because LoTW encodes +// auth and other failures inside a 200 body, not the status line. +const LOTW_MAX_ATTEMPTS = 3; +// Exponential backoff derived from the attempt number, so there are no unused +// slots to fall out of sync with LOTW_MAX_ATTEMPTS: retry N waits base·2^(N-1). +// With 3 attempts that's a 2s wait after the 1st failure and 4s after the 2nd. +const LOTW_BACKOFF_BASE_MS = 2000; +const lotwBackoffMs = (attempt: number): number => LOTW_BACKOFF_BASE_MS * 2 ** (attempt - 1); + +// Present as an ordinary browser. Some WAF front ends 503 on unusual +// (non-browser) user agents; a browser UA looks the least like a bot. +export const LOTW_USER_AGENT = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Retry a LoTW request on transient 5xx / network errors. makeRequest must +// build a fresh Request each call (FormData bodies aren't safely reusable). +// A non-5xx response is returned immediately for the caller to interpret. +export async function fetchLotwWithRetry( + makeRequest: () => Promise, + label: string +): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= LOTW_MAX_ATTEMPTS; attempt++) { + try { + const response = await makeRequest(); + if (response.status >= 500 && attempt < LOTW_MAX_ATTEMPTS) { + console.warn( + `[LoTW] ${label} returned ${response.status}; retrying (attempt ${attempt}/${LOTW_MAX_ATTEMPTS})` + ); + // Drain the discarded response so undici can reuse the connection + // instead of leaking sockets across retries. + try { await response.body?.cancel(); } catch { /* already consumed/closed */ } + await sleep(lotwBackoffMs(attempt)); + continue; + } + return response; + } catch (error) { + // Network-level failure (DNS, reset, timeout). Retry until attempts run out. + lastError = error; + if (attempt < LOTW_MAX_ATTEMPTS) { + console.warn( + `[LoTW] ${label} network error; retrying (attempt ${attempt}/${LOTW_MAX_ATTEMPTS}):`, + error instanceof Error ? error.message : error + ); + await sleep(lotwBackoffMs(attempt)); + continue; + } + } + } + throw lastError instanceof Error + ? lastError + : new Error(`LoTW ${label} failed after ${LOTW_MAX_ATTEMPTS} attempts`); +} + // Parse ADIF file from LoTW download. // Captures both the core matching fields AND the enriched fields LoTW returns when // qso_qsldetail=yes / qso_mydetail=yes are set on the request: state, county, CQZ, @@ -626,7 +689,10 @@ export async function validateLoTWCredentials(username: string, password: string // Use a future date so the response is empty even for active accounts — // we only care about whether the credential check passes. const url = buildLoTWDownloadUrl(username, password, { dateFrom: '2099-01-01' }); - const response = await fetch(url, { method: 'GET', headers: { 'User-Agent': 'Nextlog/1.0.0' } }); + const response = await fetchLotwWithRetry( + () => fetch(url, { method: 'GET', headers: { 'User-Agent': LOTW_USER_AGENT } }), + 'credential validation' + ); if (!response.ok) return false; const body = await response.text(); if (/Invalid login/i.test(body) || /Login failed/i.test(body)) return false; @@ -650,7 +716,10 @@ export async function checkLotwCertCrl(certSerialHex: string): Promise fetch(url, { headers: { 'User-Agent': LOTW_USER_AGENT } }), + 'CRL check' + ); if (!response.ok) return 'unknown'; const body = (await response.text()).trim(); // ARRL's CRL endpoint returns "0" for a valid cert, "1" for revoked/superseded.