v4.0.0-beta.2
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.2Retry 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
100msand capped at1s; - retry all errors except
context.Canceledand
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.2Wait 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.2logger := 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
- Update the core Filesystem module and the selected backend module together.
- Replace
NewStoragewithlocal.Newand handle its error. - Create the Local root directory before constructing the driver.
- Replace whole-file methods with
Repositoryhelpers or useOpenand
Putdirectly for streaming. - Treat paths as unrooted, slash-separated logical paths. Use
.for the
logical root; leading slashes,.., backslashes, and trailing slashes are
invalid. - Close every reader returned by
Open. - For opaque input streams, set
PutOptions.ContentLength; common readers
and seekable files are inferred automatically. - Continue paginated listing until
NextCursoris empty. An empty page may
still have another cursor. - Replace concrete directory/link expectations with capability assertions.
- 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.
WithMaxAttemptsincludes the initial execution.- Values below one do not skip execution; they leave the current attempt limit
unchanged. Branch before callingretry.Doif 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:
RetryPolicyWithRetryPolicyFixedRetryExponentialRetryJitterRetryNoRetry
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:
- Import
github.com/go-fries/fries/retry/v4for Backoff configuration. - Split the old policy into maximum attempts, eligibility, and delay.
- Use
WithMaxAttempts(1)to disable retries. - Return
queue.RetryAfter(delay)for a Task/Error-derived delay override. - 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