jskelet 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/docs/06-cache.md +195 -4
- package/docs/07-yapilandirma.md +70 -2
- package/docs/en/06-caching.md +202 -5
- package/docs/en/07-configuration.md +72 -2
- package/package.json +1 -1
- package/src/client/cache-panel/login.html +63 -0
- package/src/client/cache-panel/panel.css +448 -0
- package/src/client/cache-panel/panel.html +133 -0
- package/src/client/cache-panel/panel.js +339 -0
- package/src/client/devtools/overlay.js +85 -0
- package/src/client/devtools/report.js +13 -0
- package/src/config/defaults.js +67 -2
- package/src/config/index.js +98 -1
- package/src/index.js +5 -0
- package/src/server/cache-panel.js +505 -0
- package/src/server/create-app.js +16 -0
- package/src/server/data-cache.js +92 -2
- package/src/server/dev/report.js +8 -1
- package/src/server/html-cache.js +36 -0
- package/src/server/prewarm.js +94 -15
- package/src/server/upstream-limiter.js +376 -0
- package/src/server/upstream-tracking.js +25 -0
package/src/server/dev/report.js
CHANGED
|
@@ -13,8 +13,9 @@ import fs from "node:fs";
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import zlib from "node:zlib";
|
|
15
15
|
import { getHtmlCacheEntries, getHtmlCacheSize } from "../html-cache.js";
|
|
16
|
-
import { getDataCacheSize } from "../data-cache.js";
|
|
16
|
+
import { getDataCacheSize, getDataCacheStats } from "../data-cache.js";
|
|
17
17
|
import { getRedisStatus } from "../redis.js";
|
|
18
|
+
import { getUpstreamLimiterStatus } from "../upstream-limiter.js";
|
|
18
19
|
import { prewarmProgress } from "../prewarm.js";
|
|
19
20
|
import { getConfig } from "../../config/index.js";
|
|
20
21
|
|
|
@@ -351,10 +352,16 @@ export function buildReport(devtools) {
|
|
|
351
352
|
// Veri önbelleğinden yalnızca sayaç: uzun kuyruklu bir sitede on
|
|
352
353
|
// binlerce anahtar oluyor ve dökümü rapora koymak faydasız bir yük.
|
|
353
354
|
data: getDataCacheSize(),
|
|
355
|
+
// Sayaçlar dökümün yerine geçmiyor, onu tamamlıyor: "kaç girdi var"
|
|
356
|
+
// sorusundan çok "kaç okuma upstream'e gitti" sorusu karar veriyor.
|
|
357
|
+
dataStats: getDataCacheStats(),
|
|
354
358
|
// Paylaşımlı kademe kapalıyken de basılır: "Redis'i açtım ama neden
|
|
355
359
|
// çalışmıyor" sorusunun cevabı en çok burada aranıyor.
|
|
356
360
|
redis: getRedisStatus(),
|
|
357
361
|
},
|
|
362
|
+
// Hız freninin o anki durumu. 429 fırtınasında "şu an saniyede kaça
|
|
363
|
+
// indi" bilgisi olmadan ayar yapmak körlemesine oluyor.
|
|
364
|
+
upstream: getUpstreamLimiterStatus(),
|
|
358
365
|
prewarm: { ...prewarmProgress },
|
|
359
366
|
requests: devtools.requests,
|
|
360
367
|
errors: devtools.errors,
|
package/src/server/html-cache.js
CHANGED
|
@@ -686,6 +686,11 @@ onCacheEvent((event) => {
|
|
|
686
686
|
return;
|
|
687
687
|
}
|
|
688
688
|
|
|
689
|
+
if (event.type === "html:drop") {
|
|
690
|
+
if (typeof event.key === "string") dropLocalKey(event.key);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
689
694
|
if (event.type !== "html:invalidate") return;
|
|
690
695
|
|
|
691
696
|
const targets = /** @type {(string | RegExp)[]} */ (
|
|
@@ -742,6 +747,37 @@ export function invalidateHtmlByDependency(dataKeys) {
|
|
|
742
747
|
return keys.size;
|
|
743
748
|
}
|
|
744
749
|
|
|
750
|
+
/**
|
|
751
|
+
* Tek bir önbellek **anahtarını** düşürür.
|
|
752
|
+
*
|
|
753
|
+
* `invalidateHtmlCache()` yol deseniyle çalışıyor ve bir yolun bütün query
|
|
754
|
+
* varyantlarını birlikte düşürüyor. Yönetim paneli listedeki tek satırı
|
|
755
|
+
* silebilmek istiyor: `/liste?sayfa=2` düşerken `/liste?sayfa=3` sıcak
|
|
756
|
+
* kalmalı. Desen sözdiziminde `?` kaçırılamadığı için ayrı bir yüzey.
|
|
757
|
+
*
|
|
758
|
+
* @param {string} key `yol?query` biçiminde tam anahtar.
|
|
759
|
+
* @returns {boolean} Girdi var mıydı.
|
|
760
|
+
*/
|
|
761
|
+
export function dropHtmlCacheKey(key) {
|
|
762
|
+
const existed = dropLocalKey(key);
|
|
763
|
+
|
|
764
|
+
if (redisShares("html")) redisDrop([cacheKey("html", key)]);
|
|
765
|
+
publishCacheEvent({ type: "html:drop", key });
|
|
766
|
+
|
|
767
|
+
return existed;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* @param {string} key
|
|
772
|
+
* @returns {boolean}
|
|
773
|
+
*/
|
|
774
|
+
function dropLocalKey(key) {
|
|
775
|
+
// Uçuştaki tazeleme de geçersiz: silinen girdiyi geri yazmamalı.
|
|
776
|
+
tokens.delete(key);
|
|
777
|
+
invalidated.delete(key);
|
|
778
|
+
return drop(key);
|
|
779
|
+
}
|
|
780
|
+
|
|
745
781
|
/**
|
|
746
782
|
* Invalidate edilmiş ve henüz kimsenin istemediği yolları döner ve kuyruğu
|
|
747
783
|
* boşaltır. Isıtma turu bunları başa alır; iki tur aynı yolu tekrar
|
package/src/server/prewarm.js
CHANGED
|
@@ -22,6 +22,9 @@ import process from "node:process";
|
|
|
22
22
|
import { getConfig, hook } from "../config/index.js";
|
|
23
23
|
import { getRequestContext } from "../http/request-context.js";
|
|
24
24
|
import { takeInvalidatedPaths } from "./html-cache.js";
|
|
25
|
+
import { getDataCacheStats } from "./data-cache.js";
|
|
26
|
+
import { isTransientStatus } from "./upstream-tracking.js";
|
|
27
|
+
import { upstreamCooldownMs } from "./upstream-limiter.js";
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
30
|
* Isıtmanın canlı durumu. Dev araçları bunu okuyup ilerlemeyi gösterir;
|
|
@@ -266,7 +269,10 @@ function devGateHeader() {
|
|
|
266
269
|
* Tur ilerlemesini `prewarmProgress`'e yazar. Tekrar turunda sayaçların
|
|
267
270
|
* anlamı değiştiği için çağıran taraf kendi formülünü verir.
|
|
268
271
|
* @param {() => Promise<void>} [pace] İstek başına beklenen hız freni.
|
|
269
|
-
* @returns {Promise<{ ok: number, failed: number,
|
|
272
|
+
* @returns {Promise<{ ok: number, failed: number,
|
|
273
|
+
* failures: { path: string, status: number }[] }>}
|
|
274
|
+
* `failures` durum koduyla birlikte döner: tekrar turuna yalnızca geçici
|
|
275
|
+
* hatalar alınıyor, kalıcı olanı yeniden denemek boşa çağrı.
|
|
270
276
|
*/
|
|
271
277
|
async function crawl(origin, paths, concurrency, report = undefined, pace = undefined) {
|
|
272
278
|
const { brand } = getConfig();
|
|
@@ -275,8 +281,8 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
|
|
|
275
281
|
let index = 0;
|
|
276
282
|
let ok = 0;
|
|
277
283
|
let failed = 0;
|
|
278
|
-
/** @type {string[]} */
|
|
279
|
-
const
|
|
284
|
+
/** @type {{ path: string, status: number }[]} */
|
|
285
|
+
const failures = [];
|
|
280
286
|
|
|
281
287
|
async function worker() {
|
|
282
288
|
while (index < paths.length) {
|
|
@@ -301,7 +307,7 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
|
|
|
301
307
|
if (response.ok) ok += 1;
|
|
302
308
|
else {
|
|
303
309
|
failed += 1;
|
|
304
|
-
|
|
310
|
+
failures.push({ path: target, status: response.status });
|
|
305
311
|
}
|
|
306
312
|
|
|
307
313
|
prewarmProgress.entries.push({
|
|
@@ -314,7 +320,8 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
|
|
|
314
320
|
});
|
|
315
321
|
} catch (error) {
|
|
316
322
|
failed += 1;
|
|
317
|
-
|
|
323
|
+
// Yanıt hiç gelmedi: ağ hatası her zaman geçici, tekrar denenir.
|
|
324
|
+
failures.push({ path: target, status: 0 });
|
|
318
325
|
prewarmProgress.entries.push({
|
|
319
326
|
path: target,
|
|
320
327
|
status: 0,
|
|
@@ -339,7 +346,66 @@ async function crawl(origin, paths, concurrency, report = undefined, pace = unde
|
|
|
339
346
|
Array.from({ length: Math.min(concurrency, paths.length) }, worker),
|
|
340
347
|
);
|
|
341
348
|
|
|
342
|
-
return { ok, failed,
|
|
349
|
+
return { ok, failed, failures };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Tekrar turuna girecek yollar. Yalnızca geçici hatalar: `400`/`403`/`404` gibi
|
|
354
|
+
* deterministik cevaplar tekrar denemekle düzelmez ve o çağrılar kotadan
|
|
355
|
+
* karşılıksız yiyor. Sınıflandırma render tarafıyla aynı listeden
|
|
356
|
+
* (`isTransientStatus`), yoksa iki yer birbirinden kayar.
|
|
357
|
+
*
|
|
358
|
+
* @param {{ path: string, status: number }[]} failures
|
|
359
|
+
* @returns {string[]}
|
|
360
|
+
*/
|
|
361
|
+
function retryablePaths(failures) {
|
|
362
|
+
return failures
|
|
363
|
+
.filter((failure) => isTransientStatus(failure.status))
|
|
364
|
+
.map((failure) => failure.path);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Tekrar turundan önce beklenecek süre.
|
|
369
|
+
*
|
|
370
|
+
* Sabit bekleme yanlış soruyu cevaplıyordu: doğru süreyi upstream biliyor.
|
|
371
|
+
* Hız freni açıkken `Retry-After` ya da devre kesicinin soğuma süresi zaten
|
|
372
|
+
* elimizde; 10 saniye kapalı kalacak bir kesiciden 2 saniye sonra tekrar
|
|
373
|
+
* denemek, aynı 429'u peşin peşin almak demek.
|
|
374
|
+
*
|
|
375
|
+
* @returns {number} ms
|
|
376
|
+
*/
|
|
377
|
+
function retryDelayMs() {
|
|
378
|
+
const configured = setting("PREWARM_RETRY_DELAY_MS", "retryDelayMs", 2000);
|
|
379
|
+
const cooldown = upstreamCooldownMs();
|
|
380
|
+
|
|
381
|
+
// Fren kapalıysa `cooldown` 0 olur ve davranış eskisi gibi kalır. Üst sınır
|
|
382
|
+
// turun sonsuza kadar açık kalmasını engelliyor.
|
|
383
|
+
return Math.min(Math.max(configured, cooldown), 60_000);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Turun veri önbelleği üzerinden upstream'e ne kadar dokunduğunu özetler.
|
|
388
|
+
*
|
|
389
|
+
* @param {ReturnType<typeof getDataCacheStats>} before Tur başındaki sayaçlar.
|
|
390
|
+
* @returns {string} Okunacak bir şey yoksa boş dize.
|
|
391
|
+
*/
|
|
392
|
+
function upstreamUsage(before) {
|
|
393
|
+
const after = getDataCacheStats();
|
|
394
|
+
const reads = after.reads - before.reads;
|
|
395
|
+
if (reads <= 0) return "";
|
|
396
|
+
|
|
397
|
+
const produced = after.produced - before.produced;
|
|
398
|
+
const coalesced = after.coalesced - before.coalesced;
|
|
399
|
+
const shared = after.shared - before.shared;
|
|
400
|
+
const served = reads - produced;
|
|
401
|
+
const ratio = Math.round((served / reads) * 100);
|
|
402
|
+
|
|
403
|
+
return (
|
|
404
|
+
`${produced} upstream call${produced === 1 ? "" : "s"} for ${reads} data read${reads === 1 ? "" : "s"} ` +
|
|
405
|
+
`(${ratio}% from the data cache` +
|
|
406
|
+
`${coalesced ? `, ${coalesced} coalesced` : ""}` +
|
|
407
|
+
`${shared ? `, ${shared} from the shared tier` : ""})`
|
|
408
|
+
);
|
|
343
409
|
}
|
|
344
410
|
|
|
345
411
|
/**
|
|
@@ -392,13 +458,18 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
392
458
|
});
|
|
393
459
|
suppressed.clear();
|
|
394
460
|
|
|
461
|
+
// Kotanın gerçekten ne kadar harcandığı ancak veri önbelleğinden görülür:
|
|
462
|
+
// 400 sayfalık bir tur, ortak bir uç için tek çağrı da yapabilir dört yüz de.
|
|
463
|
+
const dataBefore = getDataCacheStats();
|
|
464
|
+
|
|
395
465
|
let ok = 0;
|
|
396
466
|
let failed = 0;
|
|
397
467
|
let recovered = 0;
|
|
468
|
+
let skippedRetry = 0;
|
|
398
469
|
try {
|
|
399
|
-
/** @type {string[]} */
|
|
400
|
-
let
|
|
401
|
-
({ ok, failed,
|
|
470
|
+
/** @type {{ path: string, status: number }[]} */
|
|
471
|
+
let failures;
|
|
472
|
+
({ ok, failed, failures } = await crawl(
|
|
402
473
|
origin,
|
|
403
474
|
paths,
|
|
404
475
|
concurrency,
|
|
@@ -409,18 +480,18 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
409
480
|
// Hatalar çoğunlukla upstream rate limit'i (429): ilk tur yüzlerce sayfayı
|
|
410
481
|
// aynı anda çekerken API'yi zorluyor. Tek seri tekrar turu bu sayfaların
|
|
411
482
|
// önbelleğe girmesini sağlıyor; aksi hâlde ziyaretçi soğuk render'ı öder.
|
|
412
|
-
|
|
483
|
+
const retryPaths = retryablePaths(failures);
|
|
484
|
+
skippedRetry = failures.length - retryPaths.length;
|
|
485
|
+
|
|
486
|
+
if (retryPaths.length) {
|
|
413
487
|
const firstOk = ok;
|
|
414
488
|
const firstFailed = failed;
|
|
415
489
|
|
|
416
|
-
|
|
417
|
-
// 429'u almak demek.
|
|
418
|
-
const retryDelay = setting("PREWARM_RETRY_DELAY_MS", "retryDelayMs", 2000);
|
|
419
|
-
await sleep(retryDelay);
|
|
490
|
+
await sleep(retryDelayMs());
|
|
420
491
|
|
|
421
492
|
const retry = await crawl(
|
|
422
493
|
origin,
|
|
423
|
-
|
|
494
|
+
retryPaths,
|
|
424
495
|
1,
|
|
425
496
|
(retriedOk) => {
|
|
426
497
|
// Tekrar turunda her başarı bir hatayı başarıya çevirir.
|
|
@@ -450,10 +521,18 @@ export async function prewarm({ origin, quiet = false, paths: only }) {
|
|
|
450
521
|
`${pending.length ? `, ${pending.length} invalidated` : ""}` +
|
|
451
522
|
`${failed ? `, ${failed} failed` : ""}` +
|
|
452
523
|
`${recovered ? `, ${recovered} recovered on the retry pass` : ""}` +
|
|
524
|
+
`${skippedRetry ? `, ${skippedRetry} not retried (permanent)` : ""}` +
|
|
453
525
|
`${skipped > 0 ? `, ${skipped} ${rotate ? "deferred to the next pass" : "over the limit"}` : ""}` +
|
|
454
526
|
` (${(elapsed / 1000).toFixed(1)}s)`,
|
|
455
527
|
);
|
|
456
528
|
|
|
529
|
+
// Turun upstream'e ne kadar dokunduğu. Asıl karar bu satıra bakılarak
|
|
530
|
+
// veriliyor: oran düşükse çözüm hız freni değil, `withDataCache` TTL'ini
|
|
531
|
+
// tur aralığından uzun tutmak — fren çağrıları yavaşlatır, sayısını
|
|
532
|
+
// azaltmaz.
|
|
533
|
+
const upstreamCalls = upstreamUsage(dataBefore);
|
|
534
|
+
if (upstreamCalls) console.log(`[prewarm] ${upstreamCalls}`);
|
|
535
|
+
|
|
457
536
|
// Tur boyunca bastırılan hatalar: en sık görülenler önce, liste uzarsa
|
|
458
537
|
// kalanı tek satırda toplanır. Amaç, logu şişirmeden "ne bozuldu"yu
|
|
459
538
|
// görünür tutmak.
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream API'ye giden isteklerin host başına hız freni.
|
|
3
|
+
*
|
|
4
|
+
* Isıtma turundaki `rps` ayarı bu işi yapamıyordu: o, kendi sunucumuza atılan
|
|
5
|
+
* **sayfa** isteklerini sayıyor. Bir sayfa render'ı bir API çağrısı da yapabilir
|
|
6
|
+
* yirmi tane de; kotayı bağlayan şey sayfa sayısı değil, çağrı sayısı. Bu
|
|
7
|
+
* yüzden fren `trackUpstreamFetch()` sarmalayıcısına, yani gerçek çağrının
|
|
8
|
+
* geçtiği yere konuyor — ısıtma da, gerçek trafik de aynı bütçeden harcar.
|
|
9
|
+
*
|
|
10
|
+
* Üç mekanizma birlikte çalışıyor, çünkü üç farklı şeyi sınırlıyorlar:
|
|
11
|
+
*
|
|
12
|
+
* token bucket → ortalama hız (saniyedeki çağrı)
|
|
13
|
+
* concurrency → anlık baskı (aynı anda uçan çağrı)
|
|
14
|
+
* AIMD → doğru hızın ne olduğu
|
|
15
|
+
*
|
|
16
|
+
* Sabit bir hız her zaman ya çok yavaş ya çok hızlıdır: kotanın gerçek sınırını
|
|
17
|
+
* kimse config'e doğru yazamaz, üstelik gün içinde değişir. 429 geldiğinde hızı
|
|
18
|
+
* yarıya indirip (çarpımsal azalma) temiz geçen her pencerede bir adım geri
|
|
19
|
+
* çıkmak (toplamsal artış), sınırı elle ayar yapılmadan bulur.
|
|
20
|
+
*
|
|
21
|
+
* Devre kesici de aynı gerekçeyle var: 429 geçici sayıldığı için o çağrıyla
|
|
22
|
+
* üretilen HTML önbelleğe **yazılmıyor**. Yani rate limit'e girmiş bir turda
|
|
23
|
+
* kota harcanır ve karşılığında hiçbir şey saklanmaz. Art arda 429 alan bir
|
|
24
|
+
* host'a bir süre hiç gitmemek, o boşa yanmayı kesiyor.
|
|
25
|
+
*
|
|
26
|
+
* Varsayılan **kapalı**: `rate` verilmedikçe hiçbir istek beklemez. Framework
|
|
27
|
+
* mevcut kurulumların davranışını sessizce değiştirmez.
|
|
28
|
+
*/
|
|
29
|
+
import { DEFAULT_UPSTREAM_LIMIT } from "../config/defaults.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {object} HostState
|
|
33
|
+
* @property {string} host
|
|
34
|
+
* @property {number} maxRate Config'te verilen tavan; AIMD bunun üstüne çıkmaz.
|
|
35
|
+
* @property {number} minRate Azalmanın dibi; 0'a inip tamamen kilitlenmesin.
|
|
36
|
+
* @property {number} rate Saniyedeki izin — AIMD bunu oynatır.
|
|
37
|
+
* @property {number} burst Kovanın boyu: kısa patlamalara verilen tolerans.
|
|
38
|
+
* @property {number} concurrency Aynı anda uçabilecek çağrı sayısı.
|
|
39
|
+
* @property {number} tokens
|
|
40
|
+
* @property {number} refilledAt
|
|
41
|
+
* @property {number} active Uçuştaki çağrı.
|
|
42
|
+
* @property {(() => void)[]} waiters Boş yuva bekleyenler.
|
|
43
|
+
* @property {Promise<void>} chain Admission sırası (FIFO).
|
|
44
|
+
* @property {number} blockedUntil `Retry-After` boyunca kova tamamen durur.
|
|
45
|
+
* @property {number} consecutiveFailures
|
|
46
|
+
* @property {number} bypassUntil Devre kesicinin açık kaldığı an.
|
|
47
|
+
* @property {number} adjustedAt Son AIMD kararının zamanı.
|
|
48
|
+
* @property {number} throttled Kaç kez 429/503 görüldü (teşhis için).
|
|
49
|
+
* @property {number} rejected Devre kesici kaç çağrıyı hiç göndermedi.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/** @type {Map<string, HostState>} */
|
|
53
|
+
const hosts = new Map();
|
|
54
|
+
|
|
55
|
+
/** @type {typeof DEFAULT_UPSTREAM_LIMIT} */
|
|
56
|
+
let settings = { ...DEFAULT_UPSTREAM_LIMIT };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `createApp()` config yüklendikten sonra bir kez çağırır. Ayar değiştiğinde
|
|
60
|
+
* host durumları sıfırlanır: eski `maxRate`'e göre ayarlanmış bir `rate`
|
|
61
|
+
* yeni tavanın üstünde kalabilir.
|
|
62
|
+
*
|
|
63
|
+
* @param {Record<string, unknown> | undefined} config `cache().upstream`
|
|
64
|
+
* @returns {void}
|
|
65
|
+
*/
|
|
66
|
+
export function configureUpstreamLimiter(config) {
|
|
67
|
+
settings = { ...DEFAULT_UPSTREAM_LIMIT, ...(config ?? {}) };
|
|
68
|
+
hosts.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @returns {boolean} */
|
|
72
|
+
function enabled() {
|
|
73
|
+
return settings.rate > 0 || hostOverrides().length > 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @returns {[string, Record<string, number>][]} */
|
|
77
|
+
function hostOverrides() {
|
|
78
|
+
const raw = /** @type {Record<string, any>} */ (settings.hosts ?? {});
|
|
79
|
+
return Object.entries(raw);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} url
|
|
84
|
+
* @returns {string | null} Host, ya da URL çözülemediyse `null`.
|
|
85
|
+
*/
|
|
86
|
+
function hostOf(url) {
|
|
87
|
+
try {
|
|
88
|
+
return new URL(url).host;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {string} host
|
|
96
|
+
* @returns {HostState}
|
|
97
|
+
*/
|
|
98
|
+
function stateFor(host) {
|
|
99
|
+
const existing = hosts.get(host);
|
|
100
|
+
if (existing) return existing;
|
|
101
|
+
|
|
102
|
+
const override = /** @type {Record<string, any>} */ (settings.hosts ?? {})[host] ?? {};
|
|
103
|
+
const merged = { ...settings, ...override };
|
|
104
|
+
const rate = num(merged.rate, 0);
|
|
105
|
+
|
|
106
|
+
/** @type {HostState} */
|
|
107
|
+
const created = {
|
|
108
|
+
host,
|
|
109
|
+
maxRate: rate,
|
|
110
|
+
minRate: Math.min(num(merged.minRate, DEFAULT_UPSTREAM_LIMIT.minRate), rate || 1),
|
|
111
|
+
rate: rate,
|
|
112
|
+
// Kova boyu verilmezse bir saniyelik bütçe: hız 4/s ise dört çağrılık bir
|
|
113
|
+
// patlama tolere edilir, beşincisi bekler.
|
|
114
|
+
burst: num(merged.burst, 0) || Math.max(1, Math.ceil(rate)),
|
|
115
|
+
concurrency: Math.floor(num(merged.concurrency, DEFAULT_UPSTREAM_LIMIT.concurrency)),
|
|
116
|
+
tokens: num(merged.burst, 0) || Math.max(1, Math.ceil(rate)),
|
|
117
|
+
refilledAt: Date.now(),
|
|
118
|
+
active: 0,
|
|
119
|
+
waiters: [],
|
|
120
|
+
chain: Promise.resolve(),
|
|
121
|
+
blockedUntil: 0,
|
|
122
|
+
consecutiveFailures: 0,
|
|
123
|
+
bypassUntil: 0,
|
|
124
|
+
// 0, `Date.now()` değil: ilk saniye içinde gelen bir 429 de hızı
|
|
125
|
+
// düşürmeli. Aksi hâlde ısıtma turunun ilk patlaması cezasız kalıyor.
|
|
126
|
+
adjustedAt: 0,
|
|
127
|
+
throttled: 0,
|
|
128
|
+
rejected: 0,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
hosts.set(host, created);
|
|
132
|
+
return created;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @param {unknown} value @param {number} fallback */
|
|
136
|
+
function num(value, fallback) {
|
|
137
|
+
const parsed = Number(value);
|
|
138
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @param {number} ms */
|
|
142
|
+
function sleep(ms) {
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
setTimeout(resolve, ms).unref?.();
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Geçen süreye göre kovayı doldurur ve hata görülmeyen pencereler için hızı
|
|
150
|
+
* bir adım yukarı çeker. İkisi aynı yerde: her ikisi de "zaman geçti" bilgisine
|
|
151
|
+
* dayanıyor ve zamanlayıcı kurmadan, çağrı anında hesaplanıyorlar — boşta duran
|
|
152
|
+
* bir süreç için sayaç işletmenin anlamı yok.
|
|
153
|
+
*
|
|
154
|
+
* @param {HostState} state
|
|
155
|
+
* @returns {void}
|
|
156
|
+
*/
|
|
157
|
+
function refill(state) {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
const elapsed = now - state.refilledAt;
|
|
160
|
+
|
|
161
|
+
if (elapsed > 0) {
|
|
162
|
+
state.tokens = Math.min(state.burst, state.tokens + (elapsed * state.rate) / 1000);
|
|
163
|
+
state.refilledAt = now;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (state.rate >= state.maxRate) return;
|
|
167
|
+
if (now - state.adjustedAt < settings.increaseIntervalMs) return;
|
|
168
|
+
|
|
169
|
+
// Toplamsal artış: azalma yarıya indiriyor, geri çıkış adım adım. Ters
|
|
170
|
+
// olsaydı (hızlı çık, yavaş in) her pencerede yeniden 429 yerdik.
|
|
171
|
+
state.rate = Math.min(state.maxRate, state.rate + num(settings.increaseStep, 1));
|
|
172
|
+
state.adjustedAt = now;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {HostState} state
|
|
177
|
+
* @returns {number} Kaç ms sonra tekrar denenmeli; 0 → yuva hazır.
|
|
178
|
+
*/
|
|
179
|
+
function waitFor(state) {
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
if (state.blockedUntil > now) return state.blockedUntil - now;
|
|
182
|
+
|
|
183
|
+
refill(state);
|
|
184
|
+
if (state.tokens >= 1) return 0;
|
|
185
|
+
|
|
186
|
+
// Bir tokenlik eksiğin dolması için gereken süre. `rate` düşükken bu
|
|
187
|
+
// saniyeler olabilir; beklemek doğru davranış — alternatifi 429.
|
|
188
|
+
return Math.max(10, Math.ceil(((1 - state.tokens) / state.rate) * 1000));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Boş bir eşzamanlılık yuvası bekler.
|
|
193
|
+
*
|
|
194
|
+
* @param {HostState} state
|
|
195
|
+
* @returns {Promise<void>}
|
|
196
|
+
*/
|
|
197
|
+
function waitForSlot(state) {
|
|
198
|
+
if (state.active < state.concurrency) return Promise.resolve();
|
|
199
|
+
return new Promise((resolve) => state.waiters.push(resolve));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Çağrı için izin alır. `null` dönerse fren kapalı ya da host çözülemedi;
|
|
204
|
+
* `blocked` dönerse devre kesici açık ve çağrı hiç yapılmamalı.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} url
|
|
207
|
+
* @returns {Promise<{ blocked: boolean, host: string, release: () => void } | null>}
|
|
208
|
+
*/
|
|
209
|
+
export async function limitUpstream(url) {
|
|
210
|
+
if (!enabled()) return null;
|
|
211
|
+
|
|
212
|
+
const host = hostOf(url);
|
|
213
|
+
if (!host) return null;
|
|
214
|
+
|
|
215
|
+
const state = stateFor(host);
|
|
216
|
+
if (!state.maxRate) return null;
|
|
217
|
+
|
|
218
|
+
if (state.bypassUntil > Date.now()) {
|
|
219
|
+
state.rejected += 1;
|
|
220
|
+
return { blocked: true, host, release: () => {} };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Admission FIFO: her çağrı kendinden öncekinin izin almasını bekler. Sıra
|
|
224
|
+
// olmadan, uyanan çağrılar rastgele yarışır ve ilk gelen en son geçebilir.
|
|
225
|
+
const previous = state.chain;
|
|
226
|
+
/** @type {() => void} */
|
|
227
|
+
let releaseChain = () => {};
|
|
228
|
+
state.chain = new Promise((resolve) => {
|
|
229
|
+
releaseChain = resolve;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
await previous;
|
|
234
|
+
|
|
235
|
+
for (;;) {
|
|
236
|
+
await waitForSlot(state);
|
|
237
|
+
const wait = waitFor(state);
|
|
238
|
+
if (wait === 0) break;
|
|
239
|
+
await sleep(wait);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
state.tokens -= 1;
|
|
243
|
+
state.active += 1;
|
|
244
|
+
} finally {
|
|
245
|
+
releaseChain();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let released = false;
|
|
249
|
+
return {
|
|
250
|
+
blocked: false,
|
|
251
|
+
host,
|
|
252
|
+
release: () => {
|
|
253
|
+
if (released) return;
|
|
254
|
+
released = true;
|
|
255
|
+
state.active -= 1;
|
|
256
|
+
state.waiters.shift()?.();
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Yanıtın hıza etkisini işler. 429/503 hızı yarıya indirir ve `Retry-After`
|
|
263
|
+
* varsa kovayı o süre boyunca tamamen durdurur; başarı sayaçları sıfırlar.
|
|
264
|
+
*
|
|
265
|
+
* @param {string} host
|
|
266
|
+
* @param {number} status `0` → ağ hatası (yanıt gelmedi).
|
|
267
|
+
* @param {string | null} [retryAfter] `Retry-After` başlığı.
|
|
268
|
+
* @returns {void}
|
|
269
|
+
*/
|
|
270
|
+
export function noteUpstreamResponse(host, status, retryAfter = null) {
|
|
271
|
+
const state = hosts.get(host);
|
|
272
|
+
if (!state) return;
|
|
273
|
+
|
|
274
|
+
// Yalnızca "yavaşla" anlamına gelen cevaplar hızı cezalandırır. 400/404 bir
|
|
275
|
+
// kota sorunu değil, 500 de öyle: onlar için yavaşlamak arızayı düzeltmez,
|
|
276
|
+
// sadece siteyi yavaşlatır.
|
|
277
|
+
if (status !== 429 && status !== 503) {
|
|
278
|
+
state.consecutiveFailures = 0;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
state.throttled += 1;
|
|
283
|
+
state.consecutiveFailures += 1;
|
|
284
|
+
|
|
285
|
+
const now = Date.now();
|
|
286
|
+
const cooldown = retryAfterMs(retryAfter);
|
|
287
|
+
if (cooldown) state.blockedUntil = Math.max(state.blockedUntil, now + cooldown);
|
|
288
|
+
|
|
289
|
+
// Çarpımsal azalma, ama pencere başına bir kez: aynı anda uçan on çağrı
|
|
290
|
+
// hep 429 dönerse hız on kez yarılanıp dibe vururdu.
|
|
291
|
+
if (now - state.adjustedAt >= settings.decreaseIntervalMs) {
|
|
292
|
+
state.rate = Math.max(state.minRate, state.rate / 2);
|
|
293
|
+
state.adjustedAt = now;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (
|
|
297
|
+
state.consecutiveFailures >= settings.breakerFailures &&
|
|
298
|
+
state.bypassUntil <= now
|
|
299
|
+
) {
|
|
300
|
+
state.bypassUntil = now + settings.breakerCooldownMs;
|
|
301
|
+
console.warn(
|
|
302
|
+
`[upstream] ${state.host}: ${state.consecutiveFailures} consecutive rate limits — ` +
|
|
303
|
+
`bypassing for ${settings.breakerCooldownMs}ms (rate is now ${state.rate.toFixed(1)}/s)`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* `Retry-After` iki biçimde gelir: saniye ya da HTTP tarihi.
|
|
310
|
+
*
|
|
311
|
+
* @param {string | null | undefined} value
|
|
312
|
+
* @returns {number} ms; okunamazsa 0.
|
|
313
|
+
*/
|
|
314
|
+
function retryAfterMs(value) {
|
|
315
|
+
if (!value) return 0;
|
|
316
|
+
|
|
317
|
+
const seconds = Number(value);
|
|
318
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, 300_000);
|
|
319
|
+
|
|
320
|
+
const date = Date.parse(value);
|
|
321
|
+
if (Number.isNaN(date)) return 0;
|
|
322
|
+
|
|
323
|
+
return Math.min(Math.max(0, date - Date.now()), 300_000);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Dev paneli ve teşhis için host başına durum. Fren kapalıysa boş dizi.
|
|
328
|
+
*
|
|
329
|
+
* @returns {{ host: string, rate: number, maxRate: number, concurrency: number,
|
|
330
|
+
* active: number, throttled: number, rejected: number, bypassed: boolean,
|
|
331
|
+
* blockedMs: number, bypassedMs: number }[]}
|
|
332
|
+
*/
|
|
333
|
+
export function getUpstreamLimiterStatus() {
|
|
334
|
+
const now = Date.now();
|
|
335
|
+
|
|
336
|
+
return [...hosts.values()].map((state) => ({
|
|
337
|
+
host: state.host,
|
|
338
|
+
rate: Number(state.rate.toFixed(2)),
|
|
339
|
+
maxRate: state.maxRate,
|
|
340
|
+
concurrency: state.concurrency,
|
|
341
|
+
active: state.active,
|
|
342
|
+
throttled: state.throttled,
|
|
343
|
+
rejected: state.rejected,
|
|
344
|
+
bypassed: state.bypassUntil > now,
|
|
345
|
+
blockedMs: Math.max(0, state.blockedUntil - now),
|
|
346
|
+
bypassedMs: Math.max(0, state.bypassUntil - now),
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Freni bekleten en uzun süre. Isıtma turunun tekrar denemesi bunu kullanıyor:
|
|
352
|
+
* sabit bir bekleme, kesici 10 saniye açıkken 2 saniye sonra tekrar denemek
|
|
353
|
+
* demekti — yani aynı 429'u peşin peşin almak.
|
|
354
|
+
*
|
|
355
|
+
* @returns {number} ms; fren kapalıysa ya da bekleyen bir şey yoksa 0.
|
|
356
|
+
*/
|
|
357
|
+
export function upstreamCooldownMs() {
|
|
358
|
+
const now = Date.now();
|
|
359
|
+
let longest = 0;
|
|
360
|
+
|
|
361
|
+
for (const state of hosts.values()) {
|
|
362
|
+
longest = Math.max(longest, state.bypassUntil - now, state.blockedUntil - now);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return Math.max(0, longest);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Testler için: ayarları verip tüm host durumlarını sıfırlar.
|
|
370
|
+
*
|
|
371
|
+
* @param {Record<string, unknown>} [config]
|
|
372
|
+
* @returns {void}
|
|
373
|
+
*/
|
|
374
|
+
export function resetUpstreamLimiterForTests(config = {}) {
|
|
375
|
+
configureUpstreamLimiter(config);
|
|
376
|
+
}
|
|
@@ -20,8 +20,13 @@
|
|
|
20
20
|
* }
|
|
21
21
|
*
|
|
22
22
|
* İki yol aynı hatayı bildirirse tekilleştirilir.
|
|
23
|
+
*
|
|
24
|
+
* Sarmalayıcı aynı zamanda hız freninin durduğu yerdir
|
|
25
|
+
* (`upstream-limiter.js`): kotayı harcayan şey sayfa isteği değil, buradan
|
|
26
|
+
* geçen çağrı. Fren varsayılan olarak kapalı.
|
|
23
27
|
*/
|
|
24
28
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
29
|
+
import { limitUpstream, noteUpstreamResponse } from "./upstream-limiter.js";
|
|
25
30
|
|
|
26
31
|
/**
|
|
27
32
|
* @typedef {{ status: number, path: string }} UpstreamFailure
|
|
@@ -94,16 +99,36 @@ export function trackUpstreamFetch() {
|
|
|
94
99
|
const url = requestUrl(input);
|
|
95
100
|
if (isSelfRequest(url)) return original(input, init);
|
|
96
101
|
|
|
102
|
+
// Hız freni burada, çünkü kotayı harcayan şey sayfa değil bu çağrı.
|
|
103
|
+
// Kapalıysa (varsayılan) `null` döner ve tek maliyeti bir dal.
|
|
104
|
+
const permit = await limitUpstream(url);
|
|
105
|
+
|
|
106
|
+
if (permit?.blocked) {
|
|
107
|
+
// Devre kesici açık: 429 yiyeceğini bildiğimiz bir çağrıyı yapmıyoruz.
|
|
108
|
+
// Render tarafı bunu geçici hata olarak görür, yani sayfa önbelleğe
|
|
109
|
+
// yazılmaz ve bir sonraki istek yeniden dener.
|
|
110
|
+
reportUpstreamFailure({ status: 429, path: url });
|
|
111
|
+
return new Response(null, { status: 429, statusText: "Too Many Requests" });
|
|
112
|
+
}
|
|
113
|
+
|
|
97
114
|
try {
|
|
98
115
|
const response = await original(input, init);
|
|
116
|
+
|
|
117
|
+
if (permit) {
|
|
118
|
+
noteUpstreamResponse(permit.host, response.status, response.headers.get("retry-after"));
|
|
119
|
+
}
|
|
120
|
+
|
|
99
121
|
if (!response.ok && isTransientStatus(response.status)) {
|
|
100
122
|
reportUpstreamFailure({ status: response.status, path: url });
|
|
101
123
|
}
|
|
102
124
|
return response;
|
|
103
125
|
} catch (error) {
|
|
104
126
|
// Yanıt hiç gelmedi: ağ hatası her zaman geçicidir.
|
|
127
|
+
if (permit) noteUpstreamResponse(permit.host, 0, null);
|
|
105
128
|
reportUpstreamFailure({ status: 0, path: url });
|
|
106
129
|
throw error;
|
|
130
|
+
} finally {
|
|
131
|
+
permit?.release();
|
|
107
132
|
}
|
|
108
133
|
};
|
|
109
134
|
|