File size: 3,472 Bytes
65d68f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
"use client";

import { useEffect, useEffectEvent, useRef, useState } from "react";
import { getPublicPollIntervalMs } from "@/lib/env";

type UseJobPollingOptions<T> = {
  enabled: boolean;
  fetcher: () => Promise<T>;
  shouldContinue: (value: T) => boolean;
  onData: (value: T) => void;
  onError?: (error: unknown) => void;
  onTimeout?: () => void;
  intervalMs?: number;
  timeoutMs?: number;
};

export function useJobPolling<T>({
  enabled,
  fetcher,
  shouldContinue,
  onData,
  onError,
  onTimeout,
  intervalMs = getPublicPollIntervalMs(),
  timeoutMs = 10 * 60 * 1000,
}: UseJobPollingOptions<T>) {
  const [isPolling, setIsPolling] = useState(false);
  const startedAtRef = useRef<number | null>(null);
  const requestActiveRef = useRef(false);
  const stoppedRef = useRef(false);
  const timedOutRef = useRef(false);
  const consecutiveErrorsRef = useRef(0);
  const generationRef = useRef(0);

  // A render runs for minutes; a single failed poll (HF cold-start, a network
  // blip, a brief 5xx) must NOT abort the whole job in the UI while the backend
  // keeps rendering fine. Tolerate transient errors and only give up after
  // several in a row. A successful poll resets the counter.
  const MAX_CONSECUTIVE_ERRORS = 6;

  const pollOnce = useEffectEvent(async () => {
    if (requestActiveRef.current) return;
    const generation = generationRef.current;
    requestActiveRef.current = true;
    try {
      const data = await fetcher();
      if (generation !== generationRef.current || stoppedRef.current) return;
      consecutiveErrorsRef.current = 0;
      onData(data);
      if (!shouldContinue(data)) {
        stoppedRef.current = true;
        setIsPolling(false);
      }
    } catch (error) {
      if (generation !== generationRef.current || stoppedRef.current) return;
      consecutiveErrorsRef.current += 1;
      if (consecutiveErrorsRef.current >= MAX_CONSECUTIVE_ERRORS) {
        stoppedRef.current = true;
        setIsPolling(false);
        onError?.(error);
      }
      // otherwise: swallow and let the next interval retry
    } finally {
      requestActiveRef.current = false;
    }
  });

  const markTimedOutIfNeeded = useEffectEvent(() => {
    const startedAt = startedAtRef.current ?? Date.now();
    if (Date.now() - startedAt <= timeoutMs) return false;
    stoppedRef.current = true;
    setIsPolling(false);
    if (!timedOutRef.current) {
      timedOutRef.current = true;
      onTimeout?.();
    }
    return true;
  });

  useEffect(() => {
    if (!enabled) {
      stoppedRef.current = true;
      const timeoutId = window.setTimeout(() => setIsPolling(false), 0);
      return () => window.clearTimeout(timeoutId);
    }

    stoppedRef.current = false;
    timedOutRef.current = false;
    consecutiveErrorsRef.current = 0;
    startedAtRef.current = Date.now();
    const startTimeoutId = window.setTimeout(() => setIsPolling(true), 0);
    const initialPollTimeoutId = window.setTimeout(() => void pollOnce(), 0);

    const intervalId = window.setInterval(() => {
      if (stoppedRef.current) return;
      if (markTimedOutIfNeeded()) return;
      setIsPolling(true);
      void pollOnce();
    }, intervalMs);

    return () => {
      generationRef.current += 1;
      stoppedRef.current = true;
      window.clearTimeout(startTimeoutId);
      window.clearTimeout(initialPollTimeoutId);
      window.clearInterval(intervalId);
    };
  }, [enabled, intervalMs]);

  return { isPolling };
}