Files
bwilliams c09c30ae71 Added a backfill in 7-day windows, and fetches now keep their data on a failure
Backfilling a long window (an early --since, or a long time offline)
hit the API's rate limit, which could still be exceeded after waiting
once, and then everything fetched so far was lost.

Before each Find a Tender source's worker starts, a start point more
than 7 days ago is now backfilled in 7-day windows bounded on both
sides, oldest first, each saved before the next is fetched. A window
that fails is saved as far as it got and fetched again from its start
after the source's interval. The worker then carries on from the end
of the last window.

A 429 is now waited out up to sources.<source>.rate_limit_retries
times (default 5) rather than once; fts-once --ignore-rate-limit uses
0. FetchAll returns what it fetched before an error along with it, and
the workers save it, so a failed run no longer loses its data.
findatender's FetchAll and FetchOnce take RequestOptions, which gains
Until (updatedTo), and RateLimitRetries replaces AllowRateLimit.
2026-10-21 12:40:39 +01:00

582 lines
17 KiB
Go

package findatender
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math"
"regexp"
"strconv"
"strings"
"time"
resty "resty.dev/v3"
"git.avsdev.uk/Go/job-search/internal/findatender/ocds"
"git.avsdev.uk/Go/job-search/internal/logging"
)
// apiTimeLayout is the layout of updatedFrom and updatedTo: wall-clock time
// with no offset, which the API reads as UK time.
const apiTimeLayout = "2006-01-02T15:04:05"
// Errors returned when fetching from the API.
var (
// ErrNoNext means a release package has no next page link.
ErrNoNext = errors.New("findatender: no next link")
// ErrNoReleases means the API returned no releases.
ErrNoReleases = errors.New("findatender: no releases")
// ErrRateLimitExceeded means the API refused a request with 429 Too Many
// Requests, and waiting for it was not allowed or did not help (see
// RequestOptions.RateLimitRetries).
ErrRateLimitExceeded = errors.New("findatender: rate limit has been exceeded")
// ErrFetchCancelled means the fetch's context was cancelled while waiting
// out a rate limit.
ErrFetchCancelled = errors.New("findatender: fetch cancelled")
// ErrUnexpectedStatus wraps an API response status other than 200 OK.
ErrUnexpectedStatus = errors.New("findatender: unexpected response status")
)
// RequestOptions selects the releases a fetch asks for.
type RequestOptions struct {
// Since asks for releases updated from this time. Zero means 24 hours ago.
Since time.Time
// Until asks for releases updated up to this time. Zero means no bound
// (the API's: now).
Until time.Time
// Limit is the number of releases per page. Zero means 100.
Limit int
// RateLimitRetries is how many times a request refused with 429 Too Many
// Requests is retried, each after waiting as the response's Retry-After
// asks, before failing with ErrRateLimitExceeded. 0 fails at the first.
RateLimitRetries int
}
// ---------- Utility methods for handling API irregularities ----------
// safeReleasePackage is ocds.ReleasePackage with the releases left undecoded,
// so safeUnpackReleases can repair or skip each one.
type safeReleasePackage struct {
URI string `json:"uri,omitempty"`
Version string `json:"version,omitempty"`
Extensions []string `json:"extensions,omitempty"`
PublishedDate *string `json:"publishedDate,omitempty"`
Releases []json.RawMessage `json:"releases,omitempty"`
Publisher *ocds.ReleasePackagePublisher `json:"publisher,omitempty"`
License string `json:"license,omitempty"`
PublicationPolicy string `json:"publicationPolicy,omitempty"`
Links *ocds.ReleasePackageLinks `json:"links,omitempty"`
}
// dateOnly matches a calendar date with no time of day: "2006-10-02".
var dateOnly = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
// unmarshalLenient decodes raw json into target like json.Unmarshal, after
// repairing values that it cannot decode (see repairJSON).
func unmarshalLenient(raw json.RawMessage, target any) error {
fixed, err := repairJSON(raw)
if err != nil {
return err
}
return json.Unmarshal(fixed, target)
}
// repairJSON returns a copy of raw with the values json.Unmarshal cannot
// decode repaired:
//
// - every date field holding a date-only value ("2006-10-02") is rewritten
// as midnight UTC ("2006-10-02T00:00:00Z"), and every date field holding
// an empty string is set to null. Values that already have a time, values
// in other fields, and dates that appear inside free text are left alone.
// A date field is one whose name is "date" or starts with "date",
// contains "Date", or ends with "Deadline".
// - every number too large for a float64 is replaced by math.MaxInt64 (or
// math.MinInt64 if negative). OCDS writes "no limit" as 1e9999, as JSON
// has no infinity; math.MaxInt64 still fits the int and float64 fields
// it is found in, and is marshalled again where +Inf would not be.
//
// If nothing needs repairing, raw is returned unchanged.
func repairJSON(raw json.RawMessage) (json.RawMessage, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber() // keep numbers exactly as written, not as float64
var doc any
if err := decoder.Decode(&doc); err != nil {
return nil, fmt.Errorf("repairing json: %w", err)
}
if _, err := decoder.Token(); !errors.Is(err, io.EOF) {
return nil, errors.New(
"findatender: repairing json: unexpected data after the JSON value",
)
}
repaired, changed := recursiveRepair("", doc)
if !changed {
return raw, nil
}
return json.Marshal(repaired)
}
// recursiveRepair repairs value, found in the field named key ("" for an
// array element or the document itself), and its contents (see repairJSON).
// Maps and slices are repaired in place; the repaired value is returned, and
// whether anything changed.
func recursiveRepair(key string, value any) (any, bool) {
switch typed := value.(type) {
case map[string]any:
changed := false
for fieldKey, fieldValue := range typed {
repaired, fieldChanged := recursiveRepair(fieldKey, fieldValue)
if fieldChanged {
typed[fieldKey] = repaired
changed = true
}
}
return typed, changed
case []any:
changed := false
for i, element := range typed {
repaired, elementChanged := recursiveRepair("", element)
if elementChanged {
typed[i] = repaired
changed = true
}
}
return typed, changed
case string:
return repairDate(key, typed)
case json.Number:
return repairNumber(typed)
default:
return value, false
}
}
// repairDate repairs text if key is a date field (see repairJSON).
func repairDate(key, text string) (any, bool) {
if !isDateField(key) {
return text, false
}
switch {
case dateOnly.MatchString(text):
return text + "T00:00:00Z", true
case text == "":
return nil, true
default:
return text, false
}
}
// repairNumber replaces a number too large for a float64 (see repairJSON).
func repairNumber(number json.Number) (any, bool) {
parsed, err := strconv.ParseFloat(number.String(), 64)
switch {
case !errors.Is(err, strconv.ErrRange) || !math.IsInf(parsed, 0):
return number, false
case parsed > 0:
return json.Number(strconv.FormatInt(math.MaxInt64, 10)), true
default:
return json.Number(strconv.FormatInt(math.MinInt64, 10)), true
}
}
// isDateField reports whether a field holds a date, by its name (see
// repairJSON).
func isDateField(name string) bool {
return strings.HasPrefix(name, "date") ||
strings.Contains(name, "Date") ||
strings.HasSuffix(name, "Deadline")
}
// releaseIDs reads just the ocid and the release id from a release, without
// decoding the rest of it. It reads the top-level fields in order and stops
// as soon as it has both, so an error later in the release (invalid JSON or a
// value that wouldn't fit the release struct) doesn't matter. Fields before
// them only need to be valid JSON.
func releaseIDs(raw json.RawMessage) (ocid, id string, err error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber() // a numeric id keeps its exact digits
token, err := decoder.Token()
if err != nil {
return "", "", fmt.Errorf("reading release: %w", err)
}
if token != json.Delim('{') {
return "", "", errors.New("findatender: reading release: not a JSON object")
}
var haveOCID, haveID bool
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return ocid, id, fmt.Errorf("reading release: %w", err)
}
switch key, _ := token.(string); key {
case "ocid":
if err := decoder.Decode(&ocid); err != nil {
return ocid, id, fmt.Errorf("reading release ocid: %w", err)
}
haveOCID = true
case "id":
if id, err = decodeReleaseID(decoder); err != nil {
return ocid, id, err
}
haveID = true
default:
var skip json.RawMessage // must be valid JSON, but is otherwise ignored
if err := decoder.Decode(&skip); err != nil {
return ocid, id, fmt.Errorf("reading release field %q: %w", key, err)
}
}
if haveOCID && haveID {
return ocid, id, nil
}
}
return ocid, id, missingIDsError(haveOCID, haveID)
}
// decodeReleaseID decodes a release id. OCDS ids are strings, but a number is
// tolerated and kept as written.
func decodeReleaseID(decoder *json.Decoder) (string, error) {
var decoded any
if err := decoder.Decode(&decoded); err != nil {
return "", fmt.Errorf("reading release id: %w", err)
}
switch typed := decoded.(type) {
case string:
return typed, nil
case json.Number:
return typed.String(), nil
default:
return "", fmt.Errorf("findatender: reading release id: unexpected %T", typed)
}
}
// missingIDsError reports which of the ocid and release id were not found.
func missingIDsError(haveOCID, haveID bool) error {
switch {
case !haveOCID && !haveID:
return errors.New("findatender: reading release: no ocid or id")
case !haveOCID:
return errors.New("findatender: reading release: no ocid")
default:
return errors.New("findatender: reading release: no id")
}
}
// safeUnpackReleases unpacks an API response into an OCDS ReleasePackage,
// handling API irregularities (malformed dates, 1e9999 for no limit, missing
// fields, invalid releases, etc).
//
// This method takes ownership of the response body and closes it when done.
func safeUnpackReleases(
logger *slog.Logger, response *resty.Response,
) (*ocds.ReleasePackage, error) {
var releasePackage safeReleasePackage
// Take ownership of the response body.
defer response.Body.Close() //nolint:errcheck // read only
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&releasePackage); err != nil {
return nil, err
}
out := &ocds.ReleasePackage{
URI: releasePackage.URI,
Version: releasePackage.Version,
Extensions: releasePackage.Extensions,
Publisher: releasePackage.Publisher,
License: releasePackage.License,
PublicationPolicy: releasePackage.PublicationPolicy,
Links: releasePackage.Links,
}
// Correct PublishedDate of "" and "2006-01-02"
if releasePackage.PublishedDate != nil &&
*releasePackage.PublishedDate != "" {
published, err := time.Parse(time.RFC3339, *releasePackage.PublishedDate)
if err != nil {
published, err = time.Parse(time.DateOnly, *releasePackage.PublishedDate)
}
if err == nil {
out.PublishedDate = &published
}
}
for _, rawRelease := range releasePackage.Releases {
var release ocds.Release
err := unmarshalLenient(rawRelease, &release)
if err != nil {
ocid, releaseID, _ := releaseIDs(rawRelease)
logger.Warn(
"skipping unreadable release",
"ocid", ocid,
"release_id", releaseID,
"error", err,
)
logger.Debug(
"raw unreadable release",
"ocid", ocid,
"release_id", releaseID,
"release_raw", rawRelease,
)
continue
}
out.Releases = append(out.Releases, release)
}
return out, nil
}
// ---------- API interaction methods ----------
// rateSleep sleeps the fetch worker until either the context is cancelled
// (shutdown), returning ctx.Err(), or the duration has expired. The duration
// is usually provided by the API; a zero duration returns immediately.
func rateSleep(ctx context.Context, duration time.Duration) error {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// closeBody closes res's body if it has one, avoiding a double close.
func closeBody(res *resty.Response) {
if res.Body != nil {
res.Body.Close() //nolint:errcheck,gosec // read only
}
}
// fetchURI performs a single GET of uri, using findATender's context, and
// traces it when debug logging is on (logging.IsDebugEnabled). Unlike the
// method of the same name it does not check the response status.
func fetchURI(findATender *FindATender, uri string) (*resty.Response, error) {
withTrace := logging.IsDebugEnabled()
logger := findATender.logger()
logger.Info("fetching", "uri", uri)
ctx := findATender.ctx
if ctx == nil {
ctx = context.Background()
}
req := findATender.httpClient.R()
req.SetContext(ctx)
res, err := req.SetTrace(withTrace).Get(uri)
if withTrace {
logTrace(logger, res)
}
return res, err
}
// logTrace logs res's trace, status and body at debug level. On a terminal
// the trace and body are printed as they are, as a field would escape them
// into one line; elsewhere they are fields, the trace as JSON for the JSON
// (file) logger.
func logTrace(logger *slog.Logger, res *resty.Response) {
if logging.WritesToTerminal(logger.Handler()) {
logger.Debug("fetched", "status", res.Status())
logging.PrintToTerminal(res.Request.TraceInfo().String())
logging.PrintToTerminal(res.String())
return
}
trace := res.Request.TraceInfo().String()
if logging.IsJSONHandler(logger.Handler()) {
trace = res.Request.TraceInfo().JSON()
}
if len(res.String()) <= 1024 {
logger.Debug(
"fetched",
"trace", trace,
"status", res.Status(),
"response", res.String(),
)
} else {
logger.Debug(
"fetched",
"trace", trace,
"status", res.Status(),
"response_length", len(res.String()),
)
}
}
// fetchURI performs a fetch against the API. A response of 429 Too Many
// Requests is waited out for as long as its Retry-After asks and the request
// retried, up to rateLimitRetries times, like a good client; one more fails
// with ErrRateLimitExceeded.
func (ft *FindATender) fetchURI(
uri string, rateLimitRetries int,
) (*resty.Response, error) {
ctx := ft.ctx
if ctx == nil {
ctx = context.Background()
}
res, err := fetchURI(ft, uri)
for retry := 1; err == nil && res.StatusCode() == 429; retry++ {
closeBody(res)
if retry > rateLimitRetries {
return nil, ErrRateLimitExceeded
}
retryAfterText := res.Header().Get("Retry-After")
retryAfterSeconds, parseErr := strconv.ParseInt(retryAfterText, 10, 32)
if parseErr != nil {
return nil, fmt.Errorf("unknown retry-after value: %w", parseErr)
}
retryAfter := time.Duration(retryAfterSeconds) * time.Second
ft.logger().Warn(
"api rate limit hit, waiting as asked",
"retry_after", retryAfter,
"retry", retry,
"retries", rateLimitRetries,
)
if rateSleep(ctx, retryAfter) != nil {
return nil, ErrFetchCancelled
}
res, err = fetchURI(ft, uri)
}
if err != nil {
return nil, err
}
if res.StatusCode() != 200 {
closeBody(res)
return nil, fmt.Errorf("%w: %s", ErrUnexpectedStatus, res.Status())
}
return res, nil
}
// fetchWithOpts performs a fetch against the API with some sanitised default
// options (see RequestOptions).
func (ft *FindATender) fetchWithOpts(
opts RequestOptions,
) (*resty.Response, error) {
if opts.Since.IsZero() {
opts.Since = time.Now().AddDate(0, 0, -1)
}
if opts.Limit == 0 {
opts.Limit = 100
}
uri := fmt.Sprintf(
"%s/ocdsReleasePackages?updatedFrom=%s&limit=%d",
ft.apiRoot,
opts.Since.Format(apiTimeLayout),
opts.Limit,
)
if !opts.Until.IsZero() {
uri += "&updatedTo=" + opts.Until.Format(apiTimeLayout)
}
return ft.fetchURI(uri, opts.RateLimitRetries)
}
// fetchNext fetches the page pkg's "Next" link points to. It returns
// ErrNoNext if there is no "Next" link; the caller can decide whether asking
// for it was an error.
func (ft *FindATender) fetchNext(
releasePackage *ocds.ReleasePackage, rateLimitRetries int,
) (*resty.Response, error) {
if releasePackage.Links == nil || releasePackage.Links.Next == "" {
return nil, ErrNoNext
}
return ft.fetchURI(releasePackage.Links.Next, rateLimitRetries)
}
// fetchOnce makes one call on the API. It does not follow any "Next" links.
func (ft *FindATender) fetchOnce(opts RequestOptions) ([]ocds.Release, error) {
res, err := ft.fetchWithOpts(opts)
if err != nil {
return nil, err
}
releasePackage, err := safeUnpackReleases(ft.logger(), res)
if err != nil {
return nil, err
}
if len(releasePackage.Releases) == 0 {
return nil, ErrNoReleases
}
return releasePackage.Releases, nil
}
// fetchAll makes a call on the API. If there are any "Next" links, it calls
// them in succession until the end has been reached. If a page fails, the
// releases of the pages before it are returned with the error, so the caller
// can keep them.
func (ft *FindATender) fetchAll(opts RequestOptions) ([]ocds.Release, error) {
var releases []ocds.Release
res, err := ft.fetchWithOpts(opts)
if err != nil {
return nil, err
}
releasePackage, err := safeUnpackReleases(ft.logger(), res)
if err != nil {
return nil, err
}
releases = append(releases, releasePackage.Releases...)
// Next links already followed, so a page that links back to an earlier
// one cannot loop forever.
seen := make(map[string]bool)
for {
if releasePackage.Links != nil && releasePackage.Links.Next != "" {
if seen[releasePackage.Links.Next] {
ft.logger().Warn(
"stopping at repeated next link",
"link", releasePackage.Links.Next,
)
break
}
seen[releasePackage.Links.Next] = true
}
res, err := ft.fetchNext(releasePackage, opts.RateLimitRetries)
if errors.Is(err, ErrNoNext) {
break
}
if err != nil {
return releases, err
}
releasePackage, err = safeUnpackReleases(ft.logger(), res)
if err != nil {
return releases, err
}
releases = append(releases, releasePackage.Releases...)
}
if len(releases) == 0 {
return nil, ErrNoReleases
}
return releases, nil
}