santifer/career-ops · error · Error

too many redirects (> ) for

Error message

too many redirects (>${MAX_REDIRECTS}) for ${url}

What it means

The guarded fetch wrapper follows up to MAX_REDIRECTS redirects, re-validating each hop against the egress policy (hostOk/SSRF checks). If it exceeds that limit, it gives up and throws this error naming the original URL — protecting against redirect loops and redirect-based sandbox escapes.

Solutions

  1. Fetch the final URL directly instead of following the redirect chain
  2. Fix the server's redirect loop (check http→https and www→apex rules)
  3. Increase MAX_REDIRECTS only if the chain is legitimately long — prefer not to, since it loosens the sandbox guard
  4. Test the URL with curl -IL to see the redirect chain and where it loops

Example fix

// before
await ctx.fetch('http://bit.ly/old-link'); // loops past MAX_REDIRECTS
// Error: too many redirects (>3) for http://bit.ly/old-link
// after: resolve the chain once, use the final destination
await ctx.fetch('https://example.com/final/destination');
Defensive patterns

Strategy: retry

Validate before calling

// Pre-flight: resolve the redirect chain outside the sandbox limit
// curl -sIL -o /dev/null -w '%{url_effective}' <url>  → use the effective URL
const finalUrl = await resolveRedirectsOnce(url); // via fetch with redirect:'manual', bounded
await ctx.fetch(finalUrl);

Type guard

null

Try / catch

try { return await ctx.fetch(url); } catch (e) { if (String(e.message).startsWith('too many redirects')) { /* resolve the final URL once, then retry against it */ } throw e; }

Prevention

When it happens

Trigger: A plugin fetch whose target responds with a redirect chain longer than MAX_REDIRECTS, including redirect loops (A → B → A) or misconfigured servers bounce-looping between www/apex or http/https.

Common situations: A stale short-link or tracking URL in a redirect loop; a server with broken TLS redirecting http→https→http; a login page bouncing unauthenticated requests between endpoints.

Related errors


AI-assisted analysis of santifer/career-ops@aac998c7ed (2026-09-16). Data as JSON: /api/errors/989b18ba357e978c. Report an issue: GitHub.

Appendix: source

Thrown at plugins/_engine.mjs:436

        await resolveAndValidate(next.hostname, { allowsLocalhost });
        if (next.hostname !== current.hostname) {
          // Don't forward credentials across a hostname change (what the
          // platform fetch does for cross-origin redirects; we do it manually).
          reqHeaders = Object.fromEntries(Object.entries(reqHeaders).filter(([k]) => !/^(authorization|cookie)$/i.test(k)));
        }
        current = next;
        continue;
      }
      if (!res.ok) {
        const snippet = (await res.text().catch(() => '')).replace(/\s+/g, ' ').trim().slice(0, 300);
        const err = new Error(snippet ? `HTTP ${res.status}: ${snippet}` : `HTTP ${res.status}`);
        // @ts-ignore
        err.status = res.status;
        throw err;
      }
      return res;
    }
    throw new Error(`too many redirects (>${MAX_REDIRECTS}) for ${url}`);
  };
}

/**
 * Build the least-privilege ctx for a plugin. The scoped frozen env is a
 * CONVENIENCE (process.env is still globally reachable from any module) — the
 * real boundary is code review + trust.
 * @param {PluginManifestNormalized} manifest
 * @param {{ dryRun?: boolean, settings?: object }} [opts]
 * @returns {PluginContext}
 */
export function buildCtx(manifest, opts = {}) {
  const scoped = {};
  for (const name of [...manifest.requiredEnv, ...manifest.optionalEnv]) {
    if (process.env[name] !== undefined) scoped[name] = process.env[name];
  }
  const env = Object.freeze({ ...scoped });
  // Secret values long enough to be worth redacting (avoid no-op/over-redaction

View on GitHub (pinned to aac998c7ed)