[chore] transport improvements (#1524)
* improve error readability, mark "bad hosts" as fastFail Signed-off-by: kim <grufwub@gmail.com> * pull in latest go-byteutil version with byteutil.Reader{} Signed-off-by: kim <grufwub@gmail.com> * use rewindable body reader for post requests Signed-off-by: kim <grufwub@gmail.com> --------- Signed-off-by: kim <grufwub@gmail.com>
This commit is contained in:
parent
3649b231c4
commit
a684fc4628
2
go.mod
2
go.mod
|
@ -4,7 +4,7 @@ go 1.19
|
||||||
|
|
||||||
require (
|
require (
|
||||||
codeberg.org/gruf/go-bytesize v1.0.2
|
codeberg.org/gruf/go-bytesize v1.0.2
|
||||||
codeberg.org/gruf/go-byteutil v1.0.2
|
codeberg.org/gruf/go-byteutil v1.1.2
|
||||||
codeberg.org/gruf/go-cache/v3 v3.2.2
|
codeberg.org/gruf/go-cache/v3 v3.2.2
|
||||||
codeberg.org/gruf/go-debug v1.3.0
|
codeberg.org/gruf/go-debug v1.3.0
|
||||||
codeberg.org/gruf/go-errors/v2 v2.1.1
|
codeberg.org/gruf/go-errors/v2 v2.1.1
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -47,8 +47,8 @@ codeberg.org/gruf/go-bytes v1.0.2/go.mod h1:1v/ibfaosfXSZtRdW2rWaVrDXMc9E3bsi/M9
|
||||||
codeberg.org/gruf/go-bytesize v1.0.2 h1:Mo+ITi+0uZ4YNSZf2ed6Qw8acOI39W4mmgE1a8lslXw=
|
codeberg.org/gruf/go-bytesize v1.0.2 h1:Mo+ITi+0uZ4YNSZf2ed6Qw8acOI39W4mmgE1a8lslXw=
|
||||||
codeberg.org/gruf/go-bytesize v1.0.2/go.mod h1:n/GU8HzL9f3UNp/mUKyr1qVmTlj7+xacpp0OHfkvLPs=
|
codeberg.org/gruf/go-bytesize v1.0.2/go.mod h1:n/GU8HzL9f3UNp/mUKyr1qVmTlj7+xacpp0OHfkvLPs=
|
||||||
codeberg.org/gruf/go-byteutil v1.0.0/go.mod h1:cWM3tgMCroSzqoBXUXMhvxTxYJp+TbCr6ioISRY5vSU=
|
codeberg.org/gruf/go-byteutil v1.0.0/go.mod h1:cWM3tgMCroSzqoBXUXMhvxTxYJp+TbCr6ioISRY5vSU=
|
||||||
codeberg.org/gruf/go-byteutil v1.0.2 h1:OesVyK5VKWeWdeDR00zRJ+Oy8hjXx1pBhn7WVvcZWVE=
|
codeberg.org/gruf/go-byteutil v1.1.2 h1:TQLZtTxTNca9xEfDIndmo7nBYxeS94nrv/9DS3Nk5Tw=
|
||||||
codeberg.org/gruf/go-byteutil v1.0.2/go.mod h1:cWM3tgMCroSzqoBXUXMhvxTxYJp+TbCr6ioISRY5vSU=
|
codeberg.org/gruf/go-byteutil v1.1.2/go.mod h1:cWM3tgMCroSzqoBXUXMhvxTxYJp+TbCr6ioISRY5vSU=
|
||||||
codeberg.org/gruf/go-cache/v3 v3.2.2 h1:hq6/RITgpcArjzbYSyo3uFxfIw7wW3KqAQjEaN7dj58=
|
codeberg.org/gruf/go-cache/v3 v3.2.2 h1:hq6/RITgpcArjzbYSyo3uFxfIw7wW3KqAQjEaN7dj58=
|
||||||
codeberg.org/gruf/go-cache/v3 v3.2.2/go.mod h1:+Eje6nCvN8QF71VyYjMWMnkdv6t1kHnCO/SvyC4K12Q=
|
codeberg.org/gruf/go-cache/v3 v3.2.2/go.mod h1:+Eje6nCvN8QF71VyYjMWMnkdv6t1kHnCO/SvyC4K12Q=
|
||||||
codeberg.org/gruf/go-debug v1.3.0 h1:PIRxQiWUFKtGOGZFdZ3Y0pqyfI0Xr87j224IYe2snZs=
|
codeberg.org/gruf/go-debug v1.3.0 h1:PIRxQiWUFKtGOGZFdZ3Y0pqyfI0Xr87j224IYe2snZs=
|
||||||
|
|
|
@ -19,7 +19,6 @@
|
||||||
package transport
|
package transport
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -27,6 +26,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"codeberg.org/gruf/go-byteutil"
|
||||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
)
|
)
|
||||||
|
@ -49,7 +49,7 @@ func (t *transport) BatchDeliver(ctx context.Context, b []byte, recipients []*ur
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// receive any buffered errors
|
// receive any buffered errors
|
||||||
errs := make([]string, 0, len(recipients))
|
errs := make([]string, 0, len(errCh))
|
||||||
outer:
|
outer:
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
@ -75,7 +75,11 @@ func (t *transport) Deliver(ctx context.Context, b []byte, to *url.URL) error {
|
||||||
|
|
||||||
urlStr := to.String()
|
urlStr := to.String()
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", urlStr, bytes.NewReader(b))
|
// Use rewindable bytes reader for body.
|
||||||
|
var body byteutil.ReadNopCloser
|
||||||
|
body.Reset(b)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", urlStr, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -92,7 +96,7 @@ func (t *transport) Deliver(ctx context.Context, b []byte, to *url.URL) error {
|
||||||
|
|
||||||
if code := resp.StatusCode; code != http.StatusOK &&
|
if code := resp.StatusCode; code != http.StatusOK &&
|
||||||
code != http.StatusCreated && code != http.StatusAccepted {
|
code != http.StatusCreated && code != http.StatusAccepted {
|
||||||
return fmt.Errorf("POST request to %s failed (%d): %s", urlStr, resp.StatusCode, resp.Status)
|
return fmt.Errorf("POST request to %s failed: %s", urlStr, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -78,6 +78,6 @@ func (t *transport) Dereference(ctx context.Context, iri *url.URL) ([]byte, erro
|
||||||
case http.StatusGone:
|
case http.StatusGone:
|
||||||
return nil, ErrGone
|
return nil, ErrGone
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("GET request to %s failed (%d): %s", iriStr, rsp.StatusCode, rsp.Status)
|
return nil, fmt.Errorf("GET request to %s failed: %s", iriStr, rsp.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -102,7 +102,7 @@ func dereferenceByAPIV1Instance(ctx context.Context, t *transport, iri *url.URL)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("GET request to %s failed (%d): %s", iriStr, resp.StatusCode, resp.Status)
|
return nil, fmt.Errorf("GET request to %s failed: %s", iriStr, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := io.ReadAll(resp.Body)
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
@ -252,7 +252,7 @@ func callNodeInfoWellKnown(ctx context.Context, t *transport, iri *url.URL) (*ur
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("callNodeInfoWellKnown: GET request to %s failed (%d): %s", iriStr, resp.StatusCode, resp.Status)
|
return nil, fmt.Errorf("callNodeInfoWellKnown: GET request to %s failed: %s", iriStr, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := io.ReadAll(resp.Body)
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
@ -303,7 +303,7 @@ func callNodeInfo(ctx context.Context, t *transport, iri *url.URL) (*apimodel.No
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("callNodeInfo: GET request to %s failed (%d): %s", iriStr, resp.StatusCode, resp.Status)
|
return nil, fmt.Errorf("callNodeInfo: GET request to %s failed: %s", iriStr, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := io.ReadAll(resp.Body)
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
|
|
@ -46,7 +46,7 @@ func (t *transport) DereferenceMedia(ctx context.Context, iri *url.URL) (io.Read
|
||||||
|
|
||||||
// Check for an expected status code
|
// Check for an expected status code
|
||||||
if rsp.StatusCode != http.StatusOK {
|
if rsp.StatusCode != http.StatusOK {
|
||||||
return nil, 0, fmt.Errorf("GET request to %s failed (%d): %s", iriStr, rsp.StatusCode, rsp.Status)
|
return nil, 0, fmt.Errorf("GET request to %s failed: %s", iriStr, rsp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
return rsp.Body, rsp.ContentLength, nil
|
return rsp.Body, rsp.ContentLength, nil
|
||||||
|
|
|
@ -52,7 +52,7 @@ func (t *transport) Finger(ctx context.Context, targetUsername string, targetDom
|
||||||
|
|
||||||
// Check for an expected status code
|
// Check for an expected status code
|
||||||
if rsp.StatusCode != http.StatusOK {
|
if rsp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("GET request to %s failed (%d): %s", urlStr, rsp.StatusCode, rsp.Status)
|
return nil, fmt.Errorf("GET request to %s failed: %s", urlStr, rsp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
return io.ReadAll(rsp.Body)
|
return io.ReadAll(rsp.Body)
|
||||||
|
|
|
@ -32,6 +32,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/gruf/go-byteutil"
|
||||||
errorsv2 "codeberg.org/gruf/go-errors/v2"
|
errorsv2 "codeberg.org/gruf/go-errors/v2"
|
||||||
"codeberg.org/gruf/go-kv"
|
"codeberg.org/gruf/go-kv"
|
||||||
"github.com/go-fed/httpsig"
|
"github.com/go-fed/httpsig"
|
||||||
|
@ -84,7 +85,7 @@ type transport struct {
|
||||||
signerMu sync.Mutex
|
signerMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET will perform given http request using transport client, retrying on certain preset errors, or if status code is among retryOn.
|
// GET will perform given http request using transport client, retrying on certain preset errors.
|
||||||
func (t *transport) GET(r *http.Request) (*http.Response, error) {
|
func (t *transport) GET(r *http.Request) (*http.Response, error) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
return nil, errors.New("must be GET request")
|
return nil, errors.New("must be GET request")
|
||||||
|
@ -94,7 +95,7 @@ func (t *transport) GET(r *http.Request) (*http.Response, error) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST will perform given http request using transport client, retrying on certain preset errors, or if status code is among retryOn.
|
// POST will perform given http request using transport client, retrying on certain preset errors.
|
||||||
func (t *transport) POST(r *http.Request, body []byte) (*http.Response, error) {
|
func (t *transport) POST(r *http.Request, body []byte) (*http.Response, error) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
return nil, errors.New("must be POST request")
|
return nil, errors.New("must be POST request")
|
||||||
|
@ -116,18 +117,17 @@ func (t *transport) do(r *http.Request, signer func(*http.Request) error) (*http
|
||||||
// Get request hostname
|
// Get request hostname
|
||||||
host := r.URL.Hostname()
|
host := r.URL.Hostname()
|
||||||
|
|
||||||
// Check if recently reached max retries for this host
|
|
||||||
// so we don't need to bother reattempting it. The only
|
|
||||||
// errors that are retried upon are server failure and
|
|
||||||
// domain resolution type errors, so this cached result
|
|
||||||
// indicates this server is likely having issues.
|
|
||||||
if t.controller.badHosts.Has(host) {
|
|
||||||
return nil, errors.New("too many failed attempts")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check whether request should fast fail, we check this
|
// Check whether request should fast fail, we check this
|
||||||
// before loop as each context.Value() requires mutex lock.
|
// before loop as each context.Value() requires mutex lock.
|
||||||
fastFail := IsFastfail(r.Context())
|
fastFail := IsFastfail(r.Context())
|
||||||
|
if !fastFail {
|
||||||
|
// Check if recently reached max retries for this host
|
||||||
|
// so we don't bother with a retry-backoff loop. The only
|
||||||
|
// errors that are retried upon are server failure and
|
||||||
|
// domain resolution type errors, so this cached result
|
||||||
|
// indicates this server is likely having issues.
|
||||||
|
fastFail = t.controller.badHosts.Has(host)
|
||||||
|
}
|
||||||
|
|
||||||
// Start a log entry for this request
|
// Start a log entry for this request
|
||||||
l := log.WithContext(r.Context()).
|
l := log.WithContext(r.Context()).
|
||||||
|
@ -148,6 +148,12 @@ func (t *transport) do(r *http.Request, signer func(*http.Request) error) (*http
|
||||||
r.Header.Del("Signature")
|
r.Header.Del("Signature")
|
||||||
r.Header.Del("Digest")
|
r.Header.Del("Digest")
|
||||||
|
|
||||||
|
// Rewind body reader and content-length if set.
|
||||||
|
if rc, ok := r.Body.(*byteutil.ReadNopCloser); ok {
|
||||||
|
r.ContentLength = int64(rc.Len())
|
||||||
|
rc.Rewind()
|
||||||
|
}
|
||||||
|
|
||||||
// Perform request signing
|
// Perform request signing
|
||||||
if err := signer(r); err != nil {
|
if err := signer(r); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -226,7 +232,7 @@ func (t *transport) do(r *http.Request, signer func(*http.Request) error) (*http
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add "bad" entry for this host
|
// Add "bad" entry for this host.
|
||||||
t.controller.badHosts.Set(host, struct{}{})
|
t.controller.badHosts.Set(host, struct{}{})
|
||||||
|
|
||||||
return nil, errors.New("transport reached max retries")
|
return nil, errors.New("transport reached max retries")
|
||||||
|
|
|
@ -18,17 +18,20 @@ func Copy(b []byte) []byte {
|
||||||
// B2S returns a string representation of []byte without allocation.
|
// B2S returns a string representation of []byte without allocation.
|
||||||
//
|
//
|
||||||
// According to the Go spec strings are immutable and byte slices are not. The way this gets implemented is strings under the hood are:
|
// According to the Go spec strings are immutable and byte slices are not. The way this gets implemented is strings under the hood are:
|
||||||
|
//
|
||||||
// type StringHeader struct {
|
// type StringHeader struct {
|
||||||
// Data uintptr
|
// Data uintptr
|
||||||
// Len int
|
// Len int
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
// while slices are:
|
// while slices are:
|
||||||
|
//
|
||||||
// type SliceHeader struct {
|
// type SliceHeader struct {
|
||||||
// Data uintptr
|
// Data uintptr
|
||||||
// Len int
|
// Len int
|
||||||
// Cap int
|
// Cap int
|
||||||
// }
|
// }
|
||||||
|
//
|
||||||
// because being mutable, you can change the data, length etc, but the string has to promise to be read-only to all who get copies of it.
|
// because being mutable, you can change the data, length etc, but the string has to promise to be read-only to all who get copies of it.
|
||||||
//
|
//
|
||||||
// So in practice when you do a conversion of `string(byteSlice)` it actually performs an allocation because it has to copy the contents of the byte slice into a safe read-only state.
|
// So in practice when you do a conversion of `string(byteSlice)` it actually performs an allocation because it has to copy the contents of the byte slice into a safe read-only state.
|
||||||
|
@ -54,31 +57,3 @@ func S2B(s string) []byte {
|
||||||
|
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToUpper offers a faster ToUpper implementation using a lookup table.
|
|
||||||
func ToUpper(b []byte) {
|
|
||||||
const toUpperTable = "\x00\x01\x02\x03\x04\x05\x06\a\b\t\n\v\f\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f" +
|
|
||||||
" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`ABCDEFGHIJKLMNOPQRSTUVWXYZ{|}~" +
|
|
||||||
"\u007f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96" +
|
|
||||||
"\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf" +
|
|
||||||
"\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8" +
|
|
||||||
"\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1" +
|
|
||||||
"\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"
|
|
||||||
for i := 0; i < len(b); i++ {
|
|
||||||
b[i] = toUpperTable[b[i]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToLower offers a faster ToLower implementation using a lookup table.
|
|
||||||
func ToLower(b []byte) {
|
|
||||||
const toLowerTable = "\x00\x01\x02\x03\x04\x05\x06\a\b\t\n\v\f\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f" +
|
|
||||||
" !\"#$%&'()*+,-./0123456789:;<=>?@abcdefghijklmnopqrstuvwxyz[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" +
|
|
||||||
"\u007f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96" +
|
|
||||||
"\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf" +
|
|
||||||
"\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8" +
|
|
||||||
"\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1" +
|
|
||||||
"\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"
|
|
||||||
for i := 0; i < len(b); i++ {
|
|
||||||
b[i] = toLowerTable[b[i]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
package byteutil
|
||||||
|
|
||||||
|
import "bytes"
|
||||||
|
|
||||||
|
// Reader wraps a bytes.Reader{} to provide Rewind() capabilities.
|
||||||
|
type Reader struct {
|
||||||
|
B []byte
|
||||||
|
bytes.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewReader returns a new Reader{} instance reset to b.
|
||||||
|
func NewReader(b []byte) *Reader {
|
||||||
|
r := &Reader{}
|
||||||
|
r.Reset(b)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset resets the Reader{} to be reading from b and sets Reader{}.B.
|
||||||
|
func (r *Reader) Reset(b []byte) {
|
||||||
|
r.B = b
|
||||||
|
r.Rewind()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewind resets the Reader{} to be reading from the start of Reader{}.B.
|
||||||
|
func (r *Reader) Rewind() {
|
||||||
|
r.Reader.Reset(r.B)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadNopCloser wraps a Reader{} to provide nop close method.
|
||||||
|
type ReadNopCloser struct {
|
||||||
|
Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ReadNopCloser) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -10,7 +10,7 @@ codeberg.org/gruf/go-bytes
|
||||||
# codeberg.org/gruf/go-bytesize v1.0.2
|
# codeberg.org/gruf/go-bytesize v1.0.2
|
||||||
## explicit; go 1.17
|
## explicit; go 1.17
|
||||||
codeberg.org/gruf/go-bytesize
|
codeberg.org/gruf/go-bytesize
|
||||||
# codeberg.org/gruf/go-byteutil v1.0.2
|
# codeberg.org/gruf/go-byteutil v1.1.2
|
||||||
## explicit; go 1.16
|
## explicit; go 1.16
|
||||||
codeberg.org/gruf/go-byteutil
|
codeberg.org/gruf/go-byteutil
|
||||||
# codeberg.org/gruf/go-cache/v3 v3.2.2
|
# codeberg.org/gruf/go-cache/v3 v3.2.2
|
||||||
|
|
Loading…
Reference in New Issue