{"record":{"id":"7321b04bd13a406a","repo":"gitbutlerapp/gitbutler","slug":"stopped-listing-gitlab-jobs-for-pipeline-after","errorCode":null,"errorMessage":"Stopped listing GitLab jobs for pipeline {} after unsafe pagination state","messagePattern":"Stopped listing GitLab jobs for pipeline (.+?) after unsafe pagination state","errorType":"exception","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/but-gitlab/src/client.rs","lineNumber":672,"sourceCode":"            .json()\n            .await\n            .with_context(|| format!(\"Failed to parse GitLab pipeline for ref '{reference}'\"))?;\n\n        let pipeline_web_url = pipeline.web_url;\n        let pipeline_status = Some(pipeline.status);\n\n        let jobs_url = format!(\n            \"{}/projects/{}/pipelines/{}/jobs\",\n            self.base_url, project_id, pipeline.id\n        );\n        let mut jobs = Vec::new();\n        let mut next_page = Some(\"1\".to_string());\n        let mut seen_pages = HashSet::new();\n        let mut pages_iterated = 0;\n\n        while let Some(page) = next_page.take() {\n            if pages_iterated >= MAX_PIPELINE_JOB_PAGES || !seen_pages.insert(page.clone()) {\n                bail!(\n                    \"Stopped listing GitLab jobs for pipeline {} after unsafe pagination state\",\n                    pipeline.id\n                );\n            }\n            pages_iterated += 1;\n\n            let response = self\n                .client\n                .get(&jobs_url)\n                .query(&[(\"per_page\", \"100\"), (\"page\", page.as_str())])\n                .send()\n                .await\n                .with_context(|| {\n                    format!(\"Failed to list GitLab jobs for pipeline {}\", pipeline.id)\n                })?;\n\n            if !response.status().is_success() {\n                bail!(\"Failed to list jobs for pipeline: {}\", response.status());","sourceCodeStart":654,"sourceCodeEnd":690,"githubUrl":"https://github.com/gitbutlerapp/gitbutler/blob/caf1f223d3cfb94488c9198ad34487c6006c648f/crates/but-gitlab/src/client.rs#L654-L690","documentation":"A loop-safety bail inside list_pipeline_jobs_for_ref's pagination over GET /projects/:id/pipelines/:id/jobs?per_page=100&page=N. It fires when either guard trips: pages_iterated reaches MAX_PIPELINE_JOB_PAGES (25 pages × 100 jobs = 2500 jobs), or the X-NextPage header (read by next_page_from_headers) yields a page string already seen — i.e., the server is cycling, which would loop forever. This is a deliberate fail-fast against GitLab or an intermediary misbehaving, and against unbounded memory on monster pipelines.","triggerScenarios":"A pipeline with more than 2500 jobs (huge matrix builds, tool-generated jobs) exhausts the 25-page cap; a caching proxy or middleware rewrites/drops pagination headers so X-NextPage keeps naming a page already fetched; GitLab returns inconsistent next-page headers under load.","commonSituations":"Generated matrix pipelines (thousands of jobs) in large repos; self-hosted GitLab behind nginx/CDN that mangles Link/X-NextPage headers; API smoke tests against mocked responses that always return the same next page.","solutions":["Check the pipeline in GitLab's UI: if it genuinely has >2500 jobs, reduce the job count (matrix fan-out, needs:triggers) or raise MAX_PIPELINE_JOB_PAGES locally with a deliberate decision","If job count is modest, capture the raw response headers of one jobs request — a repeated X-NextPage/Link header points at a proxy or middleware rewriting them; fix or bypass the proxy for /api/v4","Retry once: transient header inconsistency under load usually clears","File the pipeline id and header trace in a but-gitlab issue if GitLab itself cycles pages"],"exampleFix":"// before (client-side, nothing to change in the caller's request)\nlet jobs = client.list_pipeline_jobs_for_ref(project_id, branch).await?;\n\n// after: detect the safety-bail and degrade instead of failing the whole CI view\nlet jobs = match client.list_pipeline_jobs_for_ref(project_id, branch).await {\n    Ok(jobs) => jobs,\n    Err(err) if err.to_string().contains(\"unsafe pagination state\") => {\n        tracing::warn!(%branch, \"pipeline jobs truncated; showing no CI state\");\n        Vec::new()\n    }\n    Err(err) => return Err(err),\n};","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"match client.list_pipeline_jobs_for_ref(project_id, branch).await {\n    Ok(jobs) => jobs,\n    Err(err) if err.to_string().contains(\"unsafe pagination state\") => {\n        tracing::error!(%branch, \"pipeline jobs listing hit the pagination guard (see pipeline in web UI)\");\n        Vec::new()\n    }\n    Err(err) => return Err(err),\n}","preventionTips":["Keep pipelines under ~2500 jobs (25 pages x 100) — cap matrix fan-out","Ensure proxies/CDNs in front of self-hosted GitLab pass X-NextPage/Link headers through untouched"],"tags":["gitlab","pagination","loop-guard","pipeline","jobs","rust"],"backgroundTag":"pagination-loop-guard","analyzedSha":"caf1f223d3cfb94488c9198ad34487c6006c648f","analyzedAt":"2026-08-20T07:55:40.983Z","schemaVersion":2},"datasetVersion":"2026-08-31T22:30:34.772Z"}