feat: add rate limiting for CoinGecko API and global error handling
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { i18n } from './i18n.js'
|
||||
import { setupGlobalErrorHandler } from './components/error-boundary.js'
|
||||
|
||||
async function initApp() {
|
||||
// Setup global error handling first
|
||||
setupGlobalErrorHandler()
|
||||
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (savedTheme) {
|
||||
document.documentElement.dataset.theme = savedTheme
|
||||
|
||||
220
js/components/error-boundary.js
Normal file
220
js/components/error-boundary.js
Normal file
@@ -0,0 +1,220 @@
|
||||
import { t } from '../i18n.js'
|
||||
|
||||
/**
|
||||
* Error Boundary Component
|
||||
* Catches errors in child components and displays fallback UI
|
||||
*/
|
||||
class ErrorBoundary extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
this.hasError = false
|
||||
this.error = null
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.setupErrorHandling()
|
||||
}
|
||||
|
||||
setupErrorHandling() {
|
||||
// Catch errors from child components
|
||||
this.addEventListener('error', (e) => {
|
||||
e.stopPropagation()
|
||||
this.handleError(e.error || e.message || 'Unknown error')
|
||||
})
|
||||
}
|
||||
|
||||
handleError(error) {
|
||||
console.error('ErrorBoundary caught:', error)
|
||||
this.hasError = true
|
||||
this.error = error
|
||||
this.renderError()
|
||||
}
|
||||
|
||||
renderError() {
|
||||
const errorMessage = this.error?.message || String(this.error) || t('common.error')
|
||||
|
||||
this.innerHTML = /* html */`
|
||||
<div class="error-boundary">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<h3 class="error-title">${t('error.title') || 'Etwas ist schiefgelaufen'}</h3>
|
||||
<p class="error-message">${this.escapeHtml(errorMessage)}</p>
|
||||
<button class="btn btn-primary error-retry" type="button">
|
||||
${t('error.retry') || 'Erneut versuchen'}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
this.querySelector('.error-retry')?.addEventListener('click', () => this.retry())
|
||||
}
|
||||
|
||||
retry() {
|
||||
this.hasError = false
|
||||
this.error = null
|
||||
this.innerHTML = ''
|
||||
this.dispatchEvent(new CustomEvent('retry', { bubbles: true }))
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.hasError = false
|
||||
this.error = null
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
if (!str) return ''
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('error-boundary', ErrorBoundary)
|
||||
|
||||
/**
|
||||
* Global error handler for uncaught errors
|
||||
*/
|
||||
export function setupGlobalErrorHandler() {
|
||||
window.addEventListener('error', (event) => {
|
||||
console.error('Global error:', event.error)
|
||||
|
||||
// Don't show UI for script loading errors
|
||||
if (event.filename && event.filename.includes('.js')) {
|
||||
showErrorToast(event.message || 'Ein Fehler ist aufgetreten')
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('Unhandled promise rejection:', event.reason)
|
||||
showErrorToast(event.reason?.message || 'Ein Fehler ist aufgetreten')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a temporary error toast notification
|
||||
*/
|
||||
function showErrorToast(message) {
|
||||
// Remove existing toast
|
||||
document.querySelector('.error-toast')?.remove()
|
||||
|
||||
const toast = document.createElement('div')
|
||||
toast.className = 'error-toast'
|
||||
toast.innerHTML = /* html */`
|
||||
<span class="error-toast-icon">⚠️</span>
|
||||
<span class="error-toast-message">${escapeHtml(message)}</span>
|
||||
<button class="error-toast-close" aria-label="Schließen">×</button>
|
||||
`
|
||||
|
||||
document.body.appendChild(toast)
|
||||
|
||||
// Auto-dismiss after 5 seconds
|
||||
const timeout = setTimeout(() => toast.remove(), 5000)
|
||||
|
||||
toast.querySelector('.error-toast-close')?.addEventListener('click', () => {
|
||||
clearTimeout(timeout)
|
||||
toast.remove()
|
||||
})
|
||||
|
||||
// Trigger animation
|
||||
requestAnimationFrame(() => toast.classList.add('visible'))
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return ''
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// Styles
|
||||
const style = document.createElement('style')
|
||||
style.textContent = /* css */`
|
||||
error-boundary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
error-boundary .error-boundary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2xl);
|
||||
text-align: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
error-boundary .error-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: var(--space-md);
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
error-boundary .error-title {
|
||||
font-size: var(--font-size-lg);
|
||||
margin: 0 0 var(--space-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
error-boundary .error-message {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 var(--space-lg);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* Error Toast */
|
||||
.error-toast {
|
||||
position: fixed;
|
||||
bottom: var(--space-lg);
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(100px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 10000;
|
||||
opacity: 0;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.error-toast.visible {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.error-toast-icon {
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.error-toast-message {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text);
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error-toast-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.error-toast-close:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
|
||||
export { ErrorBoundary }
|
||||
@@ -16,24 +16,59 @@ const CURRENCY_SYMBOLS = {
|
||||
JPY: '¥'
|
||||
}
|
||||
|
||||
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes
|
||||
const CACHE_DURATION = 10 * 60 * 1000 // 10 minutes (CoinGecko free tier: 10-30 req/min)
|
||||
const MIN_REQUEST_INTERVAL = 6 * 1000 // 6 seconds between requests (max ~10/min)
|
||||
|
||||
let cachedRates = null
|
||||
let cacheTimestamp = 0
|
||||
let lastRequestTime = 0
|
||||
let pendingRequest = null
|
||||
|
||||
/**
|
||||
* Fetches current XMR rates from CoinGecko
|
||||
* Fetches current XMR rates from CoinGecko with rate limiting
|
||||
* @returns {Promise<Object>} Rates per currency (e.g. { EUR: 329.05, USD: 388.87 })
|
||||
*/
|
||||
export async function getXmrRates() {
|
||||
// Check cache
|
||||
// Return cached rates if still valid
|
||||
if (cachedRates && Date.now() - cacheTimestamp < CACHE_DURATION) {
|
||||
return cachedRates
|
||||
}
|
||||
|
||||
// If a request is already pending, wait for it
|
||||
if (pendingRequest) {
|
||||
return pendingRequest
|
||||
}
|
||||
|
||||
// Rate limiting: ensure minimum interval between requests
|
||||
const timeSinceLastRequest = Date.now() - lastRequestTime
|
||||
if (timeSinceLastRequest < MIN_REQUEST_INTERVAL && cachedRates) {
|
||||
return cachedRates
|
||||
}
|
||||
|
||||
// Create and store the pending request
|
||||
pendingRequest = fetchRates()
|
||||
|
||||
try {
|
||||
const rates = await pendingRequest
|
||||
return rates
|
||||
} finally {
|
||||
pendingRequest = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRates() {
|
||||
lastRequestTime = Date.now()
|
||||
|
||||
try {
|
||||
const currencies = 'eur,usd,gbp,chf,jpy'
|
||||
const response = await fetch(`${COINGECKO_API}?ids=monero&vs_currencies=${currencies}`)
|
||||
|
||||
// Handle rate limit response
|
||||
if (response.status === 429) {
|
||||
console.warn('CoinGecko rate limit hit, using cached rates')
|
||||
return cachedRates || getDefaultRates()
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.monero) {
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
"loading": "Laden...",
|
||||
"error": "Fehler beim Laden"
|
||||
},
|
||||
"error": {
|
||||
"title": "Etwas ist schiefgelaufen",
|
||||
"retry": "Erneut versuchen",
|
||||
"offline": "Keine Internetverbindung"
|
||||
},
|
||||
"categories": {
|
||||
"electronics": "Elektronik",
|
||||
"furniture": "Möbel",
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
"loading": "Loading...",
|
||||
"error": "Error loading"
|
||||
},
|
||||
"error": {
|
||||
"title": "Something went wrong",
|
||||
"retry": "Try again",
|
||||
"offline": "No internet connection"
|
||||
},
|
||||
"categories": {
|
||||
"electronics": "Electronics",
|
||||
"furniture": "Furniture",
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
"loading": "Chargement...",
|
||||
"error": "Erreur de chargement"
|
||||
},
|
||||
"error": {
|
||||
"title": "Une erreur est survenue",
|
||||
"retry": "Réessayer",
|
||||
"offline": "Pas de connexion internet"
|
||||
},
|
||||
"categories": {
|
||||
"electronics": "Électronique",
|
||||
"furniture": "Meubles",
|
||||
|
||||
Reference in New Issue
Block a user