fix(cli): report GitHub API rate limits in difyctl install scripts (#38375)

This commit is contained in:
L1nSn0w 2026-07-03 16:12:58 +08:00 committed by GitHub
parent 059a1fe090
commit 262b0b1a89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 363 additions and 13 deletions

View File

@ -28,7 +28,7 @@ curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/in
Windows: `$env:DIFYCTL_R2_BASE='<BASE>'; irm https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-r2.ps1 | iex` (same env vars, e.g. `$env:DIFYCTL_COMMIT='ce4af86'`).
Re-run to upgrade. For tagged `rc`/`stable` builds, use the GitHub installer (`install-cli.sh`).
Re-run to upgrade. For tagged `rc`/`stable` builds, use the GitHub installer (`install-cli.sh` / `install.ps1`), which resolves releases via the GitHub API. That API caps unauthenticated requests at 60/hour per IP; behind a shared NAT or in CI, set `GITHUB_TOKEN` (or `GH_TOKEN`) to raise it to 5000/hour — the installer sends it as a bearer token.
## Quickstart

View File

@ -10,6 +10,7 @@
# DIFYCTL_VERSION difyctl version pin (used only when DIFY_VERSION is unset).
# DIFYCTL_PREFIX install dir (default $HOME/.local); binary -> $PREFIX/bin/difyctl
# DIFYCTL_REPO release source repo (default langgenius/dify)
# GITHUB_TOKEN GitHub token (or GH_TOKEN) to raise the API rate limit to 5000/hour.
# requires: curl, uname, sort -V, and sha256sum or shasum.
set -eu
@ -17,11 +18,14 @@ REPO="${DIFYCTL_REPO:-langgenius/dify}"
PREFIX="${DIFYCTL_PREFIX:-${HOME}/.local}"
DIFY_VERSION="${DIFY_VERSION:-}"
DIFYCTL_VERSION="${DIFYCTL_VERSION:-}"
GH_AUTH="${GITHUB_TOKEN:-${GH_TOKEN:-}}"
# fetch_json runs in a subshell, so it reports failures via this file, not a variable.
FETCH_ERR_FILE="${TMPDIR:-/tmp}/difyctl-fetcherr.$$"
API="https://api.github.com/repos/${REPO}"
DL="https://github.com/${REPO}/releases/download"
err() { printf '%s\n' "install-cli: $*" >&2; }
die() { err "$*"; exit 1; }
die() { err "$*"; rm -f "$FETCH_ERR_FILE"; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; }
re_escape() { printf '%s' "$1" | sed 's/[][\\.^$*+?(){}|/]/\\&/g'; }
@ -66,8 +70,75 @@ list_release_tags() {
| sed -E 's#.*:[[:space:]]*"([^"]*)".*#\1#'
}
# fetch_json URL -> body on stdout, 0 on success. On failure writes the cause to
# FETCH_ERR_FILE (ratelimit:<epoch> | http:<code> | network) and returns nonzero.
# Inspects the status and rate-limit headers, so it must not use curl -f.
fetch_json() {
curl -fsSL -H "Accept: application/vnd.github+json" "$1"
rm -f "$FETCH_ERR_FILE"
_hdr=$(mktemp) || return 1
_body=$(mktemp) || { rm -f "$_hdr"; return 1; }
if [ -n "$GH_AUTH" ]; then
_code=$(curl -sSL -D "$_hdr" -o "$_body" -w '%{http_code}' \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${GH_AUTH}" \
"$1" 2>/dev/null) || _code=000
else
_code=$(curl -sSL -D "$_hdr" -o "$_body" -w '%{http_code}' \
-H "Accept: application/vnd.github+json" \
"$1" 2>/dev/null) || _code=000
fi
case "$_code" in
2*) cat "$_body"; rm -f "$_hdr" "$_body"; return 0 ;;
403|429)
_rem=$(awk 'tolower($1)=="x-ratelimit-remaining:"{gsub(/\r/,"",$2);v=$2} END{print v}' "$_hdr")
if [ "$_code" = "429" ] || [ "${_rem:-}" = "0" ]; then
_rst=$(awk 'tolower($1)=="x-ratelimit-reset:"{gsub(/\r/,"",$2);v=$2} END{print v}' "$_hdr")
printf 'ratelimit:%s' "$_rst" > "$FETCH_ERR_FILE"
else
printf 'http:%s' "$_code" > "$FETCH_ERR_FILE"
fi ;;
000) printf 'network' > "$FETCH_ERR_FILE" ;;
*) printf 'http:%s' "$_code" > "$FETCH_ERR_FILE" ;;
esac
rm -f "$_hdr" "$_body"
return 1
}
# rate_limit_hint [RESET_EPOCH] -> explain the GitHub API limit and how to proceed.
rate_limit_hint() {
err "GitHub API rate limit exceeded (unauthenticated requests are capped at 60/hour per IP)."
case "${1:-}" in
'' | *[!0-9]*) ;;
*)
_now=$(date +%s 2>/dev/null) || _now=""
if [ -n "$_now" ]; then
_mins=$(( ($1 - _now + 59) / 60 ))
if [ "$_mins" -gt 0 ]; then
err "The limit resets in ~${_mins} min."
fi
fi ;;
esac
err "To proceed now, authenticate to raise the limit to 5000/hour:"
err " curl -fsSL <install-url> | GITHUB_TOKEN=<token> sh"
err "Tip: pinning DIFY_VERSION makes a single API call (DIFYCTL_VERSION scans many)."
}
# True if the last fetch_json hit a rate limit — lets a subshell bail without printing.
fetch_hit_ratelimit() {
[ -f "$FETCH_ERR_FILE" ] || return 1
case "$(cat "$FETCH_ERR_FILE" 2>/dev/null || true)" in
ratelimit:*) return 0 ;;
esac
return 1
}
# Explain and exit if the last fetch_json hit a rate limit; else return. Main shell only.
maybe_ratelimit_exit() {
fetch_hit_ratelimit || return 0
_reset=$(cat "$FETCH_ERR_FILE" 2>/dev/null || true)
rm -f "$FETCH_ERR_FILE"
rate_limit_hint "${_reset#ratelimit:}"
exit 1
}
# find_release_for_difyctl WANT TARGET -> newest Dify tag whose assets host that difyctl build
@ -75,11 +146,11 @@ find_release_for_difyctl() {
_want="$1"
_target="$2"
_raw=$(fetch_json "${API}/releases?per_page=100") \
|| die "failed to query ${REPO} releases (network error or GitHub API rate limit)"
|| { fetch_hit_ratelimit && return 1; die "failed to query ${REPO} releases (network error or GitHub API rate limit)"; }
_tags=$(printf '%s' "$_raw" | list_release_tags)
for _t in $_tags; do
_rel=$(fetch_json "${API}/releases/tags/${_t}") \
|| { err "fetch failed for ${_t}, skipping"; continue; }
|| { fetch_hit_ratelimit && return 1; err "fetch failed for ${_t}, skipping"; continue; }
_name=$(printf '%s' "$_rel" | pick_asset "$_target")
[ -n "$_name" ] || continue
if [ "$(asset_version "$_name" "$_target")" = "$_want" ]; then
@ -94,22 +165,23 @@ resolve_release() {
_target="$1"
if [ -n "$DIFY_VERSION" ]; then
REL=$(fetch_json "${API}/releases/tags/${DIFY_VERSION}") \
|| die "Dify release ${DIFY_VERSION} not found"
|| { maybe_ratelimit_exit; die "Dify release ${DIFY_VERSION} not found"; }
DIFY_TAG="$DIFY_VERSION"
elif [ -n "$DIFYCTL_VERSION" ]; then
DIFY_TAG=$(find_release_for_difyctl "$DIFYCTL_VERSION" "$_target") \
|| die "difyctl ${DIFYCTL_VERSION} not found on any Dify release"
|| { maybe_ratelimit_exit; die "difyctl ${DIFYCTL_VERSION} not found on any Dify release"; }
REL=$(fetch_json "${API}/releases/tags/${DIFY_TAG}") \
|| die "failed to fetch Dify release ${DIFY_TAG}"
|| { maybe_ratelimit_exit; die "failed to fetch Dify release ${DIFY_TAG}"; }
else
REL=$(fetch_json "${API}/releases/latest") \
|| die "failed to query latest Dify release (set DIFY_VERSION to pin one)"
|| { maybe_ratelimit_exit; die "failed to query latest Dify release (set DIFY_VERSION to pin one)"; }
DIFY_TAG=$(printf '%s' "$REL" | list_release_tags | head -1)
[ -n "$DIFY_TAG" ] || die "could not parse a tag from the latest Dify release"
fi
}
main() {
trap 'rm -f "$FETCH_ERR_FILE"' EXIT INT TERM
need curl
need uname
sort -V /dev/null >/dev/null 2>&1 || die "sort with -V support is required (install coreutils)"
@ -131,7 +203,7 @@ main() {
base="${DL}/${DIFY_TAG}"
tmp=$(mktemp -d 2>/dev/null || mktemp -d -t difyctl-install)
trap 'rm -rf "$tmp"' EXIT INT TERM
trap 'rm -rf "$tmp"; rm -f "$FETCH_ERR_FILE"' EXIT INT TERM
printf 'downloading %s (Dify %s)...\n' "$asset" "$DIFY_TAG"
curl -fsSL "${base}/${asset}" -o "${tmp}/${asset}" \

View File

@ -1,4 +1,7 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
@ -50,6 +53,24 @@ function runLib(program: string, env: Record<string, string> = {}): { code: numb
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
}
// Like runLib but with a caller-supplied fetch_json stub, so we can drive the
// real rate_limit_hint / maybe_ratelimit_exit / fetch_hit_ratelimit (which the
// script defines) by writing a classified reason to FETCH_ERR_FILE.
function runLibStub(stub: string, program: string, env: Record<string, string> = {}): { code: number, stderr: string } {
const full = `. "${SCRIPT}"\n${stub}\n${program}`
const r = spawnSync('sh', ['-c', full], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', ...env },
})
return { code: r.status ?? 1, stderr: r.stderr ?? '' }
}
// A fetch_json that always fails with the given classification, mimicking the
// real one writing to FETCH_ERR_FILE from inside a command-substitution subshell.
function failStub(reason: string): string {
return `fetch_json() { printf '%s' '${reason}' > "$FETCH_ERR_FILE"; return 1; }`
}
const REL_1142 = JSON.stringify({ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }] })
const REL_1150 = JSON.stringify({ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-linux-x64' }] })
const LIST_NEWEST_FIRST = JSON.stringify({ releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }] })
@ -190,3 +211,132 @@ describe('install-cli find_release_for_difyctl', () => {
expect(r.stderr).toContain('fetch failed for 1.15.0')
})
})
describe('install-cli rate limit', () => {
const futureReset = String(Math.floor(Date.now() / 1000) + 1800)
it('latest: reports the rate limit with reset ETA and remediation, not a generic error', () => {
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64')
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).toContain('resets in ~')
expect(r.stderr).toContain('GITHUB_TOKEN')
expect(r.stderr).not.toContain('failed to query latest')
})
it('DIFY_VERSION: rate limit wins over the misleading "not found" message', () => {
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', { DIFY_VERSION: '1.15.0' })
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('not found')
})
it('DIFYCTL_VERSION: rate limit surfaces from the nested subshell, not "not found"', () => {
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', { DIFYCTL_VERSION: '0.2.0' })
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('not found')
})
it('omits the ETA line when the reset epoch is missing', () => {
const r = runLibStub(failStub('ratelimit:'), 'resolve_release linux-x64')
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('resets in ~')
})
it('a non-rate-limit HTTP error falls back to the generic message (no false hint)', () => {
const r = runLibStub(failStub('http:500'), 'resolve_release linux-x64')
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('failed to query latest')
expect(r.stderr).not.toContain('rate limit exceeded')
})
})
// A stand-in for curl that honours the flags fetch_json passes (-D/-o/-w/-H) and
// fabricates a response per FAKE_MODE, so the tests exercise the REAL fetch_json
// (its curl invocation, header parsing, classification and token handling) rather
// than a stub. Header names it emits are lowercase, as HTTP/2 delivers them.
const FAKE_CURL = `#!/bin/sh
hdr=""; body=""
while [ $# -gt 0 ]; do
case "$1" in
-D) hdr="$2"; shift 2 ;;
-o) body="$2"; shift 2 ;;
-H) printf '%s\\n' "$2" >> "\${FAKE_HDR_LOG:-/dev/null}"; shift 2 ;;
-w) shift 2 ;;
*) shift ;;
esac
done
case "\${FAKE_MODE:-ok}" in
ok)
[ -n "$hdr" ] && printf 'HTTP/2 200\\r\\n\\r\\n' > "$hdr"
[ -n "$body" ] && printf '%s' "\${FAKE_BODY:-}" > "$body"
printf '200' ;;
ratelimit)
[ -n "$hdr" ] && printf 'HTTP/2 403\\r\\nx-ratelimit-remaining: 0\\r\\nx-ratelimit-reset: %s\\r\\n\\r\\n' "\${FAKE_RESET:-9999999999}" > "$hdr"
printf '403' ;;
perm403)
[ -n "$hdr" ] && printf 'HTTP/2 403\\r\\nx-ratelimit-remaining: 59\\r\\n\\r\\n' > "$hdr"
printf '403' ;;
notfound)
[ -n "$hdr" ] && printf 'HTTP/2 404\\r\\n\\r\\n' > "$hdr"
printf '404' ;;
net) exit 6 ;;
esac
`
// Drive the real fetch_json with FAKE_CURL first on PATH. Returns "OK|<body>" or
// "FAIL|<FETCH_ERR_FILE contents>", plus any -H lines the fake curl received.
function runRealFetch(mode: string, env: Record<string, string> = {}): { result: string, headers: string } {
const dir = mkdtempSync(join(tmpdir(), 'difyctl-fakecurl-'))
const hdrLog = join(dir, 'hdrlog')
writeFileSync(join(dir, 'curl'), FAKE_CURL)
chmodSync(join(dir, 'curl'), 0o755)
const program = 'if body=$(fetch_json "https://api.github.com/repos/x/releases/latest"); then printf \'OK|%s\' "$body"; else printf \'FAIL|%s\' "$(cat "$FETCH_ERR_FILE" 2>/dev/null)"; fi'
const r = spawnSync('sh', ['-c', `. "${SCRIPT}"\n${program}`], {
encoding: 'utf8',
env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}`, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', FAKE_MODE: mode, FAKE_HDR_LOG: hdrLog, ...env },
})
let headers = ''
try {
headers = readFileSync(hdrLog, 'utf8')
}
catch { /* no headers logged */ }
rmSync(dir, { recursive: true, force: true })
return { result: (r.stdout ?? '').trim(), headers }
}
describe('install-cli fetch_json (real, fake curl on PATH)', () => {
it('returns the response body on 200', () => {
expect(runRealFetch('ok', { FAKE_BODY: '{"tag_name":"1.15.0"}' }).result).toBe('OK|{"tag_name":"1.15.0"}')
})
it('classifies a 403 with x-ratelimit-remaining:0 as a rate limit and captures the reset', () => {
expect(runRealFetch('ratelimit', { FAKE_RESET: '1893456000' }).result).toBe('FAIL|ratelimit:1893456000')
})
it('classifies a 403 with tokens left as a plain http error, not a rate limit', () => {
expect(runRealFetch('perm403').result).toBe('FAIL|http:403')
})
it('classifies a 404 as an http error', () => {
expect(runRealFetch('notfound').result).toBe('FAIL|http:404')
})
it('classifies a curl transport failure as a network error', () => {
expect(runRealFetch('net').result).toBe('FAIL|network')
})
it('sends an Authorization bearer header when GITHUB_TOKEN is set', () => {
expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: 'ghp_secret' }).headers).toContain('Authorization: Bearer ghp_secret')
})
it('falls back to GH_TOKEN when GITHUB_TOKEN is unset', () => {
expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: 'gho_fallback' }).headers).toContain('Authorization: Bearer gho_fallback')
})
it('sends no Authorization header when neither token is set', () => {
expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: '' }).headers).not.toContain('Authorization')
})
})

View File

@ -9,6 +9,8 @@
# DIFYCTL_VERSION difyctl version pin (used only when DIFY_VERSION is unset).
# DIFYCTL_PREFIX install dir (default $env:LOCALAPPDATA\difyctl)
# DIFYCTL_REPO release source repo (default langgenius/dify)
# GITHUB_TOKEN GitHub token (or GH_TOKEN) sent as a bearer to raise the API
# rate limit from 60 to 5000 requests/hour (useful in CI).
$ErrorActionPreference = 'Stop'
@ -21,6 +23,55 @@ $apiBase = "https://api.github.com/repos/$repo"
$dlBase = "https://github.com/$repo/releases/download"
$headers = @{ Accept = 'application/vnd.github+json' }
$ghToken = if ($env:GITHUB_TOKEN) { $env:GITHUB_TOKEN } elseif ($env:GH_TOKEN) { $env:GH_TOKEN } else { $null }
if ($ghToken) { $headers.Authorization = "Bearer $ghToken" }
# Read a response header, coping with both PS7 (TryGetValues) and PS5.1 (indexer).
function Get-HeaderValue($Response, [string]$Name) {
if (-not $Response -or -not $Response.Headers) { return $null }
try {
$vals = $null
if ($Response.Headers.TryGetValues($Name, [ref]$vals)) { return ($vals | Select-Object -First 1) }
return $null
}
catch {
try { return $Response.Headers[$Name] } catch { return $null }
}
}
# $null unless the error is a GitHub rate limit; else an object with the reset epoch.
function Get-RateLimitInfo($ErrorRecord) {
$resp = $null
try { $resp = $ErrorRecord.Exception.Response } catch { return $null }
if (-not $resp) { return $null }
$status = 0; try { $status = [int]$resp.StatusCode } catch {}
if ($status -ne 403 -and $status -ne 429) { return $null }
$remaining = Get-HeaderValue $resp 'x-ratelimit-remaining'
if ($status -eq 429 -or $remaining -eq '0') {
return [pscustomobject]@{ Reset = (Get-HeaderValue $resp 'x-ratelimit-reset') }
}
return $null
}
function Write-RateLimitHint($ResetEpoch) {
[Console]::Error.WriteLine('GitHub API rate limit exceeded (unauthenticated requests are capped at 60/hour per IP).')
if ("$ResetEpoch" -match '^\d+$') {
$mins = [int][math]::Ceiling(([long]$ResetEpoch - [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()) / 60)
if ($mins -gt 0) { [Console]::Error.WriteLine("The limit resets in ~$mins min.") }
}
[Console]::Error.WriteLine('To proceed now, authenticate to raise the limit to 5000/hour:')
[Console]::Error.WriteLine(' $env:GITHUB_TOKEN = "<token>"; irm <install-url> | iex')
[Console]::Error.WriteLine('Tip: pinning DIFY_VERSION makes a single API call (DIFYCTL_VERSION scans many).')
}
# Print the hint and exit if rate-limited; else return so the caller can throw.
function Stop-IfRateLimited($ErrorRecord) {
$info = Get-RateLimitInfo $ErrorRecord
if (-not $info) { return }
Write-RateLimitHint $info.Reset
exit 1
}
function Get-AssetSemver([string]$Name) {
if ($Name -notmatch '^difyctl-v(.+?)-windows-x64\.exe$') { return $null }
$v = $Matches[1]
@ -39,7 +90,8 @@ function Select-Asset([object]$Release) {
}
function Find-ReleaseForDifyctl([string]$Want) {
$releases = Invoke-RestMethod -Uri "$apiBase/releases?per_page=100" -Headers $headers
try { $releases = Invoke-RestMethod -Uri "$apiBase/releases?per_page=100" -Headers $headers }
catch { Stop-IfRateLimited $_; throw }
foreach ($rel in $releases) {
$asset = Select-Asset $rel
if ($asset -and $asset.Version -eq $Want) { return $rel }
@ -50,7 +102,7 @@ function Find-ReleaseForDifyctl([string]$Want) {
function Resolve-Release {
if ($difyVersion) {
try { return Invoke-RestMethod -Uri "$apiBase/releases/tags/$difyVersion" -Headers $headers }
catch { throw "Dify release $difyVersion not found: $_" }
catch { Stop-IfRateLimited $_; throw "Dify release $difyVersion not found: $_" }
}
elseif ($difyctlVersion) {
$release = Find-ReleaseForDifyctl $difyctlVersion
@ -59,7 +111,7 @@ function Resolve-Release {
}
else {
try { return Invoke-RestMethod -Uri "$apiBase/releases/latest" -Headers $headers }
catch { throw "failed to query latest Dify release (set DIFY_VERSION to pin one): $_" }
catch { Stop-IfRateLimited $_; throw "failed to query latest Dify release (set DIFY_VERSION to pin one): $_" }
}
}

View File

@ -180,3 +180,79 @@ describe.skipIf(!PWSH)('install.ps1 Find-ReleaseForDifyctl', () => {
expect(r.stdout).toBe('NULL')
})
})
// Build a fake ErrorRecord whose Exception.Response is a real HttpResponseMessage
// carrying the given status + headers, matching what Get-RateLimitInfo inspects.
function fakeErr(status: number, headers: Record<string, string>): string {
const adds = Object.entries(headers)
.map(([k, v]) => `$resp.Headers.TryAddWithoutValidation('${k}','${v}') | Out-Null`)
.join('\n')
return [
`$resp = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]${status})`,
adds,
'$err = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = $resp } }',
].join('\n')
}
const futureReset = String(Math.floor(Date.now() / 1000) + 1800)
describe.skipIf(!PWSH)('install.ps1 rate limit', () => {
it('classifies a 403 with x-ratelimit-remaining:0 as rate-limited, returning the reset', () => {
const r = runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': futureReset })}
$i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { $i.Reset }`)
expect(r.code).toBe(0)
expect(r.stdout).toBe(futureReset)
})
it('does not classify a 403 with remaining tokens as rate-limited', () => {
const r = runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '59' })}
$i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { 'LIMITED' }`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
it('always treats a 429 as rate-limited', () => {
const r = runPwsh(`${fakeErr(429, {})}
$i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { 'LIMITED' }`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('LIMITED')
})
it('returns null for an error without a response (e.g. a plain string throw)', () => {
const r = runPwsh('$err = [pscustomobject]@{ Exception = [pscustomobject]@{} }; if ($null -eq (Get-RateLimitInfo $err)) { \'NULL\' } else { \'OBJ\' }')
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
it('Write-RateLimitHint prints cause, ETA, and remediation to stderr', () => {
const r = runPwsh(`Write-RateLimitHint '${futureReset}'`)
expect(r.code).toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).toContain('resets in ~')
expect(r.stderr).toContain('GITHUB_TOKEN')
})
it('Write-RateLimitHint omits the ETA line when the reset epoch is missing', () => {
const r = runPwsh('Write-RateLimitHint \'\'')
expect(r.code).toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('resets in ~')
})
it('sends an Authorization header when GITHUB_TOKEN is set', () => {
const r = runPwsh('$headers.Authorization', { GITHUB_TOKEN: 'ghp_secret123' })
expect(r.code).toBe(0)
expect(r.stdout).toBe('Bearer ghp_secret123')
})
it('Resolve-Release surfaces the rate-limit hint and exits, not "not found"', () => {
const stub = `function Invoke-RestMethod { throw [Microsoft.PowerShell.Commands.HttpResponseException]::new('rate limited', $script:rlResp) }`
const setup = `$script:rlResp = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]403)
$script:rlResp.Headers.TryAddWithoutValidation('x-ratelimit-remaining','0') | Out-Null
$script:rlResp.Headers.TryAddWithoutValidation('x-ratelimit-reset','${futureReset}') | Out-Null`
const r = runPwsh(`${setup}\n${stub}\nResolve-Release`, { DIFY_VERSION: '1.15.0' })
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('not found')
})
})