veteroner commited on
Commit
3c3dc4f
·
1 Parent(s): c83a325

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 = 10_000,
114
- chartRefreshMs = 60_000,
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: `Trading action backend error (HTTP ${resp.status}): ${text}` },
87
- { status: resp.status >= 400 && resp.status < 500 ? resp.status : 502 }
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 NextResponse.json({ error: `Trading action proxy failed: ${msg}` }, { status: 502 })
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
- setError(e instanceof Error ? e.message : t('Veriler alınamadı', 'Failed to load data'))
 
 
 
 
 
 
 
 
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="bg-red-900/30 border border-red-500/50 rounded-lg p-6 max-w-md text-center">
362
- <AlertTriangle className="w-8 h-8 text-red-400 mx-auto mb-3" />
363
- <p className="text-red-300 mb-4">{error}</p>
 
 
 
 
364
  <button
365
  onClick={loadData}
366
- className="px-4 py-2 bg-red-600 hover:bg-red-500 text-white rounded-lg text-sm"
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={10_000}
781
- chartRefreshMs={60_000}
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 = 10_000,
115
- chartRefreshMs = 60_000,
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(currentPrice)
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(currentPrice)
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: 60 requests per minute */
85
- export const API_RATE_LIMIT = { maxRequests: 60, windowMs: 60_000 }
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',