synapsecns/sanguine

View on GitHub
tools/abigen/internal/etherscan/ratelimiter.go

Summary

Maintainability
A
1 hr
Test Coverage
package etherscan

import (
    "context"
    "fmt"
    "github.com/gofrs/flock"
    "io"
    "os"
    "path/filepath"
    "strconv"
    "sync"
    "time"
)

// fileRateLimiter implements a file based rate limiter for limiting requests across abi generate commands.
type fileRateLimiter struct {
    // lockFolderPath is the path to the rate limiter
    lockFolderPath string
    // flock is the file locker for recording the last request. This is locked per process to prevent two from running at once
    flock *flock.Flock
    // waitBetweenRequests is how long to wait before requests
    waitBetweenRequests time.Duration
    // lastRequestFile is the last request file
    lastRequestFile *os.File
    // mux prevents duplicate locking
    mux sync.Mutex
}

// fileRateTimeout is how long to wait for file rate limiting.
const fileRateTimeout = time.Second * 20

// lastRequestFile is the last request.
const lastRequestFile = "/last_request.txt"

// newFileRateLimiter creates a new file rate limiter.
func newFileRateLimiter(parentCtx context.Context, lockFolderPath string, waitBetweenRequests time.Duration) (*fileRateLimiter, error) {
    ctx, cancel := context.WithTimeout(parentCtx, fileRateTimeout)
    defer cancel()

    _ = os.MkdirAll(lockFolderPath, os.ModePerm)

    frl := fileRateLimiter{
        lockFolderPath:      lockFolderPath,
        waitBetweenRequests: waitBetweenRequests,
        flock:               flock.New(filepath.Join(lockFolderPath, "locker")),
    }

    _, err := frl.flock.TryLockContext(ctx, time.Second)
    if err != nil {
        return nil, fmt.Errorf("could not obtain lock (timeout %s): %w", waitBetweenRequests, err)
    }

    //nolint: gosec
    err = frl.openFile()
    if err != nil {
        return nil, fmt.Errorf("could not create last request file: %w", err)
    }

    return &frl, nil
}

func (f *fileRateLimiter) openFile() (err error) {
    _ = f.lastRequestFile.Close()
    f.lastRequestFile, err = os.OpenFile(filepath.Join(f.lockFolderPath, lastRequestFile), os.O_RDWR|os.O_CREATE, os.ModePerm)
    if err != nil {
        return fmt.Errorf("could not create last request file: %w", err)
    }
    return nil
}

// obtainLock locks until the time since last request is greater than waitBetweenRequests.
func (f *fileRateLimiter) obtainLock(ctx context.Context) (ok bool, err error) {
    f.mux.Lock()
    err = f.openFile()
    if err != nil {
        return false, fmt.Errorf("could not open file: %w", err)
    }

    fileContents, err := io.ReadAll(f.lastRequestFile)
    if err != nil {
        return false, fmt.Errorf("could not obtain file contents: %w", err)
    }

    var unixTimestamp int
    if len(fileContents) == 0 {
        unixTimestamp = 0
    } else {
        unixTimestamp, err = strconv.Atoi(string(fileContents))
        if err != nil {
            return false, fmt.Errorf("could not parse unix timestamp: %w", err)
        }
    }

    lastRequest := time.Unix(int64(unixTimestamp), 0)

    // waitPeriod is how long to wait before obtaining the lock
    waitPeriod := time.Until(lastRequest.Add(f.waitBetweenRequests))

    select {
    case <-ctx.Done():
        return false, context.Canceled
    case <-time.After(waitPeriod):
        return true, nil
    }
}

// releaseLock releases the lock.
func (f *fileRateLimiter) releaseLock() (ok bool, err error) {
    f.mux.Unlock()

    err = f.openFile()
    if err != nil {
        return false, fmt.Errorf("could not open file: %w", err)
    }

    err = f.lastRequestFile.Truncate(0)
    if err != nil {
        return false, fmt.Errorf("could not truncate file: %w", err)
    }

    _, err = f.lastRequestFile.Seek(0, 0)
    if err != nil {
        return false, fmt.Errorf("could not release lock: %w", err)
    }

    _, err = f.lastRequestFile.WriteString(strconv.Itoa(int(time.Now().Unix())))
    if err != nil {
        return false, fmt.Errorf("could not write timestamp: %w", err)
    }

    err = f.lastRequestFile.Close()
    if err != nil {
        return false, fmt.Errorf("could not write timestamp: %w", err)
    }

    return true, nil
}