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.
@@ -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,
@@ -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
@@ -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, failedPaths: string[] }>}
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 failedPaths = [];
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
- failedPaths.push(target);
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
- failedPaths.push(target);
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, failedPaths };
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 failedPaths;
401
- ({ ok, failed, failedPaths } = await crawl(
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
- if (failedPaths.length) {
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
- // Rate limit pencereleri saniye mertebesinde; hemen tekrar denemek aynı
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
- failedPaths,
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