Three Data Sources, One Feature: Moon, Tide, Weather Per Trip
One enrichment step, three upstream sources with three different failure modes — a local lunar calculation, a tide API, and a weather API layered over a government-site scrape.
Every time a Fishing Tracker Pro user logs a trip, the backend makes calls to a WeatherAPI.com endpoint, a WorldTides-or-fallback tide calculation, and a local astronomical function — three sources with wildly different reliability profiles, stitched into a single field on a single row. Treating that as one API call instead of three is the whole architectural problem.
One slow or down source never blocks the other two — allSettled, not all.
The three sources, ranked by how often they fail
Moon phase basically never fails. It's a pure function of a date and a known lunar cycle length — no network call, no external dependency:
function getMoonPhase(date) {
const knownNewMoon = new Date('2000-01-06');
const lunarCycle = 29.53058867;
const daysSince = (date - knownNewMoon) / 86400000;
const phase = (daysSince % lunarCycle) / lunarCycle;
return phaseNameFromFraction(phase);
}Tide is a paid third-party API (WorldTides) with a free-tier quota, so it fails in two ways: quota exhaustion and plain timeouts. There's a fallback tide approximation for when it's unavailable, which trades precision for availability.
Weather is the least reliable by a wide margin, because "weather" in this app is actually two separate integrations: WeatherAPI.com for wind/wave/temp, and a scrape of the Mauritius Meteorological Services site for sunrise, sunset, moonrise, and moonset (the subject of the next post). Scraping a government page that was never built to be an API is the single most fragile link in the chain.
Why allSettled, not all
The naive implementation calls all three, awaits a Promise.all, and saves. The problem: Promise.all rejects the instant any promise rejects, which means a WorldTides timeout throws away moon phase data that had already resolved successfully three seconds earlier. That's backwards — the trip should save with whatever enrichment succeeded, and log what didn't.
async function enrichTripContext(date, lat, lon) {
const [moon, tide, weather] = await Promise.allSettled([
getMoonPhase(date),
getTideState(date, lat, lon),
getWeatherConditions(date, lat, lon),
]);
return {
moon_phase: moon.status === 'fulfilled' ? moon.value : null,
tide_state: tide.status === 'fulfilled' ? tide.value : null,
weather: weather.status === 'fulfilled' ? weather.value : null,
};
}Each null is a real, honest signal — "we don't know," not "we guessed wrong." The UI treats missing fields as absent, not as zero or default values, which matters a lot for a later feature: correlating catch success with tide state only makes sense if null never gets silently coerced into "neutral tide."
Timeouts matter more than error handling
The bug that actually bit us wasn't a thrown exception — it was a weather call that hung for 25 seconds because the upstream API had degraded without returning an error. Promise.allSettled doesn't help if nothing ever settles. Every outbound call in the enrichment path now carries an explicit timeout via axios's timeout option, tuned to 5 seconds for weather and tide and left unset for the local moon calculation (since it can't hang):
axios.get(weatherUrl, { params, timeout: 5000 });Five seconds felt aggressive at first. In practice it's the difference between a trip-logging form that responds in under a second on a good day and one that occasionally makes someone wait half a minute wondering if the submit button did anything.
What one enrichment step buys you
The payoff of centralizing this in one function, rather than three separate calls sprinkled across the trip controller, is that every caller — the log trip endpoint, the trip-recommendation engine, the admin backfill script that enriches old rows — gets the same failure semantics for free. Nobody has to remember to wrap a tide call in a try/catch a second time.
Series: Fishing Tracker Pro. Next: the weather source that isn't an API at all — scraping a government website, and the judgment calls that come with it.