| 1 | package feishu |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/rand" |
| 6 | "encoding/hex" |
| 7 | "errors" |
| 8 | "io" |
| 9 | "log/slog" |
| 10 | mrand "math/rand" |
| 11 | "net" |
| 12 | "strings" |
| 13 | "syscall" |
| 14 | "time" |
| 15 | |
| 16 | "reasonix/internal/bot" |
| 17 | ) |
| 18 | |
| 19 | // newIdempotencyKey returns a random key for the Feishu create/reply `uuid` |
| 20 | // dedup field. It is generated once per logical send and reused across |
| 21 | // transient retries, so a retry after the request already reached the server |
| 22 | // (response read failed) does not post a duplicate visible message. An empty |
| 23 | // return (rand failure) simply omits the key — retries then behave as before. |
| 24 | func newIdempotencyKey() string { |
| 25 | var b [16]byte |
| 26 | if _, err := rand.Read(b[:]); err != nil { |
| 27 | return "" |
| 28 | } |
| 29 | return hex.EncodeToString(b[:]) |
| 30 | } |
| 31 | |
| 32 | const ( |
| 33 | transientRetryAttempts = 3 |
| 34 | transientRetryBaseDelay = 500 * time.Millisecond |
| 35 | transientRetryMaxDelay = 5 * time.Second |
| 36 | ) |
| 37 | |
| 38 | // withTransientRetry retries fn on transport-level failures (connection reset, |
| 39 | // timeout, broken pipe) with exponential backoff and jitter. Feishu API-level |
| 40 | // errors (rate limit, size limit, permission) are returned as-is — retrying |
| 41 | // those blindly would only make things worse. |
| 42 | func withTransientRetry(ctx context.Context, logger *slog.Logger, op string, fn func(context.Context) error) error { |
| 43 | delay := transientRetryBaseDelay |
| 44 | for attempt := 1; ; attempt++ { |
| 45 | err := fn(ctx) |
| 46 | if err == nil || attempt >= transientRetryAttempts || ctx.Err() != nil || !isTransientError(err) { |
| 47 | return err |
| 48 | } |
| 49 | wait := delay + time.Duration(mrand.Int63n(int64(delay/4)+1)) |
| 50 | logger.Warn("feishu transient error; retrying", "op", op, "attempt", attempt, "wait", wait, "err", err) |
| 51 | if !bot.SleepCtx(ctx, wait) { |
| 52 | return err |
| 53 | } |
| 54 | delay *= 2 |
| 55 | if delay > transientRetryMaxDelay { |
| 56 | delay = transientRetryMaxDelay |
| 57 | } |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func isTransientError(err error) bool { |
| 62 | if err == nil { |
| 63 | return false |
| 64 | } |
| 65 | if errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) || errors.Is(err, io.ErrUnexpectedEOF) { |
| 66 | return true |
| 67 | } |
| 68 | var netErr net.Error |
| 69 | if errors.As(err, &netErr) && netErr.Timeout() { |
| 70 | return true |
| 71 | } |
| 72 | msg := strings.ToLower(err.Error()) |
| 73 | for _, marker := range []string{ |
| 74 | "connection reset", "broken pipe", "i/o timeout", |
| 75 | "tls handshake timeout", "connection refused", "unexpected eof", |
| 76 | } { |
| 77 | if strings.Contains(msg, marker) { |
| 78 | return true |
| 79 | } |
| 80 | } |
| 81 | return false |
| 82 | } |
| 83 |