Skip to content

v4.0.0-beta.2

Choose a tag to compare

@flc1125 flc1125 released this 13 Jul 16:03
· 248 commits to 4.x since this release
v4.0.0-beta.2
7a785bb

Fries v4.0.0-beta.2

v4.0.0-beta.2 is the second beta release of Fries v4. It adds reusable
Retry and Poll components, introduces a lifecycle provider for the standard
library log/slog logger, and completes a set of intentional v4 API redesigns
across Filesystem, Parallel, Hashing, Codec, Hyperf Jet Retry, Support, and
Queue.

This release focuses on clearer component boundaries, context-aware execution,
and APIs that are easier to compose in production applications.

Important

This is a prerelease and contains seven intentionally breaking refactors.
Applications upgrading from v4.0.0-beta.1 should review every migration
section below before updating dependencies.

Upgrade

Fries is a multi-module repository. Upgrade the root module and every component
module used by the application to the same prerelease version.

go get github.com/go-fries/fries/v4@v4.0.0-beta.2
go get github.com/go-fries/fries/retry/v4@v4.0.0-beta.2
go get github.com/go-fries/fries/poll/v4@v4.0.0-beta.2
go mod tidy
go test ./...

Replace the example component modules with those imported by the application.
Applications using modules that depend on each other, such as Queue and Retry,
should keep those modules on the same Fries release.

Highlights

  • Add a general-purpose, context-aware Retry component.
  • Add a context-aware Poll component for eventually consistent state and
    asynchronous work.
  • Add a Foundation lifecycle provider for the process-wide slog.Default()
    logger.
  • Replace the Coroutines component with context-aware Parallel batch,
    collection, and worker-pool APIs.
  • Redesign Filesystem around a portable streaming driver contract and optional
    capabilities.
  • Replace Hashing's global registry with reusable hash constructors and typed
    digest values.
  • Replace mutable shared Codec instances with zero-value concrete codec types.
  • Reuse the shared Retry component in Hyperf Jet Retry middleware.
  • Separate Queue retry decisions from shared Backoff calculation while
    preserving durable delivery and settlement semantics.

New features

Context-aware Retry component

#2614 adds
github.com/go-fries/fries/retry/v4 for bounded retries of transiently failing
in-process operations.

Install it with:

go get github.com/go-fries/fries/retry/v4@v4.0.0-beta.2

Retry an operation

err := retry.Do(ctx, func(ctx context.Context) error {
	return repository.Refresh(ctx)
})

The defaults are:

  • three total attempts, including the initial execution;
  • exponential backoff starting at 100ms and capped at 1s;
  • retry all errors except context.Canceled and
    context.DeadlineExceeded; and
  • no notification callback.

Configure attempts, backoff, and error filtering explicitly when business
behavior should not depend on the defaults:

err := retry.Do(ctx, func(ctx context.Context) error {
	return client.Send(ctx, request)
},
	retry.WithMaxAttempts(5),
	retry.WithBackoff(retry.Jitter(
		retry.Exponential(200*time.Millisecond, 5*time.Second),
		100*time.Millisecond,
	)),
	retry.WithRetryIf(func(err error) bool {
		return errors.Is(err, ErrUnavailable)
	}),
)

Available Backoff strategies are NoBackoff, Fixed, Linear,
Exponential, and Jitter. The failed attempt number starts at one.

Return a typed value

Use DoValue when an operation returns a value:

profile, err := retry.DoValue(ctx,
	func(ctx context.Context) (Profile, error) {
		return service.LoadProfile(ctx, userID)
	},
)

If the final execution fails, DoValue returns both the value and error
produced by that execution.

Stop or delay a retry

Use Permanent when another execution cannot resolve an error:

if errors.Is(err, ErrInvalidRequest) {
	return retry.Permanent(err)
}

Use After when a remote service provides a retry delay:

return retry.After(retryAfter, ErrRateLimited)

Both helpers preserve normal errors.Is and errors.As behavior for the
underlying error.

Observe scheduled retries

err := retry.Do(ctx, operation,
	retry.WithNotify(func(ctx context.Context, event retry.Event) {
		logger.WarnContext(ctx, "operation will retry",
			"attempt", event.Attempt,
			"max_attempts", event.MaxAttempts,
			"delay", event.Delay,
			"error", event.Err,
		)
	}),
)

Notifications run synchronously after a retry has been approved and before its
Backoff wait begins.

The caller's Context owns the complete retry lifetime. The operation must pass
that Context to blocking I/O so an in-flight attempt can stop promptly.

Note

retry.Do is for in-process operation retries. Durable Queue redelivery,
acknowledgement, and dead-letter behavior remain owned by Queue.

Context-aware Poll component

#2622 adds
github.com/go-fries/fries/poll/v4 for repeatedly observing state until a
condition is satisfied.

Install it with:

go get github.com/go-fries/fries/poll/v4@v4.0.0-beta.2

Wait for a condition

ctx, cancel := context.WithTimeout(parent, 30*time.Second)
defer cancel()

err := poll.Until(ctx, time.Second,
	func(ctx context.Context) (bool, error) {
		status, err := client.Status(ctx)
		if err != nil {
			return false, err
		}
		return status == "completed", nil
	},
)

The first check runs immediately. When a condition returns
done=false, err=nil, Poll waits the positive interval before checking again.
A condition error is terminal.

Return the latest observed value

job, err := poll.UntilValue(ctx, time.Second,
	func(ctx context.Context) (Job, bool, error) {
		job, err := client.Job(ctx, id)
		return job, job.Status == "completed", err
	},
)

UntilValue returns the most recent value on success, condition failure, or
Context cancellation. It returns the type's zero value when the condition has
not run.

Poll executes conditions synchronously and does not create a background
goroutine around them. Conditions must observe the supplied Context during
blocking work. Nil conditions and non-positive intervals panic because they
represent invalid static configuration.

Poll and Retry serve different purposes:

Retry Poll
Re-executes a failing operation Re-observes state until it is ready
Progress is driven by error Progress is driven by done bool
Usually bounded by attempt count Usually bounded by Context deadline
Backoff is a primary option v4.0.0-beta.2 uses a fixed interval

support.Until and support.UntilTimeout still exist in this release. Adding
Poll does not remove or change those APIs.

log/slog lifecycle provider

#2617 adds
github.com/go-fries/fries/log/slog/v4, a Foundation provider that sets the
process-wide standard-library logger during bootstrap.

go get github.com/go-fries/fries/log/slog/v4@v4.0.0-beta.2
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
providers := foundation.NewChain(
	slogprovider.NewProvider(logger),
)

ctx, err := providers.Bootstrap(context.Background())
if err != nil {
	return err
}

slog.InfoContext(ctx, "service started")

if _, err := providers.Terminate(ctx); err != nil {
	return err
}

Bootstrap calls slog.SetDefault. Terminate intentionally leaves the
configured logger in place and does not restore the previous default.

Breaking changes and migration

Filesystem: portable streaming storage contract

#2604 replaces the broad
Filesystem interface with a small Driver contract shared by Local, Amazon
S3, and Alibaba Cloud OSS.

The new portable contract contains:

type Driver interface {
	Open(ctx context.Context, path string) (io.ReadCloser, error)
	Put(ctx context.Context, path string, src io.Reader, options PutOptions) error
	Delete(ctx context.Context, path string) error
	Stat(ctx context.Context, path string) (Entry, error)
	ListFiles(ctx context.Context, path string, options ListOptions) (ListPage, error)
}

Optional backend features are represented by Copier, Mover, Linker,
Symlinker, and DirectoryManager capability interfaces.

Filesystem API mapping

Before After
filesystem.Filesystem filesystem.Driver
Read / Write Open / Put
Whole-file byte operations Repository.ReadFile / Repository.WriteFile
Files / AllFiles Paginated ListFiles
Exists Repository.Exists
IsFile, IsDir, Size, LastModified Stat and Entry fields
Rename Repository.Move or filesystem.Mover
MakeDirectory / DeleteDirectory filesystem.DirectoryManager
Link / Symlink filesystem.Linker / filesystem.Symlinker
Copyable filesystem.Copier
NewStorage(root) local.New(root) returning (*local.Filesystem, error)
Repository interface Concrete *filesystem.Repository
Get, Set, Destroy, Has, Missing ReadFile, WriteFile, Delete, Exists
Append / Prepend Removed; read, modify, and write explicitly
ErrNotSupported ErrUnsupported
NoopFilesystem Removed

Before

driver := local.NewStorage("./storage")
storage := filesystem.NewRepository(driver)

if err := storage.Write(ctx, "documents/example.txt", data); err != nil {
	return err
}

data, err := storage.Read(ctx, "documents/example.txt")
files, err := storage.AllFiles(ctx, "documents")

After

if err := os.MkdirAll("./storage", 0o755); err != nil {
	return err
}

driver, err := local.New("./storage")
if err != nil {
	return err
}
storage := filesystem.NewRepository(driver)

if err := storage.WriteFile(
	ctx,
	"documents/example.txt",
	data,
	filesystem.PutOptions{},
); err != nil {
	return err
}

data, err = storage.ReadFile(ctx, "documents/example.txt")
if err != nil {
	return err
}

options := filesystem.ListOptions{Recursive: true}
for {
	page, err := storage.ListFiles(ctx, "documents", options)
	if err != nil {
		return err
	}
	for _, entry := range page.Entries {
		fmt.Println(entry.Path)
	}
	if page.NextCursor == "" {
		break
	}
	options.Cursor = page.NextCursor
}

Filesystem migration requirements

  1. Update the core Filesystem module and the selected backend module together.
  2. Replace NewStorage with local.New and handle its error.
  3. Create the Local root directory before constructing the driver.
  4. Replace whole-file methods with Repository helpers or use Open and
    Put directly for streaming.
  5. Treat paths as unrooted, slash-separated logical paths. Use . for the
    logical root; leading slashes, .., backslashes, and trailing slashes are
    invalid.
  6. Close every reader returned by Open.
  7. For opaque input streams, set PutOptions.ContentLength; common readers
    and seekable files are inferred automatically.
  8. Continue paginated listing until NextCursor is empty. An empty page may
    still have another cursor.
  9. Replace concrete directory/link expectations with capability assertions.
  10. Use errors.Is(err, filesystem.ErrNotFound) for portable not-found
    handling. Deleting a missing path now succeeds.

S3 and OSS directories are virtual prefixes. Their moves use copy-then-delete
and are not atomic.

Coroutines replaced by Parallel

#2607 removes
github.com/go-fries/fries/coroutines/v4 and replaces it with
github.com/go-fries/fries/parallel/v4.

There is no compatibility alias or forwarding module.

Parallel API mapping

Before (coroutines) After (parallel)
Tasks use func() parallel.Task uses func(context.Context) error
Wait Run
ParallelWait RunLimit
Fire-and-forget Run / Parallel Explicit caller goroutine or Pool.Submit
Worker.Push / Wait / Close Pool.Submit / Future.Wait / Pool.Shutdown
No typed collection APIs ForEach, Map, MapResults, and Filter

Migrate a finite task batch

Before:

coroutines.Wait(
	func() { refreshCache() },
	func() { updateIndex() },
)

After:

err := parallel.Run(ctx,
	func(ctx context.Context) error {
		return refreshCache(ctx)
	},
	func(ctx context.Context) error {
		return updateIndex(ctx)
	},
)

Use parallel.RunLimit(ctx, limit, tasks...) when concurrency must be bounded.
Batch helpers return the first task error and cancel the Context passed to
sibling work.

Migrate a long-lived worker

Before:

worker := coroutines.NewWorker(8)
defer worker.Close()
worker.Push(tasks...)
worker.Wait()

After:

pool := parallel.NewPool(8, parallel.WithQueueSize(16))

future, err := pool.Submit(taskContext,
	func(ctx context.Context) error {
		return refreshCache(ctx)
	},
)
if err != nil {
	return err // task was not accepted
}

if err := future.Wait(waitContext); err != nil {
	return err
}

if err := pool.Shutdown(shutdownContext); err != nil {
	return err
}

Submit applies backpressure when the queue is full. The submission Context is
also the task Context. Use an explicitly owned Context when background work
must outlive a request.

NewPool panics when the worker count is not positive. Queue size zero creates
an unbuffered queue; negative values keep the default size. Task panics are not
recovered.

Use typed collection helpers

profiles, err := parallel.Map(ctx, 8, userIDs,
	func(ctx context.Context, id int64) (Profile, error) {
		return loadProfile(ctx, id)
	},
)

Map and Filter preserve input ordering. Use MapResults for best-effort
processing when every input should be attempted and each result must retain its
own error.

Hashing: reusable hashers and typed digests

#2608 removes the closed global
hash registry and adopts Go's standard func() hash.Hash constructor model.

Hashing API mapping

Before After
hashing.Hash enum and hashing.MD5 Direct algorithm constructor or hashing/md5
hashing.MD5.New() md5.New()
hasher.Make(value) hasher.SumString(value).Hex()
hasher.MustMake(value) hasher.SumString(value).Hex()
hasher.Check(value, encoded) Parse the digest, then call Equal
hashing.Register(...) hashing.New(constructor)
String-only input Sum, SumString, SumReader, and SumFile
Encoded string result hashing.Digest with explicit encoding

Create and reuse a hasher

hasher := hashing.New(sha256.New)

digest := hasher.SumString("hello")
fmt.Println(digest.Hex())
fmt.Println(digest.Base64())

fileDigest, err := hasher.SumFile("archive.tar.gz")

A Hasher creates a fresh hash.Hash for each operation and can be shared by
concurrent callers when its constructor is concurrency-safe.

Verify a stored digest

Before:

valid := hasher.Check(value, storedHash)

After:

expected, err := hashing.ParseHex(storedHash)
if err != nil {
	return err
}

actual := hashing.New(sha256.New).Sum(payload)
if !actual.Equal(expected) {
	return errors.New("checksum mismatch")
}

Choose Digest.Hex(), Digest.Base64(), or Digest.Bytes() explicitly at
storage and protocol boundaries. Digest.Bytes() returns a copy, and Equal
uses constant-time comparison for equal-length digests.

For MD5 compatibility:

checksum := md5.SumString("legacy payload").Hex()

Warning

MD5 is cryptographically broken. Use it only for checksums and legacy
protocol compatibility, never for passwords, signatures, certificates, or
other security-sensitive data.

Codec: zero-value concrete implementations

#2613 keeps the root
codec.Codec interface unchanged but changes every built-in implementation's
exported Codec identifier from a mutable package variable to a concrete
zero-value struct type.

Affected modules are JSON, MessagePack, Protocol Buffers, Sonic, XML, and YAML.

Before:

var c codec.Codec = json.Codec
store := redis.New(client, redis.Codec(json.Codec))

After:

var c codec.Codec = json.Codec{}
store := redis.New(client, redis.Codec(json.Codec{}))

Apply the same {} construction to:

  • msgpack.Codec{}
  • proto.Codec{}
  • sonic.Codec{}
  • xml.Codec{}
  • yaml.Codec{}

The zero value is ready to use, stateless, allocation-free, and safe for
concurrent use. Code that mutated the previous shared variable or relied on its
identity must instead construct and pass an explicit value.

Protocol Buffers also renames its invalid-message sentinel:

// Before
errors.Is(err, proto.ErrInvalidProtoMessage)

// After
errors.Is(err, proto.ErrInvalidMessage)

Custom implementations of the root codec.Codec interface require no changes
because the Marshal and Unmarshal method contract is unchanged.

support.Retry removed

#2615 removes the legacy
support.Retry helper. Migrate calls to the dedicated Retry component.

No-delay retry

Before:

err := support.Retry(func() error {
	return refresh()
}, 3)

After:

err := retry.Do(ctx, func(context.Context) error {
	return refresh()
},
	retry.WithMaxAttempts(3),
	retry.WithBackoff(retry.NoBackoff()),
)

NoBackoff must be explicit to preserve the old no-delay behavior because the
new default uses exponential Backoff.

Fixed-delay retry

Before:

err := support.Retry(func() error {
	return refresh()
}, 3, time.Second)

After:

err := retry.Do(ctx, func(ctx context.Context) error {
	return refreshWithContext(ctx)
},
	retry.WithMaxAttempts(3),
	retry.WithBackoff(retry.Fixed(time.Second)),
)

Behavior differences:

  • Retry waits can now be canceled through Context.
  • The final failed attempt returns immediately without another delay.
  • WithMaxAttempts includes the initial execution.
  • Values below one do not skip execution; they leave the current attempt limit
    unchanged. Branch before calling retry.Do if execution should be skipped.

Hyperf Jet Retry uses the shared Retry component

#2616 turns
hyperf/jet/middleware/retry into a thin Jet adapter over the base Retry
component.

The middleware now exposes only New, DefaultRetryIf, and Version.
New accepts options from github.com/go-fries/fries/retry/v4.

Hyperf option mapping

Before After
jetretry.Attempts(n) baseretry.WithMaxAttempts(n)
jetretry.Allow(f) baseretry.WithRetryIf(f)
jetretry.Backoff(f) baseretry.WithBackoff(f)
jetretry.NoBackoff() baseretry.NoBackoff()
jetretry.LinearBackoff(d) baseretry.Linear(d)
jetretry.ConstantBackoff(d) baseretry.Fixed(d)
jetretry.ExponentialBackoff(d) baseretry.Exponential(initial, maximum)
jetretry.DefaultAllow jetretry.DefaultRetryIf
jetretry.IsError(err) Inspect the underlying error with errors.Is / errors.As

Removed middleware-local types include Option, AllowFunc, BackoffFunc,
Error, and their constructors and helpers.

Before:

client.Use(jetretry.New(
	jetretry.Attempts(3),
	jetretry.Backoff(jetretry.LinearBackoff(100*time.Millisecond)),
	jetretry.Allow(jetretry.OrAllowFuncs(
		jetretry.DefaultAllow,
		func(err error) bool {
			return errors.Is(err, ErrTemporaryBusinessFailure)
		},
	)),
))

After:

client.Use(jetretry.New(
	baseretry.WithMaxAttempts(3),
	baseretry.WithBackoff(
		baseretry.Linear(100*time.Millisecond),
	),
	baseretry.WithRetryIf(func(err error) bool {
		return jetretry.DefaultRetryIf(err) ||
			errors.Is(err, ErrTemporaryBusinessFailure)
	}),
))

The default classifier now retries Jet timeout errors, HTTP 408, HTTP 429, and
HTTP 5xx responses. Other HTTP 4xx and unrelated errors return immediately.
Exhaustion returns the final underlying operation error without the removed
middleware error wrapper.

The old exponential helper's first delay was 2 * delay; the base
Exponential(initial, maximum) strategy starts at initial. Select the new
initial value explicitly when preserving timing matters.

Middleware order controls timeout ownership:

retry -> timeout -> handler  # independent timeout for each attempt
timeout -> retry -> handler  # one timeout for the complete sequence

Only retry idempotent operations or calls that are otherwise safe to execute
more than once.

Queue retry decisions separated from Backoff

#2620 removes Queue's combined
RetryPolicy abstraction. Queue continues to own durable delivery attempts,
Task/Error-aware retry decisions, and Ack/Retry/DeadLetter settlement, while
delay calculation uses retry.Backoff.

Removed Queue APIs:

  • RetryPolicy
  • WithRetryPolicy
  • FixedRetry
  • ExponentialRetry
  • JitterRetry
  • NoRetry

Queue API mapping

Before After
WithRetryPolicy(FixedRetry(5, delay)) WithMaxAttempts(5) and WithBackoff(retry.Fixed(delay))
WithRetryPolicy(ExponentialRetry(5, initial, maximum)) WithMaxAttempts(5) and WithBackoff(retry.Exponential(initial, maximum))
JitterRetry(ExponentialRetry(...), maximum) retry.Jitter(retry.Exponential(...), maximum)
WithRetryPolicy(NoRetry()) WithMaxAttempts(1)
Custom Task/Error eligibility WithRetryIf(func(*queue.Task, error) bool)
Custom Task/Error delay Return queue.RetryAfter(delay)

Before:

worker := queue.NewWorker(
	q,
	queue.WithRetryPolicy(
		queue.JitterRetry(
			queue.ExponentialRetry(5, time.Second, time.Minute),
			250*time.Millisecond,
		),
	),
)

After:

worker := queue.NewWorker(
	q,
	queue.WithMaxAttempts(5),
	queue.WithBackoff(
		retry.Jitter(
			retry.Exponential(time.Second, time.Minute),
			250*time.Millisecond,
		),
	),
	queue.WithRetryIf(func(task *queue.Task, err error) bool {
		return !errors.Is(err, ErrInvalidPayload)
	}),
)

Migration requirements:

  1. Import github.com/go-fries/fries/retry/v4 for Backoff configuration.
  2. Split the old policy into maximum attempts, eligibility, and delay.
  3. Use WithMaxAttempts(1) to disable retries.
  4. Return queue.RetryAfter(delay) for a Task/Error-derived delay override.
  5. Review invalid duration inputs: shared Backoff constructors panic on
    negative durations, and an exponential maximum below its initial delay also
    panics.

The default remains three total deliveries with a fixed one-second delay.
RetryAfter bypasses WithRetryIf but still respects the attempt limit. A
predicate-rejected error becomes the dead-letter reason; ErrRetryExhausted
is reserved for an exhausted delivery budget.

Important

Queue does not use retry.Do around handlers. Every retry remains a separate
durable delivery attempt so adapter redelivery, attempt increments, and
settlement semantics remain intact.

What's changed

Breaking API changes

  • #2604 refactor(filesystem)!: redesign storage API
  • #2607 refactor(parallel)!: replace coroutines module
  • #2608 refactor(hashing)!: replace registry with reusable hashers
  • #2613 refactor(codec)!: replace shared instances with zero-value types
  • #2615 refactor(support)!: remove retry helper
  • #2616 refactor(hyperf)!: reuse retry component
  • #2620 refactor(queue)!: separate retry decisions from backoff

Features

  • #2614 feat(retry): add retry component
  • #2617 feat(log): add slog provider
  • #2622 feat(poll): add context-aware polling component

CI and documentation

  • #2605 ci: remove static analysis workflow
  • #2606 ci: restrict test workflow permissions
  • #2610 docs: add component catalog to README
  • #2611 ci(lint): improve Go cache reuse

Dependencies

  • #2593 chore(deps): update google.golang.org/genproto/googleapis/api digest to f0a9213
  • #2597 chore(deps): update module github.com/rogpeppe/go-internal to v1.15.0
  • #2599 chore(deps): update module golang.org/x/net to v0.55.0 [security]
  • #2600 chore(deps): update module go.mongodb.org/mongo-driver/v2 to v2.8.0
  • #2601 fix(deps): update module buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go to v1.36.11-20260709200747-435963d16310.1
  • #2602 chore(deps): update module github.com/prometheus/common to v0.70.0
  • #2603 chore(deps): update module github.com/dlclark/regexp2/v2 to v2.3.0
  • #2589 fix(deps): update golang.org/x
  • #2609 chore(deps): update module github.com/dlclark/regexp2/v2 to v2.4.0
  • #2612 chore(deps): update module github.com/dlclark/regexp2/v2 to v2.5.0
  • #2618 chore(deps): update github.com/charmbracelet/ultraviolet digest to 4bee191
  • #2619 chore(deps): update github.com/petermattis/goid digest to 57ed88f
  • #2621 chore(deps): update github.com/petermattis/goid digest to 97594f2

Full Changelog: v4.0.0-beta.1...v4.0.0-beta.2