• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dg / ai-access / 38295556861

12 Oct 2026 07:07PM UTC coverage: 93.899% (+0.3%) from 93.613%
38295556861

push

github

dg
added support for nette/schema: response schemas and tool parameters as Expect structures

setResponseSchema() and Tool::$parameters take a Nette\Schema\Elements\Structure
next to a JSON Schema array. The structure is exported for the wire with
JsonSchema::export() and kept: getJson() validates and casts the answer with it,
tool arguments are validated by it before the handler and the model gets the
exact messages. Strict providers (OpenAI, Grok, the generic client, a strict
tool) check the structure against their rules up front instead of failing at
the API. nette/schema is a suggestion, not a requirement.

54 of 59 new or added lines in 13 files covered. (91.53%)

92 existing lines in 27 files now uncovered.

2093 of 2229 relevant lines covered (93.9%)

0.94 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.91
/src/Http/RetryClient.php
1
<?php declare(strict_types=1);
2

3
/**
4
 * This file is part of the AI Access library (https://ai-access.deegee.dev)
5
 * Copyright (c) 2024 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace AIAccess\Http;
9

10
use AIAccess\CommunicationException;
11

12

13
/**
14
 * Retries rate limits, overloads and network failures with exponential backoff.
15
 *
16
 * Requests are replayed as they are, so a 5xx that in fact created something
17
 * server-side (a batch job, an uploaded file) can be created twice.
18
 */
19
final readonly class RetryClient implements Client
20
{
21
        /** @var \Closure(float): void */
22
        private \Closure $sleep;
23

24

25
        /**
26
         * @param  \Closure(float): void  $sleep
27
         */
28
        public function __construct(
1 ✔
29
                private Client $inner,
30
                private int $maxAttempts = 3,
31
                private float $initialDelay = 1.0,
32
                private float $maxDelay = 30.0,
33
                ?\Closure $sleep = null,
34
        ) {
35
                $this->sleep = $sleep ?? static fn(float $seconds) => usleep((int) ($seconds * 1_000_000));
1 ✔
36
        }
1 ✔
37

38

39
        public function fetch(
40
                string $url,
41
                string|array|FormData|null $payload = null,
42
                array $headers = [],
43
                ?string $method = null,
44
                ?\Closure $onChunk = null,
45
        ): Response
46
        {
47
                $attempt = 1;
1 ✔
48
                while (true) {
1 ✔
49
                        $started = false;
1 ✔
50
                        $watch = $onChunk === null ? null : function (string $chunk) use ($onChunk, &$started) {
1 ✔
51
                                $started = true;
1 ✔
52
                                return $onChunk($chunk);
1 ✔
53
                        };
1 ✔
54

55
                        try {
56
                                $response = $this->inner->fetch($url, $payload, $headers, $method, $watch);
1 ✔
57
                                // once the model has started talking, replaying the request would duplicate
58
                                // the answer and pay for it twice
59
                                if ($started || $attempt >= $this->maxAttempts || !$this->isRetriable($response->getStatusCode())) {
1 ✔
60
                                        return $response;
1 ✔
61
                                }
62
                                $delay = $this->parseRetryAfter($response->getHeader('retry-after')) ?? $this->backoff($attempt);
1 ✔
63

64
                        } catch (CommunicationException $e) {
1 ✔
65
                                if ($started || $attempt >= $this->maxAttempts) {
1 ✔
66
                                        throw $e;
1 ✔
67
                                }
68
                                $delay = $this->backoff($attempt);
1 ✔
69
                        }
70

71
                        ($this->sleep)($delay);
1 ✔
72
                        $attempt++;
1 ✔
73
                }
UNCOV
74
        }
×
75

76

77
        private function isRetriable(int $statusCode): bool
78
        {
79
                // 501 and 505 are the server saying "never", not "not now"
80
                return $statusCode === 408
1 ✔
81
                        || $statusCode === 429
1 ✔
82
                        || ($statusCode >= 500 && $statusCode !== 501 && $statusCode !== 505);
1 ✔
83
        }
84

85

86
        private function backoff(int $attempt): float
87
        {
88
                $delay = min($this->initialDelay * 2 ** ($attempt - 1), $this->maxDelay);
1 ✔
89
                return $delay * (0.5 + mt_rand() / mt_getrandmax() / 2);
1 ✔
90
        }
91

92

93
        private function parseRetryAfter(?string $value): ?float
94
        {
95
                if ($value === null) {
1 ✔
96
                        return null;
1 ✔
97
                } elseif (is_numeric($value)) {
1 ✔
98
                        // a negative value from a confused proxy must not turn into a negative sleep
99
                        return min(max((float) $value, 0), $this->maxDelay);
1 ✔
100
                }
101
                $time = strtotime($value);
×
102
                return $time === false ? null : min(max($time - time(), 0), $this->maxDelay);
×
103
        }
104
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc