SaqSaq Docs
最佳实践

错误处理

Saq 返回标准 HTTP 状态码。你的策略取决于错误类别。

处理对照表

状态码含义处理方式
200操作成功。处理响应。
201资源已创建(收款、提现)。处理响应。
400Payload 无效。不要重试。记录 message + requestId 并修正代码。
401Token 缺失、无效或已撤销。不要重试。检查 token(空格、编码、轮换)。
403Token 有效但没有该 endpoint 的权限。不要重试。联系支持团队确认账户是否已开通。
404资源未找到。不要重试。确认 idclientReference
409冲突(重复、资源状态无效)。不要重试。重试前通过 GET 查询当前状态。
422语义校验失败。不要重试。根据 message 修正 payload。
429触发 rate limit。等待,做指数退避后再重试。
5xxSaq 服务器错误。带指数退避重试(1s → 2s → 4s → 8s,最多 4 次)。
Timeout在期限内没有响应。操作可能已被应用。重新创建前通过 clientReference 查询。

Retry helper

只在 4295xx 时重试。4xx 绝不重试。

ATTEMPTS=4
DELAY=1

for i in $(seq 1 $ATTEMPTS); do
  STATUS=$(curl -s -o /tmp/resp.json -w "%{http_code}" \
    -X POST https://api.saq.processamento.com/v1/pix \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"amount":99.90,"clientReference":"order-1234"}')

  case $STATUS in
    2*) cat /tmp/resp.json; exit 0 ;;
    429|5*) sleep $DELAY; DELAY=$((DELAY*2)) ;;
    *) echo "错误 $STATUS"; cat /tmp/resp.json; exit 1 ;;
  esac
done
echo "已超过最大重试次数"
exit 1
async function withRetry<T>(
  fn: () => Promise<Response>,
  attempts = 4,
): Promise<T> {
  let lastErr: unknown;
  for (let i = 0; i < attempts; i++) {
    try {
      const res = await fn();
      if (res.ok) return res.json();

      if (res.status >= 400 && res.status < 500 && res.status !== 429) {
        const body = await res.text();
        throw new Error(`Client error ${res.status}: ${body}`);
      }
    } catch (err) {
      lastErr = err;
    }

    const delay = Math.min(8000, 1000 * 2 ** i) + Math.random() * 250;
    await new Promise((r) => setTimeout(r, delay));
  }
  throw lastErr ?? new Error('Max retries exceeded');
}
import time, random, requests

def with_retry(fn, attempts=4):
    last_err = None
    for i in range(attempts):
        try:
            res = fn()
            if res.ok:
                return res.json()
            if 400 <= res.status_code < 500 and res.status_code != 429:
                raise RuntimeError(f'Client error {res.status_code}: {res.text}')
        except Exception as e:
            last_err = e

        delay = min(8.0, 1.0 * (2 ** i)) + random.random() * 0.25
        time.sleep(delay)

    raise last_err or RuntimeError('Max retries exceeded')
func WithRetry(fn func() (*http.Response, error), attempts int) ([]byte, error) {
    var lastErr error
    for i := 0; i < attempts; i++ {
        res, err := fn()
        if err == nil {
            defer res.Body.Close()
            if res.StatusCode >= 200 && res.StatusCode < 300 {
                return io.ReadAll(res.Body)
            }
            if res.StatusCode >= 400 && res.StatusCode < 500 && res.StatusCode != 429 {
                body, _ := io.ReadAll(res.Body)
                return nil, fmt.Errorf("client error %d: %s", res.StatusCode, body)
            }
        }
        lastErr = err

        delay := time.Duration(math.Min(8000, 1000*math.Pow(2, float64(i)))) * time.Millisecond
        time.Sleep(delay + time.Duration(rand.Intn(250))*time.Millisecond)
    }
    return nil, lastErr
}

Timeout:“可能已经成功”的陷阱

Timeout 不等同于失败。Saq 可能已经接收、处理并保存了交易,只是响应没有返回。你的应用并不知道。

解决方案:使用唯一的 clientReference,重试前先查询。

async function createOrRetry(orderId: string, amount: number) {
  const ref = `order-${orderId}`;
  try {
    return await withRetry(() => postPix({ amount, clientReference: ref }));
  } catch (err) {
    // 尽管出错/超时,操作可能已经成功
    const existing = await fetch(
      `https://api.saq.processamento.com/v1/pix?clientReference=${ref}`,
      { headers },
    ).then((r) => (r.ok ? r.json() : null));
    if (existing) return existing;
    throw err;
  }
}

错误的可观测性

至少要记录以下字段:

字段原因
requestId来自 Saq 错误响应。支持团队可直接追踪。
本地 id你的标识符(订单、提现)。
Saq id如果已存在。
endToEndId在争议时用于在 Bacen 追踪。
clientReference通用的关联键。
HTTP status + message根本原因几乎总是在 message 中。
第 N 次 / 共 M 次区分首次尝试与重试。
log.error('Saq /pix 调用失败', {
  requestId: body.requestId,
  status: res.status,
  message: body.message,
  clientReference: ref,
  attempt: i + 1,
  attempts,
});

给终端用户的友好错误提示

不要直接暴露原始的 message。将其翻译为可操作的提示:

Saq 错误给用户的提示
401 Unauthorized“配置错误。请联系支持团队并提供 requestId 代码。”
400 amount must be >= 1“收款最低金额为 R$ 1,00。”
400 invalid pixKey“Pix 密钥无效。请核对后重试。”
429 Too Many Requests“请求过多。请稍后再试。”
5xx“系统暂时不可用。我们正在处理。”

常见陷阱

陷阱症状
400 上重试对 API 形成 spam,相同错误重复 N 次
401 上重试却不轮换 tokenToken 在日志中进一步泄漏
没有退避(立即循环重试)变成 rate limit,最终被封禁
退避没有 jitterN 个客户端同时打过来,形成 “thundering herd”
把 timeout 当作最终失败向用户重复扣款 2 次
不记录 requestId支持团队无法调查

使用 requestId 开支持工单

保存了 requestId?直接发给支持团队。

On this page