Skip to content

Commit e08e4ed

Browse files
committed
feat: add polling, search, cond sub-packages; harden retry Retry-After parser
1 parent 7ca45a5 commit e08e4ed

31 files changed

Lines changed: 3562 additions & 54 deletions

CHANGELOG.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,116 @@ All notable changes to **go-github-kit** are documented in this file.
55
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.5.0] - 2026-05-02
9+
10+
Adds three consumer-utility sub-packages that ride on top of the
11+
assembled `*http.Client` and compose with the existing transport
12+
stack: `polling` (range-over-func iterator over an HTTP endpoint on
13+
an interval), `search` (envelope iterator for `/search/*` endpoints
14+
that `pages.As[T]` cannot serve), and `cond` (visible 304 / change-
15+
vs-unchanged signal building on the etag layer). Hardens `retry`'s
16+
`Retry-After` parser as part of the same release.
17+
18+
### Fixed
19+
20+
- `retry`: `Retry-After` parser saturates extremely large delta-seconds
21+
values (above `math.MaxInt64 / int64(time.Second)`) instead of wrapping
22+
int64 nanoseconds. Clamped values route through the existing
23+
`ErrRetryAfterExceedsMax` abort path.
24+
- `retry`: whitespace around `Retry-After` values (`" 5"`, `"5 "`) is now
25+
trimmed before parsing.
26+
27+
### Added
28+
29+
- `polling.Poll` and `polling.As[T]`: Go 1.23 range-over-func iterator
30+
that re-issues a request on a caller-tunable interval, reusing the
31+
supplied `*http.Client` so retry, etag, ratelimit, throttle, and
32+
oauth2 apply per attempt. Options: `WithDone` (header/status
33+
predicate), `WithDoneT[T]` (typed predicate on the decoded value),
34+
`WithDecode[T]` (custom decoder), `WithChangeOnly` (skip yields on
35+
cache hit), `WithMaxAttempts`, `WithMaxWallClock`, `WithJitter`
36+
(deterministic mid-point), `WithHonorRetryAfter`, `WithLogger`,
37+
plus `WithSleepFunc` / `WithNowFunc` test seams. Sentinels
38+
`ErrMaxAttemptsExceeded`, `ErrMaxWallClockExceeded` (wraps
39+
`context.DeadlineExceeded`), `ErrInvalidInterval`,
40+
`ErrInvalidOption`, `ErrPredicatePanic`. Boundary yields surface
41+
`(lastResp, sentinel)` so the caller can inspect what triggered
42+
the limit.
43+
- `search.Issues`, `search.Code`, `search.Repos`, `search.Users`:
44+
typed iterators over GitHub's `/search/*` envelope endpoints.
45+
Yields `Result[T]{Item, TotalCount, IncompleteResults}` per item;
46+
surfaces the post-page-10 1000-result hard cap as
47+
`ErrResultCapHit`. Reuses `pages.Pages` for Link-header walking.
48+
Functional options (`WithBaseURL`, `WithPerPage`, `WithSort`,
49+
`WithOrder`, `WithHeaders`) match the convention used by the rest
50+
of ghkit; `WithBaseURL` overrides the GitHub default for GHES or
51+
test fixtures.
52+
- `cond.Status`, `cond.StatusOf`, `cond.Fetch[T]`: surfaces the
53+
change-vs-unchanged signal the etag layer already computes.
54+
`cond.HeaderCacheStatus` is the canonical `"X-Ghkit-Cache"` header
55+
the etag layer sets on synth-200 (`"hit"`) and wire-200 store
56+
(`"miss"`). `StatusOf` is nil-safe and maps absent to `Updated`.
57+
Sentinel errors `cond.ErrNilClient`, `cond.ErrNilContext`,
58+
`cond.ErrNilRequest` for invalid `Fetch` arguments. Decode errors
59+
wrap as `cond: decode: %w` for parity with pages, polling, search.
60+
- `etag/transport.go`: sets `cond.HeaderCacheStatus` on synth-200
61+
(after header merge) and wire-200 (after `cache.Add` returns) so
62+
the cache does not ingest the key. Drift detector and
63+
`ComputeExpectedETag` are unaffected (the header is response-only
64+
and not in the hash domain).
65+
- `retry.RetryAfter(resp) (time.Duration, bool)`: exported wrapper
66+
around `parseRetryAfter`. Returns `(0, false)` for absent,
67+
unparseable, AND negative-numeric values. Used by `polling` to
68+
honor server hints without duplicating the parser.
69+
- `ghtest.ETagServer(t, body) (*httptest.Server, *int64)`: lifted
70+
from `etag/transport_test.go`. Used by polling, cond, and any
71+
future test that exercises etag composition.
72+
- `retry`: `retry_retry_after_unparseable` slog event at Warn when
73+
the header is present but matches neither delta-seconds nor
74+
HTTP-date. Carries a length-bounded, ASCII-sanitised `raw`
75+
attribute (32 bytes max).
76+
- `retry`: `retry_sleep` event's `source` attribute gains a
77+
`"malformed"` value, distinct from `"jitter"` and `"retry_after"`.
78+
- `retry`: `FuzzParseRetryAfter` fuzz test pinning the parser as
79+
total.
80+
81+
### Documentation
82+
83+
- New `polling/doc.go`, `search/doc.go`, `cond/doc.go` documenting
84+
contracts and sharp edges (retry compounding, throttle backpressure,
85+
etag synth-200 sameness, gofri ctx-cancel observed on next
86+
round-trip, header set ordering for the cond signal).
87+
- `retry/doc.go` documents the unparseable arm and the
88+
`source="malformed"` label.
89+
- `pages/doc.go`: corrected the stale chain-order note (predates the
90+
v1.4.0 ratelimit/throttle inversion).
91+
- `README.md`: Go Report Card badge added; coverage badge skipped
92+
(peer cohort split: 3 of 4 transport-utility libraries do not
93+
carry one).
94+
95+
### Examples
96+
97+
- New `examples/poll-workflow-run/`: waits for a workflow run to
98+
reach `status="completed"` via `polling.As[*github.WorkflowRun]`
99+
with `WithDoneT`, `WithMaxWallClock`, `WithJitter`. Pairs polling
100+
with `WithETagCache` so unchanged ticks short-circuit at the etag
101+
layer.
102+
- New `examples/search-issues/`: walks `/search/issues` with
103+
`search.Issues[*github.Issue]`, surfaces `incomplete_results` and
104+
the 1000-result cap.
105+
- New `examples/conditional-fetch/`: visible 304 via
106+
`cond.Fetch[*github.Repository]`; the second call returns
107+
`cond.Unchanged` so downstream work can be skipped.
108+
109+
### Tooling
110+
111+
- `polling`, `search`, and `cond` introduce no new runtime
112+
dependencies in the root module. The `ghtest.ETagServer` lift adds
113+
no module surface; `cond` is stdlib-only; `polling` imports `cond`
114+
and `retry`; `search` imports `pages`. The import graph remains
115+
acyclic (`etag -> cond`, `polling -> cond, retry`, `search ->
116+
pages`).
117+
8118
## [1.4.0] - 2026-05-02
9119

10120
Adds a `pages` sub-package: a Go 1.23 range-over-func iterator for
@@ -470,6 +580,8 @@ and rotating PATs alike.
470580
- `golang.org/x/oauth2` v0.36.0
471581
- `golang.org/x/time` v0.15.0
472582

583+
[1.5.0]: https://github.com/pcanilho/go-github-kit/releases/tag/v1.5.0
584+
[1.4.0]: https://github.com/pcanilho/go-github-kit/releases/tag/v1.4.0
473585
[1.3.1]: https://github.com/pcanilho/go-github-kit/releases/tag/v1.3.1
474586
[1.3.0]: https://github.com/pcanilho/go-github-kit/releases/tag/v1.3.0
475587
[1.2.1]: https://github.com/pcanilho/go-github-kit/releases/tag/v1.2.1

MIGRATION.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,55 @@ After swapping, before merging:
211211
- `go test ./...` and `go vet ./...` in your repo: tests that mocked the in-tree etag transport will need to mock against ghkit's `etag.NewTransport` or against a higher-level `*http.Client`.
212212
- Confirm `errors.As(err, &primaryRateLimit)` still unwraps the `*github_primary_ratelimit.RateLimitReachedError` your controller relies on (it does, ghkit re-uses gofri unchanged).
213213
- If you keep your own metric emitter, wrap it as a RoundTripper above the `*http.Client` returned by `ghkit.HTTPClient(...)` rather than relying on ghkit's slog events for per-route labels.
214+
215+
## Recipe 4 (1.5): consumer utilities round (`polling`, `search`, `cond`)
216+
217+
Three consumer-facing iterators ship in v1.5.0. Each composes with the
218+
existing transport stack via the shared `*http.Client` and adds no new
219+
runtime dependencies in the root module.
220+
221+
### `polling`: long-running operations
222+
223+
Replace ad-hoc `time.Ticker` loops with a range-over-func iterator:
224+
225+
```go
226+
seq := polling.As[*github.WorkflowRun](ctx, hc, http.MethodGet, runURL, headers, nil,
227+
15*time.Second,
228+
polling.WithDoneT(func(r *github.WorkflowRun) bool { return r.GetStatus() == "completed" }),
229+
polling.WithMaxWallClock(30*time.Minute),
230+
)
231+
for run, err := range seq { /* ... */ }
232+
```
233+
234+
Each `c.Do` benefits from retry, etag, ratelimit, throttle, oauth2.
235+
Recommend `retry.WithMaxAttempts(1)` on the client when polling owns
236+
the outer loop, otherwise retry's per-attempt jitter compounds with
237+
the polling interval.
238+
239+
### `search`: envelope-shaped pagination
240+
241+
`pages.As[T]` cannot serve `/search/*` (envelope, not array). Use
242+
`search.Issues[T]` etc. and surface `IncompleteResults` /
243+
`ErrResultCapHit` per page:
244+
245+
```go
246+
for r, err := range search.Issues[*github.Issue](ctx, hc, q, search.WithPerPage(100)) {
247+
if errors.Is(err, search.ErrResultCapHit) { /* refine query */ break }
248+
/* ... */
249+
}
250+
```
251+
252+
### `cond`: visible 304
253+
254+
The etag layer used to erase the change-vs-unchanged signal before the
255+
caller saw it. v1.5.0 surfaces it via `cond.HeaderCacheStatus`
256+
(`"X-Ghkit-Cache"`) on the response, mapped to `cond.Status` by
257+
`cond.StatusOf(resp)`. Use `cond.Fetch[T]` for one-shot conditional
258+
GETs, or `polling.WithChangeOnly` to silently skip polling iterations
259+
on cache hits.
260+
261+
No API breaks. The new `X-Ghkit-Cache` response header is additive;
262+
existing consumers ignore it. The exported `retry.RetryAfter` is a
263+
new symbol consumed internally by `polling`; consumers can use it for
264+
their own Retry-After parsing without depending on the unexported
265+
`parseOutcome` enum.

README.md

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ A small Go toolkit that wraps [`github.com/google/go-github`](https://github.com
44

55
[![CI](https://github.com/pcanilho/go-github-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/pcanilho/go-github-kit/actions/workflows/ci.yml)
66
[![Go Reference](https://pkg.go.dev/badge/github.com/pcanilho/go-github-kit.svg)](https://pkg.go.dev/github.com/pcanilho/go-github-kit)
7+
[![Go Report Card](https://goreportcard.com/badge/github.com/pcanilho/go-github-kit)](https://goreportcard.com/report/github.com/pcanilho/go-github-kit)
78
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
89

910
## Why?
@@ -166,6 +167,135 @@ fmt.Printf("total: %d\n", n)
166167
For tests, `ghtest.LinkHeader(baseURL, page, perPage, lastPage)` builds RFC 8288 Link header values. See [`examples/list-all-repos/`](examples/list-all-repos/main.go) for a runnable end-to-end demo.
167168
</details>
168169

170+
<details>
171+
<summary><b>Polling a long-running operation (workflow run, check run, deployment)</b></summary>
172+
173+
The `polling` sub-package iterates an HTTP endpoint on a caller-tunable interval, reusing the configured `*http.Client` so retry, etag, ratelimit, throttle, and oauth2 apply per attempt. `polling.As[T]` decodes each response into `T`; `WithDoneT` stops on the decoded value; `WithMaxWallClock` caps total time and wraps `context.DeadlineExceeded`.
174+
175+
```go
176+
import (
177+
"context"
178+
"errors"
179+
"log"
180+
"net/http"
181+
"os"
182+
"time"
183+
184+
"github.com/google/go-github/v85/github"
185+
ghkit "github.com/pcanilho/go-github-kit"
186+
"github.com/pcanilho/go-github-kit/polling"
187+
"github.com/pcanilho/go-github-kit/retry"
188+
)
189+
190+
hc, err := ghkit.HTTPClient(
191+
ghkit.WithToken(os.Getenv("GITHUB_TOKEN")),
192+
ghkit.WithRetry(retry.WithMaxAttempts(1)), // polling owns the loop
193+
ghkit.WithETagCache(),
194+
)
195+
if err != nil { panic(err) }
196+
197+
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Minute)
198+
defer cancel()
199+
200+
url := "https://api.github.com/repos/owner/repo/actions/runs/12345"
201+
seq := polling.As[*github.WorkflowRun](
202+
ctx, hc, http.MethodGet, url,
203+
http.Header{"Accept": []string{"application/vnd.github+json"}},
204+
nil, 15*time.Second,
205+
polling.WithDoneT(func(r *github.WorkflowRun) bool { return r.GetStatus() == "completed" }),
206+
polling.WithMaxWallClock(30*time.Minute),
207+
polling.WithJitter(0.2),
208+
)
209+
210+
var run *github.WorkflowRun
211+
for r, err := range seq {
212+
if err != nil {
213+
if errors.Is(err, polling.ErrMaxWallClockExceeded) { log.Fatal("budget exceeded") }
214+
log.Fatal(err)
215+
}
216+
run = r
217+
}
218+
log.Printf("conclusion=%s", run.GetConclusion())
219+
```
220+
221+
Sharp edges: each `c.Do` may itself loop through `retry.Transport` (pass `retry.WithMaxAttempts(1)` when polling owns the outer loop); `throttle.WithRequestsPerSecond` below `1/interval` dominates cadence; with `WithETagCache` an unchanged resource yields identical decoded bytes per tick (pair with `polling.WithChangeOnly` to skip those silently). Pages-shape body ownership: `Poll` yields `*http.Response` and the caller closes; `As[T]` owns and closes via defer.
222+
223+
See [`examples/poll-workflow-run/`](examples/poll-workflow-run/main.go) for a runnable demo.
224+
</details>
225+
226+
<details>
227+
<summary><b>Searching with envelope pagination (1000-cap aware)</b></summary>
228+
229+
GitHub's `/search/*` endpoints return an envelope (`{total_count, incomplete_results, items[]}`), so `pages.As[T]` cannot serve them directly. The `search` sub-package wraps the four common endpoints (`Issues`, `Code`, `Repos`, `Users`) and surfaces both `IncompleteResults` (timed-out queries) and the post-page-10 1000-result hard cap as `ErrResultCapHit`.
230+
231+
```go
232+
import (
233+
"context"
234+
"errors"
235+
"fmt"
236+
237+
"github.com/google/go-github/v85/github"
238+
"github.com/pcanilho/go-github-kit/search"
239+
)
240+
241+
for r, err := range search.Issues[*github.Issue](
242+
context.Background(), hc, "is:open is:pr author:torvalds",
243+
search.WithPerPage(100),
244+
search.WithSort("updated"),
245+
search.WithOrder("desc"),
246+
) {
247+
if err != nil {
248+
if errors.Is(err, search.ErrResultCapHit) {
249+
// Refine the query with a `created:<...` date filter.
250+
break
251+
}
252+
panic(err)
253+
}
254+
if r.IncompleteResults {
255+
// GitHub timed out server-side on this page; results may be partial.
256+
}
257+
fmt.Printf("[%d] %s\n", r.TotalCount, r.Item.GetHTMLURL())
258+
}
259+
```
260+
261+
Implementation reuses `pages.Pages` for Link-header walking, so per-page retry/etag/ratelimit/throttle composition is automatic. Search has its own `X-RateLimit-Resource` budget; gofri routes requests transparently. See [`examples/search-issues/`](examples/search-issues/main.go).
262+
</details>
263+
264+
<details>
265+
<summary><b>Detecting unchanged resources (visible 304)</b></summary>
266+
267+
The `etag` layer transparently converts 304 responses into synthesised 200s with the cached body. The `cond` sub-package surfaces the change-vs-unchanged signal that is otherwise erased, letting consumers skip JSON parse, structural diff, DB writes, and webhook fan-out when nothing changed.
268+
269+
```go
270+
import (
271+
"context"
272+
"encoding/json"
273+
"io"
274+
"net/http"
275+
276+
"github.com/google/go-github/v85/github"
277+
"github.com/pcanilho/go-github-kit/cond"
278+
)
279+
280+
req, _ := http.NewRequest(http.MethodGet, "https://api.github.com/repos/google/go-github", nil)
281+
req.Header.Set("Accept", "application/vnd.github+json")
282+
283+
repo, status, err := cond.Fetch(context.Background(), hc, req,
284+
func(r io.Reader) (*github.Repository, error) {
285+
var v github.Repository
286+
return &v, json.NewDecoder(r).Decode(&v)
287+
},
288+
)
289+
if err != nil { panic(err) }
290+
switch status {
291+
case cond.Updated: // wire 200 (or no etag layer in chain)
292+
case cond.Unchanged: // synth 200 from cache hit; resource hasn't changed
293+
}
294+
```
295+
296+
Mechanics: the etag layer sets `cond.HeaderCacheStatus` (`"X-Ghkit-Cache"`) on synth-200 (`"hit"`) and wire-200-store (`"miss"`) paths. `cond.StatusOf(resp)` reads the header. Absent header → `Updated` (no etag layer in chain → caller behaves as if every response is fresh). Pair with `polling.WithChangeOnly` to silently skip yields when polling an unchanged resource. See [`examples/conditional-fetch/`](examples/conditional-fetch/main.go).
297+
</details>
298+
169299
<details>
170300
<summary><b>GitHub App installation tokens (JIT auth, shared cache)</b></summary>
171301

0 commit comments

Comments
 (0)