fix: 429 rate limiting — reduce polling, add cache headers, improve error UX
Browse files- PositionChart: reduce price poll from 10s to 30s, chart from 60s to 300s
- /api/trading: add Cache-Control s-maxage=60 header on Supabase responses
- middleware: add /api/trading to CACHEABLE_ROUTES for CDN caching
- rate-limit: increase API limit from 60 to 120 req/min
- auto-trading page: show friendly 429 error with retry button
huggingface-space/nextjs-app/src/components/PositionChart.tsx
CHANGED
|
@@ -110,8 +110,8 @@ export default function PositionChart({
|
|
| 110 |
exitPrice,
|
| 111 |
exitDate,
|
| 112 |
exitReason,
|
| 113 |
-
priceRefreshMs =
|
| 114 |
-
chartRefreshMs =
|
| 115 |
}: PositionChartProps) {
|
| 116 |
const [priceData, setPriceData] = useState<PricePoint[]>([])
|
| 117 |
const [currentPrice, setCurrentPrice] = useState<number | null>(null)
|
|
|
|
| 110 |
exitPrice,
|
| 111 |
exitDate,
|
| 112 |
exitReason,
|
| 113 |
+
priceRefreshMs = 30_000,
|
| 114 |
+
chartRefreshMs = 300_000,
|
| 115 |
}: PositionChartProps) {
|
| 116 |
const [priceData, setPriceData] = useState<PricePoint[]>([])
|
| 117 |
const [currentPrice, setCurrentPrice] = useState<number | null>(null)
|
nextjs-app/src/app/api/trading/route.ts
CHANGED
|
@@ -36,7 +36,9 @@ async function proxyGet(): Promise<NextResponse> {
|
|
| 36 |
return await proxyGetHF()
|
| 37 |
}
|
| 38 |
const data = await resp.json()
|
| 39 |
-
return NextResponse.json(data
|
|
|
|
|
|
|
| 40 |
} catch {
|
| 41 |
// Fallback to HF Space
|
| 42 |
return await proxyGetHF()
|
|
@@ -70,6 +72,16 @@ async function proxyGetHF(): Promise<NextResponse> {
|
|
| 70 |
}
|
| 71 |
}
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
async function proxyPost(body: Record<string, unknown>): Promise<NextResponse> {
|
| 74 |
const controller = new AbortController()
|
| 75 |
const timeout = setTimeout(() => controller.abort(), 120000)
|
|
@@ -82,16 +94,20 @@ async function proxyPost(body: Record<string, unknown>): Promise<NextResponse> {
|
|
| 82 |
})
|
| 83 |
if (!resp.ok) {
|
| 84 |
const text = await resp.text().catch(() => '')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
return NextResponse.json(
|
| 86 |
-
{ error:
|
| 87 |
-
{ status: resp.status
|
| 88 |
)
|
| 89 |
}
|
| 90 |
const data = await resp.json()
|
| 91 |
return NextResponse.json(data)
|
| 92 |
} catch (e: unknown) {
|
| 93 |
const msg = e instanceof Error ? e.message : 'Unknown'
|
| 94 |
-
return
|
| 95 |
} finally {
|
| 96 |
clearTimeout(timeout)
|
| 97 |
}
|
|
|
|
| 36 |
return await proxyGetHF()
|
| 37 |
}
|
| 38 |
const data = await resp.json()
|
| 39 |
+
return NextResponse.json(data, {
|
| 40 |
+
headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=120' },
|
| 41 |
+
})
|
| 42 |
} catch {
|
| 43 |
// Fallback to HF Space
|
| 44 |
return await proxyGetHF()
|
|
|
|
| 72 |
}
|
| 73 |
}
|
| 74 |
|
| 75 |
+
function actionUnavailableResponse(action: unknown, rootError: string) {
|
| 76 |
+
return NextResponse.json({
|
| 77 |
+
success: false,
|
| 78 |
+
action: typeof action === 'string' ? action : null,
|
| 79 |
+
error: rootError,
|
| 80 |
+
hint: 'HF trading backend gecici olarak ulasilamaz durumda. Dashboard snapshot verisi okunabilir, ancak trading aksiyonlari su an calistirilamaz.',
|
| 81 |
+
unavailable: true,
|
| 82 |
+
})
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
async function proxyPost(body: Record<string, unknown>): Promise<NextResponse> {
|
| 86 |
const controller = new AbortController()
|
| 87 |
const timeout = setTimeout(() => controller.abort(), 120000)
|
|
|
|
| 94 |
})
|
| 95 |
if (!resp.ok) {
|
| 96 |
const text = await resp.text().catch(() => '')
|
| 97 |
+
const errorMessage = `Trading action backend error (HTTP ${resp.status}): ${text}`
|
| 98 |
+
if (resp.status >= 500) {
|
| 99 |
+
return actionUnavailableResponse(body.action, errorMessage)
|
| 100 |
+
}
|
| 101 |
return NextResponse.json(
|
| 102 |
+
{ error: errorMessage },
|
| 103 |
+
{ status: resp.status }
|
| 104 |
)
|
| 105 |
}
|
| 106 |
const data = await resp.json()
|
| 107 |
return NextResponse.json(data)
|
| 108 |
} catch (e: unknown) {
|
| 109 |
const msg = e instanceof Error ? e.message : 'Unknown'
|
| 110 |
+
return actionUnavailableResponse(body.action, `Trading action proxy failed: ${msg}`)
|
| 111 |
} finally {
|
| 112 |
clearTimeout(timeout)
|
| 113 |
}
|
nextjs-app/src/app/auto-trading/page.tsx
CHANGED
|
@@ -257,7 +257,7 @@ const EMPTY_PERFORMANCE: Performance = {
|
|
| 257 |
export default function AutoTradingPage() {
|
| 258 |
const { market } = useMarket()
|
| 259 |
const isUS = market === 'us'
|
| 260 |
-
const t = (tr: string, en: string) => isUS ? en : tr
|
| 261 |
|
| 262 |
const [data, setData] = useState<TradingData | null>(null)
|
| 263 |
const [loading, setLoading] = useState(true)
|
|
@@ -284,7 +284,15 @@ export default function AutoTradingPage() {
|
|
| 284 |
// The user's explicit market selection (from global context) takes
|
| 285 |
// priority even when the snapshot has no data for that market.
|
| 286 |
} catch (e: unknown) {
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
} finally {
|
| 289 |
setLoading(false)
|
| 290 |
}
|
|
@@ -312,7 +320,7 @@ export default function AutoTradingPage() {
|
|
| 312 |
|
| 313 |
// Handle 403 gracefully (run/reset moved to worker)
|
| 314 |
if (res.error && res.hint) {
|
| 315 |
-
setActionMessage(`${res.error}`)
|
| 316 |
return
|
| 317 |
}
|
| 318 |
if (res.error) throw new Error(res.error)
|
|
@@ -356,14 +364,19 @@ export default function AutoTradingPage() {
|
|
| 356 |
}
|
| 357 |
|
| 358 |
if (error) {
|
|
|
|
| 359 |
return (
|
| 360 |
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
| 361 |
-
<div className=
|
| 362 |
-
|
| 363 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
<button
|
| 365 |
onClick={loadData}
|
| 366 |
-
className=
|
| 367 |
>
|
| 368 |
{t('Tekrar Dene', 'Retry')}
|
| 369 |
</button>
|
|
@@ -777,8 +790,8 @@ export default function AutoTradingPage() {
|
|
| 777 |
entryDate={p.entryDate}
|
| 778 |
quantity={p.quantity}
|
| 779 |
market={market}
|
| 780 |
-
priceRefreshMs={
|
| 781 |
-
chartRefreshMs={
|
| 782 |
/>
|
| 783 |
))}
|
| 784 |
</div>
|
|
|
|
| 257 |
export default function AutoTradingPage() {
|
| 258 |
const { market } = useMarket()
|
| 259 |
const isUS = market === 'us'
|
| 260 |
+
const t = useCallback((tr: string, en: string) => isUS ? en : tr, [isUS])
|
| 261 |
|
| 262 |
const [data, setData] = useState<TradingData | null>(null)
|
| 263 |
const [loading, setLoading] = useState(true)
|
|
|
|
| 284 |
// The user's explicit market selection (from global context) takes
|
| 285 |
// priority even when the snapshot has no data for that market.
|
| 286 |
} catch (e: unknown) {
|
| 287 |
+
const is429 = e instanceof Error && (e.message.includes('429') || (e as { status?: number }).status === 429)
|
| 288 |
+
if (is429) {
|
| 289 |
+
setError(t(
|
| 290 |
+
'Sunucu yoğun — lütfen 1 dakika bekleyip tekrar deneyin.',
|
| 291 |
+
'Server busy — please wait 1 minute and retry.'
|
| 292 |
+
))
|
| 293 |
+
} else {
|
| 294 |
+
setError(e instanceof Error ? e.message : t('Veriler alınamadı', 'Failed to load data'))
|
| 295 |
+
}
|
| 296 |
} finally {
|
| 297 |
setLoading(false)
|
| 298 |
}
|
|
|
|
| 320 |
|
| 321 |
// Handle 403 gracefully (run/reset moved to worker)
|
| 322 |
if (res.error && res.hint) {
|
| 323 |
+
setActionMessage(`${t('Hata', 'Error')}: ${res.error} ${res.hint}`)
|
| 324 |
return
|
| 325 |
}
|
| 326 |
if (res.error) throw new Error(res.error)
|
|
|
|
| 364 |
}
|
| 365 |
|
| 366 |
if (error) {
|
| 367 |
+
const is429 = error.includes('429') || error.includes('yoğun') || error.includes('busy')
|
| 368 |
return (
|
| 369 |
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
| 370 |
+
<div className={`${is429 ? 'bg-yellow-50 border-yellow-300' : 'bg-red-50 border-red-300'} border rounded-lg p-6 max-w-md text-center`}>
|
| 371 |
+
{is429 ? (
|
| 372 |
+
<Clock className="w-8 h-8 text-yellow-500 mx-auto mb-3" />
|
| 373 |
+
) : (
|
| 374 |
+
<AlertTriangle className="w-8 h-8 text-red-500 mx-auto mb-3" />
|
| 375 |
+
)}
|
| 376 |
+
<p className={`${is429 ? 'text-yellow-700' : 'text-red-700'} mb-4`}>{error}</p>
|
| 377 |
<button
|
| 378 |
onClick={loadData}
|
| 379 |
+
className={`px-4 py-2 ${is429 ? 'bg-yellow-600 hover:bg-yellow-500' : 'bg-red-600 hover:bg-red-500'} text-white rounded-lg text-sm`}
|
| 380 |
>
|
| 381 |
{t('Tekrar Dene', 'Retry')}
|
| 382 |
</button>
|
|
|
|
| 790 |
entryDate={p.entryDate}
|
| 791 |
quantity={p.quantity}
|
| 792 |
market={market}
|
| 793 |
+
priceRefreshMs={30_000}
|
| 794 |
+
chartRefreshMs={300_000}
|
| 795 |
/>
|
| 796 |
))}
|
| 797 |
</div>
|
nextjs-app/src/components/PositionChart.tsx
CHANGED
|
@@ -111,8 +111,8 @@ export default function PositionChart({
|
|
| 111 |
exitPrice,
|
| 112 |
exitDate,
|
| 113 |
exitReason,
|
| 114 |
-
priceRefreshMs =
|
| 115 |
-
chartRefreshMs =
|
| 116 |
market = 'bist',
|
| 117 |
}: PositionChartProps) {
|
| 118 |
const isUS = market === 'us'
|
|
@@ -124,9 +124,14 @@ export default function PositionChart({
|
|
| 124 |
const [lastUpdate, setLastUpdate] = useState<string>('')
|
| 125 |
const [expanded, setExpanded] = useState(true)
|
| 126 |
const mountedRef = useRef(true)
|
|
|
|
| 127 |
|
| 128 |
const isClosed = !!exitDate
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
// ─── Fetch full chart data ─────────────
|
| 131 |
const fetchChart = useCallback(async () => {
|
| 132 |
try {
|
|
@@ -139,7 +144,7 @@ export default function PositionChart({
|
|
| 139 |
setPriceData(json.data)
|
| 140 |
const last = json.data[json.data.length - 1]
|
| 141 |
if (last) {
|
| 142 |
-
setPrevPrice(
|
| 143 |
setCurrentPrice(last.close)
|
| 144 |
}
|
| 145 |
}
|
|
@@ -160,7 +165,7 @@ export default function PositionChart({
|
|
| 160 |
if (!mountedRef.current) return
|
| 161 |
if (json.ok && json.data?.length > 0) {
|
| 162 |
const last = json.data[json.data.length - 1]
|
| 163 |
-
setPrevPrice(
|
| 164 |
setCurrentPrice(last.close)
|
| 165 |
setLastUpdate(new Date().toLocaleTimeString(isUS ? 'en-US' : 'tr-TR'))
|
| 166 |
}
|
|
|
|
| 111 |
exitPrice,
|
| 112 |
exitDate,
|
| 113 |
exitReason,
|
| 114 |
+
priceRefreshMs = 30_000,
|
| 115 |
+
chartRefreshMs = 300_000,
|
| 116 |
market = 'bist',
|
| 117 |
}: PositionChartProps) {
|
| 118 |
const isUS = market === 'us'
|
|
|
|
| 124 |
const [lastUpdate, setLastUpdate] = useState<string>('')
|
| 125 |
const [expanded, setExpanded] = useState(true)
|
| 126 |
const mountedRef = useRef(true)
|
| 127 |
+
const currentPriceRef = useRef<number | null>(null)
|
| 128 |
|
| 129 |
const isClosed = !!exitDate
|
| 130 |
|
| 131 |
+
useEffect(() => {
|
| 132 |
+
currentPriceRef.current = currentPrice
|
| 133 |
+
}, [currentPrice])
|
| 134 |
+
|
| 135 |
// ─── Fetch full chart data ─────────────
|
| 136 |
const fetchChart = useCallback(async () => {
|
| 137 |
try {
|
|
|
|
| 144 |
setPriceData(json.data)
|
| 145 |
const last = json.data[json.data.length - 1]
|
| 146 |
if (last) {
|
| 147 |
+
setPrevPrice(currentPriceRef.current)
|
| 148 |
setCurrentPrice(last.close)
|
| 149 |
}
|
| 150 |
}
|
|
|
|
| 165 |
if (!mountedRef.current) return
|
| 166 |
if (json.ok && json.data?.length > 0) {
|
| 167 |
const last = json.data[json.data.length - 1]
|
| 168 |
+
setPrevPrice(currentPriceRef.current)
|
| 169 |
setCurrentPrice(last.close)
|
| 170 |
setLastUpdate(new Date().toLocaleTimeString(isUS ? 'en-US' : 'tr-TR'))
|
| 171 |
}
|
nextjs-app/src/lib/rate-limit.ts
CHANGED
|
@@ -81,8 +81,8 @@ export function getClientIp(headers: Headers): string {
|
|
| 81 |
|
| 82 |
// ─── Rate limit presets ───
|
| 83 |
|
| 84 |
-
/** General API:
|
| 85 |
-
export const API_RATE_LIMIT = { maxRequests:
|
| 86 |
|
| 87 |
/** Write operations (POST actions): 20 per minute */
|
| 88 |
export const WRITE_RATE_LIMIT = { maxRequests: 20, windowMs: 60_000 }
|
|
|
|
| 81 |
|
| 82 |
// ─── Rate limit presets ───
|
| 83 |
|
| 84 |
+
/** General API: 120 requests per minute */
|
| 85 |
+
export const API_RATE_LIMIT = { maxRequests: 120, windowMs: 60_000 }
|
| 86 |
|
| 87 |
/** Write operations (POST actions): 20 per minute */
|
| 88 |
export const WRITE_RATE_LIMIT = { maxRequests: 20, windowMs: 60_000 }
|
nextjs-app/src/middleware.ts
CHANGED
|
@@ -40,6 +40,7 @@ const CACHEABLE_ROUTES: Record<string, string> = {
|
|
| 40 |
'/api/stocks': 'public, s-maxage=60, stale-while-revalidate=120',
|
| 41 |
'/api/stock-data': 'public, s-maxage=60, stale-while-revalidate=120',
|
| 42 |
'/api/technical-analysis': 'public, s-maxage=120, stale-while-revalidate=300',
|
|
|
|
| 43 |
'/api/universe': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
| 44 |
'/api/news': 'public, s-maxage=120, stale-while-revalidate=300',
|
| 45 |
'/api/kap': 'public, s-maxage=60, stale-while-revalidate=120',
|
|
|
|
| 40 |
'/api/stocks': 'public, s-maxage=60, stale-while-revalidate=120',
|
| 41 |
'/api/stock-data': 'public, s-maxage=60, stale-while-revalidate=120',
|
| 42 |
'/api/technical-analysis': 'public, s-maxage=120, stale-while-revalidate=300',
|
| 43 |
+
'/api/trading': 'public, s-maxage=60, stale-while-revalidate=120',
|
| 44 |
'/api/universe': 'public, s-maxage=3600, stale-while-revalidate=7200',
|
| 45 |
'/api/news': 'public, s-maxage=120, stale-while-revalidate=300',
|
| 46 |
'/api/kap': 'public, s-maxage=60, stale-while-revalidate=120',
|