Skip to content

fix: handle async 202 job polling for analyze#16

Merged
greynewell merged 1 commit intosupermodeltools:mainfrom
jonathanpopham:fix/async-job-polling
Apr 2, 2026
Merged

fix: handle async 202 job polling for analyze#16
greynewell merged 1 commit intosupermodeltools:mainfrom
jonathanpopham:fix/async-job-polling

Conversation

@jonathanpopham
Copy link
Copy Markdown
Contributor

@jonathanpopham jonathanpopham commented Apr 2, 2026

Summary

  • The API returns HTTP 202 with an async job envelope ({"status":"pending","jobId":"...","retryAfter":10,"result":null}) but the client was deserializing it directly into Graph, producing 0 nodes/relationships
  • Adds polling: re-POSTs with the same idempotency key until status: "completed"
  • Extracts the graph from the nested result.graph path

Changes

  • internal/api/types.go — add JobResponse and jobResult types
  • internal/api/client.go — rewrite Analyze() with poll loop, extract postZip helper

Test results

$ supermodel analyze --force
✓ Analysis complete
Files          362
Functions      1527
Relationships  7741

Note

Depends on #15 for the correct endpoint path (/v1/graphs/supermodel). This PR keeps the endpoint as-is to avoid conflicts — once #15 merges, the analyzeEndpoint const just needs updating.

Test plan

  • supermodel analyze on a real repo returns non-zero node/relationship counts
  • Polling works for repos that take >10s to analyze
  • --force bypasses cache and re-runs analysis

Summary by CodeRabbit

  • New Features
    • Analysis now runs as an asynchronous job with polling, improving reliability for longer uploads.
    • Automatic retry/wait behavior added between attempts to reduce transient failures.
  • Bug Fixes
    • Improved error surfacing for analysis operations and clearer completed/failed status handling.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 2, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d195590c-9bb4-4ff1-8d82-0011f6d8964a

📥 Commits

Reviewing files that changed from the base of the PR and between cb38984 and ae6a423.

📒 Files selected for processing (2)
  • internal/api/client.go
  • internal/api/types.go

Walkthrough

The API client was refactored to an asynchronous job flow: Analyze now uploads a ZIP via postZip, polls the returned JobResponse (respecting retryAfter and ctx.Done()), and decodes the final result into a Graph only when status == "completed".

Changes

Cohort / File(s) Summary
Async Job Processing
internal/api/client.go, internal/api/types.go
Added exported JobResponse and internal jobResult types. Extracted postZip helper to POST multipart ZIP to analyzeEndpoint. Refactored Analyze to poll job status (uses RetryAfter fallback to 5s), handle error field, and unmarshal final result into Graph.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Analyze
    participant postZip
    participant Server

    Client->>Analyze: Analyze(ctx, zip)
    loop poll until done or error
        Analyze->>postZip: postZip(ctx, zip)
        postZip->>Server: POST /.../analyze (multipart ZIP)
        Server-->>postZip: JobResponse(status, jobId, retryAfter, error?, result?)
        postZip-->>Analyze: return JobResponse
        alt status == "completed"
            Analyze->>Analyze: unmarshal JobResponse.Result -> jobResult.Graph
            Analyze-->>Client: return *Graph
        else status == "pending" or "processing"
            Analyze->>Analyze: wait retryAfter (fallback 5s) or ctx.Done()
        else error set or unexpected status
            Analyze-->>Client: return error
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

A ZIP sets sail, requests take flight ✈️
Polls reply in day or night ⏳
RetryAfter guides the pace,
Until the graph takes its place 🌿
Async wins with quiet grace.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: implementing async 202 job polling for the analyze endpoint, which is the core objective of this PR.
Description check ✅ Passed The description covers the key sections: What (async polling issue), Why (API behavior changed), and How (JobResponse types + polling logic). Test results are provided, though the test plan checklist items are incomplete.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/api/client.go`:
- Around line 72-76: Check for null/empty payloads before attempting to
unmarshal the job result: in the code that uses json.Unmarshal into jobResult
(the jobResult type and the result variable), first verify job.Result is not nil
and not an empty/whitespace/"null" payload (e.g., len==0 or trimmed equals
"null") and return a descriptive error if it is; after unmarshalling, also
validate that result.Graph is present/non-empty and return an error if the graph
is missing so a completed job with result:null cannot silently succeed.
- Around line 47-63: The polling loop in internal/api/client.go that checks
job.Status and calls c.postZip can hang indefinitely; add a hard stop by
introducing a max poll guard (e.g., maxPollDuration or maxPollAttempts) measured
from a start time (time.Now()) or a counter, and after the limit is exceeded
return a clear error (or context.DeadlineExceeded) instead of looping forever;
keep existing handling of job.RetryAfter and the ctx.Done() case, but before
each sleep/iteration check the elapsed time or attempts and bail out with an
error referencing the job/idempotency context so callers can handle it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 67f8d546-e468-4ded-8491-b68a54874a37

📥 Commits

Reviewing files that changed from the base of the PR and between 5093d4f and cb38984.

📒 Files selected for processing (2)
  • internal/api/client.go
  • internal/api/types.go

Comment on lines +47 to +63
// Poll until the job completes.
for job.Status == "pending" || job.Status == "processing" {
wait := time.Duration(job.RetryAfter) * time.Second
if wait <= 0 {
wait = 5 * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}

job, err = c.postZip(ctx, zipPath, idempotencyKey)
if err != nil {
return nil, err
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add a hard stop for polling to avoid indefinite hangs.

Right now, Line 48 can loop forever if the API never reaches a terminal state. At call sites (internal/analyze/handler.go Line 57, internal/find/handler.go Line 161), ctx is passed through without a timeout wrapper, so this can block indefinitely.

Suggested guardrail
 func (c *Client) Analyze(ctx context.Context, zipPath, idempotencyKey string) (*Graph, error) {
+	if _, hasDeadline := ctx.Deadline(); !hasDeadline {
+		var cancel context.CancelFunc
+		ctx, cancel = context.WithTimeout(ctx, defaultTimeout)
+		defer cancel()
+	}
+
 	job, err := c.postZip(ctx, zipPath, idempotencyKey)
 	if err != nil {
 		return nil, err
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/client.go` around lines 47 - 63, The polling loop in
internal/api/client.go that checks job.Status and calls c.postZip can hang
indefinitely; add a hard stop by introducing a max poll guard (e.g.,
maxPollDuration or maxPollAttempts) measured from a start time (time.Now()) or a
counter, and after the limit is exceeded return a clear error (or
context.DeadlineExceeded) instead of looping forever; keep existing handling of
job.RetryAfter and the ctx.Done() case, but before each sleep/iteration check
the elapsed time or attempts and bail out with an error referencing the
job/idempotency context so callers can handle it.

Comment on lines +72 to +76
var result jobResult
if err := json.Unmarshal(job.Result, &result); err != nil {
return nil, fmt.Errorf("decode graph result: %w", err)
}
return &result.Graph, nil
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against completed responses with null/empty result payload.

A completed job with result: null would currently decode to an empty graph and look like success. Fail fast here so bad server payloads don’t silently pass.

Suggested validation
 	var result jobResult
+	if trimmed := bytes.TrimSpace(job.Result); len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
+		return nil, fmt.Errorf("analysis completed without graph result")
+	}
 	if err := json.Unmarshal(job.Result, &result); err != nil {
 		return nil, fmt.Errorf("decode graph result: %w", err)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/client.go` around lines 72 - 76, Check for null/empty payloads
before attempting to unmarshal the job result: in the code that uses
json.Unmarshal into jobResult (the jobResult type and the result variable),
first verify job.Result is not nil and not an empty/whitespace/"null" payload
(e.g., len==0 or trimmed equals "null") and return a descriptive error if it is;
after unmarshalling, also validate that result.Graph is present/non-empty and
return an error if the graph is missing so a completed job with result:null
cannot silently succeed.

@jonathanpopham jonathanpopham marked this pull request as draft April 2, 2026 16:43
@greynewell greynewell force-pushed the fix/async-job-polling branch from cb38984 to 1a81429 Compare April 2, 2026 17:05
The /v1/graphs/supermodel endpoint returns HTTP 202 with an async job
envelope (status, jobId, retryAfter, result). The client was treating
this as a completed response and unmarshalling directly into Graph,
resulting in 0 nodes/relationships.

Changes:
- Add JobResponse and jobResult types for the async envelope
- Poll with the same idempotency key until status is "completed"
- Extract graph from the nested result.graph path
- Extract postZip helper to avoid duplicating multipart construction

Tested live against api.supermodeltools.com — correctly returns
362 files, 1527 functions, 7741 relationships for supermodel-public-api.
@greynewell greynewell force-pushed the fix/async-job-polling branch from 1a81429 to ae6a423 Compare April 2, 2026 17:06
@greynewell greynewell marked this pull request as ready for review April 2, 2026 17:06
@greynewell greynewell merged commit dd3d10c into supermodeltools:main Apr 2, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants