File size: 3,391 Bytes
076156e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
UI-agnostic retry / failure-state logic.

`stream_with_retries` turns a stream (or its failure) into a sequence of
Events. A UI only has to render events; it never catches exceptions.
"""

import math
import random
import time
from dataclasses import dataclass
from typing import Callable, Iterator, Optional

from providers import (ProviderError, RateLimitError, RefusalError,
                       RequestTimeoutError, StallError, with_watchdog)


@dataclass
class Event:
    kind: str            # start | chunk | retry_wait | done | stalled | refused | error
    text: str = ""       # accumulated response text so far
    message: str = ""
    seconds: int = 0     # for retry_wait: seconds remaining
    attempt: int = 0
    status_code: Optional[int] = None


def backoff_seconds(attempt: int, retry_after: Optional[float] = None,
                    base: float = 2.0, cap: float = 30.0) -> int:
    """Honor the server's Retry-After if present; otherwise exponential
    backoff with jitter (so many clients don't retry in lockstep)."""
    if retry_after:
        return math.ceil(retry_after)
    return math.ceil(min(cap, base * 2 ** (attempt - 1)) * random.uniform(0.75, 1.25))


def stream_with_retries(make_stream: Callable[[], Iterator[str]], *,
                        max_retries: int = 3,
                        stall_seconds: Optional[float] = None,
                        sleep: Callable[[float], None] = time.sleep) -> Iterator[Event]:
    attempt = 0
    while True:
        attempt += 1
        text = ""
        yield Event("start", attempt=attempt)
        try:
            stream = make_stream()
            if stall_seconds:
                stream = with_watchdog(stream, stall_seconds)
            for chunk in stream:
                text += chunk
                yield Event("chunk", text=text, attempt=attempt)
            yield Event("done", text=text, attempt=attempt)
            return

        except RateLimitError as e:
            if attempt > max_retries:
                yield Event("error", text=text, attempt=attempt, status_code=429,
                            message=f"Still rate limited after {attempt} attempts. Giving up.")
                return
            wait = backoff_seconds(attempt, e.retry_after)
            source = "Retry-After header" if e.retry_after else "exponential backoff + jitter"
            yield Event("retry_wait", text=text, attempt=attempt, seconds=wait, status_code=429,
                        message=f"429 on attempt {attempt}. Waiting {wait}s ({source}).")
            for left in range(wait, 0, -1):
                yield Event("retry_wait", text=text, attempt=attempt, seconds=left,
                            status_code=429, message="")
                sleep(1)

        except StallError as e:
            yield Event("stalled", text=text, attempt=attempt, message=str(e))
            return

        except RefusalError as e:
            yield Event("refused", text=text, attempt=attempt, message=str(e))
            return

        except RequestTimeoutError as e:
            yield Event("error", text=text, attempt=attempt,
                        message=f"No response from the API: {e}")
            return

        except ProviderError as e:
            yield Event("error", text=text, attempt=attempt,
                        status_code=e.status_code, message=str(e))
            return